From 237fbca3af5cf1ec0fbdbb33dc1c3a3efc7d3782 Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:04:39 +0530 Subject: [PATCH] fix: Synthetic check runs table sorting issue (#13477) ## What changed This PR makes the Synthetics runs table sortable (with a default sort by last-run, newest first) on all columns, using raw underlying values (e.g. `durationMs`, `statusRank`, `locationName`) instead of the formatted display strings the cells render. It also removes a `tableFilterKey`-based force re-render hack and an inline error-badge cell/`filterByError` helper that duplicated existing `filterByErrorPattern` filtering. Separately, it fixes a loading-state flash in `RunDetail.vue`/`ProtocolRunSummary.vue`: `MonitorResults.vue` now passes a resolved `overrideMonitorType` down to `RunDetail`, `RunDetail` seeds `monitorType` from that override in drawer mode to skip a redundant fetch, and `loadRun()` now sets `synthetics.loading.value = true` synchronously so the skeleton shows instead of an empty/wrong-branch UI while monitor type resolves. A new `ProtocolRunSummarySkeleton.vue` component replaces a generic `OSkeleton` block to mirror the actual result-card layout. The runs query limit in `useSyntheticResults.ts` was bumped from 500 to 1000, and the runs page size default is kept at 10. ## Why Reviewing `MonitorRuns.vue`'s pagination, default sort, and column sorting surfaced that columns weren't sortable and had no default sort, and that sorting on formatted values would produce incorrect ordering (e.g. "900ms" sorting after "1.2s", or status ordering depending on locale). Separately, the session's testing turned up a UI flash where `RunDetail`/`ProtocolRunSummary` briefly rendered the wrong branch (empty state or "browser" default) before `fetchCheck()`/monitor-type resolution completed. --- .../results/ProtocolRunSummary.spec.ts | 2 +- .../synthetics/results/ProtocolRunSummary.vue | 4 +- .../ProtocolRunSummarySkeleton.spec.ts | 55 ++++ .../results/ProtocolRunSummarySkeleton.vue | 35 +++ web/src/composables/useSyntheticResults.ts | 2 +- web/src/locales/languages/en-US.json | 1 + web/src/views/SyntheticMonitoring.spec.ts | 251 +++++++++++++++++- web/src/views/SyntheticMonitoring.vue | 122 +++++++-- .../views/synthetics/MonitorResults.spec.ts | 38 +++ web/src/views/synthetics/MonitorResults.vue | 10 + web/src/views/synthetics/MonitorRuns.spec.ts | 103 +++++++ web/src/views/synthetics/MonitorRuns.vue | 82 +++--- web/src/views/synthetics/RunDetail.spec.ts | 178 ++++++++++--- web/src/views/synthetics/RunDetail.vue | 14 +- 14 files changed, 795 insertions(+), 102 deletions(-) create mode 100644 web/src/components/synthetics/results/ProtocolRunSummarySkeleton.spec.ts create mode 100644 web/src/components/synthetics/results/ProtocolRunSummarySkeleton.vue diff --git a/web/src/components/synthetics/results/ProtocolRunSummary.spec.ts b/web/src/components/synthetics/results/ProtocolRunSummary.spec.ts index 8e0fc2b62b..9a184b1b88 100644 --- a/web/src/components/synthetics/results/ProtocolRunSummary.spec.ts +++ b/web/src/components/synthetics/results/ProtocolRunSummary.spec.ts @@ -148,7 +148,7 @@ describe("ProtocolRunSummary", () => { wrapper = mountComponent(); await flushPromises(); - expect(wrapper.find('[data-test="oskeleton"]').exists()).toBe(true); + expect(wrapper.find('[data-test="synthetics-protocol-run-skeleton"]').exists()).toBe(true); expect(wrapper.find('[data-test="oempty-state"]').exists()).toBe(false); }); }); diff --git a/web/src/components/synthetics/results/ProtocolRunSummary.vue b/web/src/components/synthetics/results/ProtocolRunSummary.vue index 1f2ebf1eb1..86e094c19f 100644 --- a/web/src/components/synthetics/results/ProtocolRunSummary.vue +++ b/web/src/components/synthetics/results/ProtocolRunSummary.vue @@ -11,8 +11,8 @@ import { useStore } from "vuex"; import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue"; import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue"; import OBadge from "@/lib/core/Badge/OBadge.vue"; -import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue"; import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue"; +import ProtocolRunSummarySkeleton from "./ProtocolRunSummarySkeleton.vue"; import useSyntheticResults from "@/composables/useSyntheticResults"; import syntheticsService from "@/services/synthetics"; import type { HttpAssertion } from "@/types/synthetics"; @@ -221,7 +221,7 @@ const showAssertions = computed(() => run.value?.type === "http" && assertionDef
- + ({ + useI18n: vi.fn(() => ({ t: (key: string) => key })), +})); + +vi.mock("@/lib/feedback/Skeleton/OSkeleton.vue", () => ({ + default: { + name: "OSkeleton", + template: '
Loading...
', + }, +})); + +import ProtocolRunSummarySkeleton from "./ProtocolRunSummarySkeleton.vue"; + +describe("ProtocolRunSummarySkeleton", () => { + let wrapper: VueWrapper; + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllMocks(); + }); + + function mountComponent() { + return mount(ProtocolRunSummarySkeleton); + } + + it("should render the root status container with correct aria attributes and data-test", () => { + wrapper = mountComponent(); + + const root = wrapper.find('[data-test="synthetics-protocol-run-skeleton"]'); + expect(root.exists()).toBe(true); + expect(root.attributes("role")).toBe("status"); + expect(root.attributes("aria-label")).toBe("synthetics.protocolRun.loading"); + expect(root.attributes("aria-live")).toBe("polite"); + }); + + it("should render exactly 4 grid cells", () => { + wrapper = mountComponent(); + + const grid = wrapper.find(".grid-cols-2"); + expect(grid.exists()).toBe(true); + expect(grid.element.children.length).toBe(4); + }); + + it("should render the accent-strip header element", () => { + wrapper = mountComponent(); + + const accentStrip = wrapper.find(".bg-accent"); + expect(accentStrip.exists()).toBe(true); + }); +}); diff --git a/web/src/components/synthetics/results/ProtocolRunSummarySkeleton.vue b/web/src/components/synthetics/results/ProtocolRunSummarySkeleton.vue new file mode 100644 index 0000000000..4890664ae9 --- /dev/null +++ b/web/src/components/synthetics/results/ProtocolRunSummarySkeleton.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/composables/useSyntheticResults.ts b/web/src/composables/useSyntheticResults.ts index 0daaec86a5..7a64da5a04 100644 --- a/web/src/composables/useSyntheticResults.ts +++ b/web/src/composables/useSyntheticResults.ts @@ -251,7 +251,7 @@ export function useSyntheticResults() { // Group 3: Runs list — feeds timeline, breakdown cards, table, // steps tab, and errors tab. Typically the slowest query. const runsPromise = executeQuery( - buildRunsSql(monitorId, 500, schemaFields), + buildRunsSql(monitorId, 1000, schemaFields), startTime, endTime, "logs", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 1c6e9ef733..aaf9eb85ef 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -9740,6 +9740,7 @@ "error": "Error", "failed": "Failed", "init": "init", + "loading": "Loading run details", "location": "Location", "notFound": "Run not found", "passed": "Passed", diff --git a/web/src/views/SyntheticMonitoring.spec.ts b/web/src/views/SyntheticMonitoring.spec.ts index 8c1d34c81c..e38feb9d45 100644 --- a/web/src/views/SyntheticMonitoring.spec.ts +++ b/web/src/views/SyntheticMonitoring.spec.ts @@ -48,7 +48,8 @@ const { mockServiceGetAgentSetup: vi .fn() .mockResolvedValue({ data: { install: "curl ...", token: "abc123" } }), - mockRouterPush: vi.fn(), + // router.push returns a Promise in vue-router; callers here chain .catch() on it. + mockRouterPush: vi.fn().mockResolvedValue(undefined), })); // ── Module mocks ───────────────────────────────────────────────────────── @@ -92,10 +93,17 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({ vi.mock("@/utils/synthetics/buildPayload", () => ({ mapResponseToBrowserCheck: vi.fn((data: any) => data), - buildCreateBrowserTestPayload: vi.fn((data: any) => data), + buildCreateBrowserTestPayload: vi.fn((data: any) => ({ ...data, type: "browser" })), + mapResponseToProtocolCheck: vi.fn((data: any) => ({ ...data, checkType: data.type })), + buildCreateProtocolCheckPayload: vi.fn((data: any) => ({ ...data, type: data.checkType })), })); import SyntheticMonitoring from "./SyntheticMonitoring.vue"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import { + buildCreateBrowserTestPayload, + buildCreateProtocolCheckPayload, +} from "@/utils/synthetics/buildPayload"; // ── Test helpers ───────────────────────────────────────────────────────── @@ -130,6 +138,12 @@ const baseStubs = { template: '
', props: ["type", "moduleId", "activeFolderId", "open"], }, + SelectFolderDropDown: { + name: "SelectFolderDropDown", + template: "
", + props: ["type", "activeFolderId", "disableDropdown"], + emits: ["folder-selected"], + }, ODropdown: { template: '
', @@ -504,4 +518,237 @@ describe("SyntheticMonitoring", () => { expect((wrapper.vm as any).showSetupDrawer).toBe(false); }); }); + + // ═══════════════════════════════════════════════════════════════════════ + // Duplicate Check dialog — folder picker, folder-scoped requests + // ═══════════════════════════════════════════════════════════════════════ + describe("duplicate check dialog", () => { + const row = { id: "m-1", name: "Checkout flow", folderId: "f-2" }; + + const findTable = () => + wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]'); + const findDialog = () => + wrapper.findComponent('[data-test="synthetic-monitoring-duplicate-dialog"]'); + const findFolderSelect = () => wrapper.findComponent({ name: "SelectFolderDropDown" }); + + /** Opens the dialog for a row and waits for the source fetch to settle. */ + const openDuplicate = async (monitor: any = row) => { + findTable().vm.$emit("duplicate", monitor); + await flushPromises(); + }; + + beforeEach(() => { + mockServiceGet.mockResolvedValue({ + data: { id: "m-1", name: "Checkout flow", target: "https://example.com" }, + }); + mockServiceCreate.mockResolvedValue({ data: { id: "m-2" } }); + }); + + it("prefills the folder dropdown with the row's own folder", async () => { + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + + expect(findDialog().exists()).toBe(true); + expect( + wrapper.find('[data-test="synthetic-monitoring-duplicate-folder-select"]').exists(), + ).toBe(true); + expect(findFolderSelect().props("activeFolderId")).toBe("f-2"); + expect(findFolderSelect().props("type")).toBe("synthetics"); + }); + + it("falls back to the active folder when the row carries no folderId", async () => { + wrapper = mountPage(); + await flushPromises(); + await openDuplicate({ id: "m-9", name: "No folder" }); + + expect(findFolderSelect().props("activeFolderId")).toBe("default"); + }); + + it("fetches the source check scoped to the row's folder when opened", async () => { + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + + expect(mockServiceGet).toHaveBeenCalledTimes(1); + const [, id, folderId] = mockServiceGet.mock.calls[0]; + expect(id).toBe("m-1"); + expect(folderId).toBe("f-2"); + }); + + it("closes the dialog and does not create when the source fetch fails", async () => { + mockServiceGet.mockRejectedValueOnce({ response: { data: { message: "boom" } } }); + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + + expect(findDialog().exists()).toBe(false); + expect(mockServiceCreate).not.toHaveBeenCalled(); + }); + + it("creates the copy in the folder chosen in the dropdown", async () => { + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + + findFolderSelect().vm.$emit("folder-selected", { label: "Ops", value: "f-9" }); + await nextTick(); + + findDialog().vm.$emit("click:primary"); + await flushPromises(); + + expect(mockServiceCreate).toHaveBeenCalledTimes(1); + const [, payload, folderId] = mockServiceCreate.mock.calls[0]; + // The payload folder and the ?folder= RBAC scope must agree. + expect((payload as any).folder).toBe("f-9"); + expect(folderId).toBe("f-9"); + expect((payload as any).id).toBeUndefined(); + }); + + it("follows the copy into its destination folder with a single reload", async () => { + wrapper = mountPage(); + await flushPromises(); + const listCallsAfterMount = mockServiceList.mock.calls.length; + + await openDuplicate(); + findFolderSelect().vm.$emit("folder-selected", { label: "Ops", value: "f-9" }); + await nextTick(); + findDialog().vm.$emit("click:primary"); + await flushPromises(); + + expect((wrapper.vm as any).activeFolderId).toBe("f-9"); + expect(mockServiceList.mock.calls.length - listCallsAfterMount).toBe(1); + }); + + // buildCreateBrowserTestPayload hardcodes type: "browser", so a protocol + // check run through it would come back as a browser check. + describe("check type", () => { + const duplicateWithType = async (type: string) => { + mockServiceGet.mockResolvedValue({ + data: { id: "m-1", name: "API health", type, schedule: {} }, + }); + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + findDialog().vm.$emit("click:primary"); + await flushPromises(); + return mockServiceCreate.mock.calls[0][1] as any; + }; + + it.each(["http", "tcp", "tls", "ssh"])("preserves a %s check's type", async (type) => { + const payload = await duplicateWithType(type); + + expect(payload.type).toBe(type); + expect(vi.mocked(buildCreateProtocolCheckPayload)).toHaveBeenCalled(); + expect(vi.mocked(buildCreateBrowserTestPayload)).not.toHaveBeenCalled(); + }); + + it("uses the browser builder for a browser check", async () => { + const payload = await duplicateWithType("browser"); + + expect(payload.type).toBe("browser"); + expect(vi.mocked(buildCreateBrowserTestPayload)).toHaveBeenCalled(); + expect(vi.mocked(buildCreateProtocolCheckPayload)).not.toHaveBeenCalled(); + }); + }); + + // The API rejects a start in the past, and mapFrequencyToSchedule reports + // every saved check as "Schedule Later" with the date it originally started. + describe("schedule start rebasing", () => { + const pastStart = 1_600_000_000_000_000; // µs — Sep 2020 + + it("resets a past start to 'Schedule Now'", async () => { + mockServiceGet.mockResolvedValue({ + data: { + id: "m-1", + name: "Checkout flow", + start: pastStart, + schedule: { startType: "later", startDate: "2020-09-13", startTime: "12:26" }, + }, + }); + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + findDialog().vm.$emit("click:primary"); + await flushPromises(); + + const [, payload] = mockServiceCreate.mock.calls[0]; + expect((payload as any).schedule.startType).toBe("now"); + expect((payload as any).schedule.startDate).toBeUndefined(); + expect((payload as any).schedule.startTime).toBeUndefined(); + }); + + // buildPayload is mocked to identity above, so the tests around this one + // only prove the view sets startType. This one pins the contract the fix + // relies on, using the real builder. + it("real buildCreateBrowserTestPayload emits a current start for 'now' and replays a past one for 'later'", async () => { + const actual = await vi.importActual( + "@/utils/synthetics/buildPayload", + ); + const source = actual.mapResponseToBrowserCheck({ + name: "Checkout flow", + target: "https://example.com", + folder_id: "f-2", + start: pastStart, + frequency: { type: "minutes", interval: 5, timezone: "UTC" }, + config: { steps: [] }, + }); + + // As read back from the API: "later", replaying the original start — + // in the past, which is what the server rejects. (Not exactly + // pastStart: the round-trip through HH:mm truncates to the minute.) + expect(source.schedule.startType).toBe("later"); + const asIs = actual.buildCreateBrowserTestPayload({ ...source }) as any; + expect(asIs.start).toBeLessThan(Date.now() * 1000); + expect(asIs.start).toBeCloseTo(pastStart, -8); // same minute + + // What saveDuplicate submits instead. computeStart truncates to the + // minute, so allow up to 60s of backdating. + const rebased = actual.buildCreateBrowserTestPayload({ + ...source, + schedule: { ...source.schedule, startType: "now" }, + } as any) as any; + expect(rebased.start).toBeGreaterThan((Date.now() - 60_000) * 1000); + }); + + it("preserves a start that is still in the future", async () => { + const futureStart = (Date.now() + 7 * 24 * 60 * 60 * 1000) * 1000; + mockServiceGet.mockResolvedValue({ + data: { + id: "m-1", + name: "Checkout flow", + start: futureStart, + schedule: { startType: "later", startDate: "2099-01-01", startTime: "09:00" }, + }, + }); + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + findDialog().vm.$emit("click:primary"); + await flushPromises(); + + const [, payload] = mockServiceCreate.mock.calls[0]; + expect((payload as any).schedule).toEqual({ + startType: "later", + startDate: "2099-01-01", + startTime: "09:00", + }); + }); + }); + + it("closes the dialog without an extra error toast on a 403", async () => { + mockServiceCreate.mockRejectedValueOnce({ response: { status: 403 } }); + wrapper = mountPage(); + await flushPromises(); + await openDuplicate(); + + findDialog().vm.$emit("click:primary"); + await flushPromises(); + + expect(findDialog().exists()).toBe(false); + expect(vi.mocked(toast)).not.toHaveBeenCalledWith( + expect.objectContaining({ variant: "error" }), + ); + }); + }); }); diff --git a/web/src/views/SyntheticMonitoring.vue b/web/src/views/SyntheticMonitoring.vue index 943fe5f9de..da09bc5f3a 100644 --- a/web/src/views/SyntheticMonitoring.vue +++ b/web/src/views/SyntheticMonitoring.vue @@ -192,11 +192,12 @@ - + +
+ +
@@ -321,10 +328,13 @@ import AgentSetupDrawer from "@/components/synthetic-monitoring/AgentSetupDrawer import CheckTypePicker from "@/components/synthetics/CheckTypePicker.vue"; import FolderList from "@/components/common/sidebar/FolderList.vue"; import MoveAcrossFolders from "@/components/common/sidebar/MoveAcrossFolders.vue"; +import SelectFolderDropDown from "@/components/common/sidebar/SelectFolderDropDown.vue"; import BetaBadge from "@/components/common/BetaBadge.vue"; import { mapResponseToBrowserCheck, buildCreateBrowserTestPayload, + mapResponseToProtocolCheck, + buildCreateProtocolCheckPayload, } from "@/utils/synthetics/buildPayload"; import { SYNTHETIC_CHECK_TYPES, @@ -545,6 +555,7 @@ const activeFolderName = computed(() => { watch(activeFolderId, async (newFolderId) => { selectedMonitorIds.value = []; + duplicateFolder.value = newFolderId; if (searchAcrossFolders.value) { searchAcrossFolders.value = false; } @@ -578,7 +589,12 @@ const monitorsToMove = ref([]); const showDuplicateDialog = ref(false); const duplicateTarget = ref(null); const duplicateName = ref(""); -const duplicateFolder = ref("default"); +const duplicateFolder = ref(activeFolderId.value); +// Full check fetched when the dialog opens — the duplicate is built from this, +// so submitting stays instant and a failed fetch surfaces before the form is filled. +const duplicateSource = ref(null); +// "browser" | "http" | "tcp" | "tls" | "ssh" — picks the mapper/payload builder. +const duplicateSourceType = ref("browser"); const isDuplicating = ref(false); const openBulkDeleteConfirm = () => { @@ -1042,40 +1058,100 @@ async function toggleEnabled(m: any) { } } -function duplicateMonitor(m: any) { +/** + * A duplicate is a brand-new check, so it cannot inherit the original's start + * timestamp: mapFrequencyToSchedule reports every saved check as "Schedule + * Later" with the date/time it originally started, and the API rejects a start + * in the past ("start must not be in the past"). Keep a start that is still in + * the future — duplicating a check scheduled for next week should stay + * scheduled — and otherwise fall back to "Schedule Now". + */ +function rebaseScheduleStart(schedule: any, start?: number) { + // `start` is microseconds on the wire. + if (typeof start === "number" && start / 1000 > Date.now()) return { ...schedule }; + const { startDate: _startDate, startTime: _startTime, ...rest } = schedule ?? {}; + return { ...rest, startType: "now" as const }; +} + +async function duplicateMonitor(m: any) { duplicateTarget.value = m; duplicateName.value = t("synthetics.labels.copyOf", { name: m.name }); - duplicateFolder.value = m.folder || "default"; + // The row's own folder, falling back to the folder being browsed. These are + // the same value unless "search across folders" is on, in which case the copy + // should land beside its original rather than jump to the active folder. + duplicateFolder.value = m.folderId || activeFolderId.value || "default"; + duplicateSource.value = null; showDuplicateDialog.value = true; + try { + const res = await syntheticsService.get( + orgIdentifier.value, + String(m.id), + m.folderId ?? activeFolderId.value, + ); + const data = res.data as Record; + // Browser and protocol (http/tcp/tls/ssh) checks have separate mappers and + // payload builders — the browser builder hardcodes type: "browser", so + // running a protocol check through it would silently convert it. + duplicateSourceType.value = String(data.type ?? "browser").toLowerCase(); + duplicateSource.value = + duplicateSourceType.value === "browser" + ? mapResponseToBrowserCheck(data) + : mapResponseToProtocolCheck(data); + } catch (err: any) { + showDuplicateDialog.value = false; + toast({ + variant: "error", + message: + err?.response?.data?.message || + err?.response?.data?.error || + t("synthetics.toast.duplicateFailed"), + }); + console.error("[synthetics] failed to load check for duplication", err); + } } async function saveDuplicate() { - if (!duplicateTarget.value) return; + if (!duplicateSource.value || !duplicateName.value.trim()) { + toast({ variant: "error", message: t("synthetics.toast.duplicateFailed") }); + return; + } isDuplicating.value = true; const dismiss = toast({ variant: "loading", message: t("synthetics.toast.duplicating"), timeout: 0, }); + const targetFolder = duplicateFolder.value; try { - const org = orgIdentifier.value; - const res = await syntheticsService.get( - org, - String(duplicateTarget.value.id), - activeFolderId.value, - ); - const check = mapResponseToBrowserCheck(res.data as Record); - check.name = duplicateName.value; - check.folder = duplicateFolder.value; + const check = { + ...duplicateSource.value, + name: duplicateName.value, + folder: targetFolder, + schedule: rebaseScheduleStart(duplicateSource.value.schedule, duplicateSource.value.start), + }; delete (check as any).id; - const payload = buildCreateBrowserTestPayload(check); - await syntheticsService.create(org, payload); + const payload = + duplicateSourceType.value === "browser" + ? buildCreateBrowserTestPayload(check) + : buildCreateProtocolCheckPayload(check); + await syntheticsService.create(orgIdentifier.value, payload, targetFolder); dismiss(); toast({ variant: "success", message: t("synthetics.toast.duplicateSuccess") }); showDuplicateDialog.value = false; - await loadMonitors(); + // Follow the copy into its folder. Assigning activeFolderId triggers its + // watcher, which reloads — so only call loadMonitors on the other branch. + if (!searchAcrossFolders.value && targetFolder !== activeFolderId.value) { + activeFolderId.value = targetFolder; + } else { + await loadMonitors(); + } } catch (err: any) { dismiss(); + // 403s are already surfaced by the http interceptor. + if (err?.response?.status === 403) { + showDuplicateDialog.value = false; + return; + } toast({ variant: "error", message: diff --git a/web/src/views/synthetics/MonitorResults.spec.ts b/web/src/views/synthetics/MonitorResults.spec.ts index 4d243bddb7..38190fa64d 100644 --- a/web/src/views/synthetics/MonitorResults.spec.ts +++ b/web/src/views/synthetics/MonitorResults.spec.ts @@ -150,6 +150,7 @@ function makeWrapper() { "overrideMonitorName", "overrideRunId", "overrideExecutionId", + "overrideMonitorType", ], }, BetaBadge: { @@ -266,6 +267,43 @@ describe("MonitorResults", () => { expect(wrapper.find('[data-test="run-detail"]').exists()).toBe(true); }); + + it("should pass an empty override-monitor-type to RunDetail until fetchCheck resolves", async () => { + let resolveFetch: (value: any) => void; + mockSyntheticsServiceGet.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + wrapper = makeWrapper(); + await flushPromises(); + + // fetchCheck() hasn't settled yet — RunDetail must not receive the + // possibly-stale "browser" default. + let runDetail = wrapper.findComponent('[data-test="run-detail"]'); + expect(runDetail.props("overrideMonitorType")).toBe(""); + + resolveFetch!({ data: { type: "api", last_triggered_at: 0 } }); + await flushPromises(); + + // fetchCheck() has now resolved with the real type. + runDetail = wrapper.findComponent('[data-test="run-detail"]'); + expect(runDetail.props("overrideMonitorType")).toBe("api"); + }); + + it("should resolve override-monitor-type to the default type when fetchCheck fails with a non-404 error", async () => { + mockSyntheticsServiceGet.mockRejectedValueOnce({ response: { status: 500 } }); + + wrapper = makeWrapper(); + await flushPromises(); + + // The finally block still flips checkTypeReady even on a non-404 + // error, exposing the untouched "browser" default. + const runDetail = wrapper.findComponent('[data-test="run-detail"]'); + expect(runDetail.props("overrideMonitorType")).toBe("browser"); + }); }); describe("auto-open drawer from URL query params", () => { diff --git a/web/src/views/synthetics/MonitorResults.vue b/web/src/views/synthetics/MonitorResults.vue index 7161f079cd..18da732c8f 100644 --- a/web/src/views/synthetics/MonitorResults.vue +++ b/web/src/views/synthetics/MonitorResults.vue @@ -116,6 +116,7 @@ along with this program. If not, see . :override-monitor-name="monitorName" :override-run-id="selectedRunId" :override-execution-id="selectedExecutionId" + :override-monitor-type="resolvedCheckType" @update-status="onRunStatusUpdate" /> @@ -151,6 +152,13 @@ const orgIdentifier = computed(() => store.state.selectedOrganization?.identifie // and distinguishes "never triggered" from "no runs in this window." const lastTriggeredAt = ref(0); const checkType = ref("browser"); +// True once fetchCheck() resolves — the drawer can auto-open from a run/exec +// query param before that happens, so RunDetail must not trust checkType's +// "browser" default until it's confirmed. +const checkTypeReady = ref(false); +// Empty until confirmed, so RunDetail falls back to resolving the type itself +// instead of trusting a possibly-stale default. +const resolvedCheckType = computed(() => (checkTypeReady.value ? checkType.value : "")); const DEFAULT_RELATIVE = "15m"; @@ -385,6 +393,8 @@ async function fetchCheck() { toast({ variant: "warning", message: t("synthetics.newCheck.notFoundInOrg") }); return; } + } finally { + checkTypeReady.value = true; } } diff --git a/web/src/views/synthetics/MonitorRuns.spec.ts b/web/src/views/synthetics/MonitorRuns.spec.ts index a0fa56c3ce..5b04bd98ea 100644 --- a/web/src/views/synthetics/MonitorRuns.spec.ts +++ b/web/src/views/synthetics/MonitorRuns.spec.ts @@ -246,6 +246,7 @@ const baseStubs = { inheritAttrs: true, }, OTable: { + name: "OTable", template: '', props: [ "columns", @@ -254,6 +255,9 @@ const baseStubs = { "pagination", "pageSize", "pageSizeOptions", + "sorting", + "sortBy", + "sortOrder", "rowKey", "showGlobalFilter", "enableColumnResize", @@ -489,6 +493,105 @@ describe("MonitorRuns", () => { }); }); + describe("runs table sorting", () => { + // The runs table is the only OTable mounted with client pagination — the + // steps table below it uses pagination="none". + function runsTable(w: VueWrapper) { + const table = w + .findAllComponents({ name: "OTable" }) + .find((t) => t.props("pagination") === "client"); + expect(table).toBeTruthy(); + return table!; + } + + it("should open sorted by last run, newest first", async () => { + wrapper = mountRuns({ + monitorId: "mon-1", + monitorName: "Test Monitor", + checkType: "browser", + }); + await flushPromises(); + + const table = runsTable(wrapper); + expect(table.props("sorting")).toBe("client"); + expect(table.props("sortBy")).toBe("last_run_at"); + expect(table.props("sortOrder")).toBe("desc"); + }); + + it("should mark every runs column sortable", async () => { + wrapper = mountRuns({ + monitorId: "mon-1", + monitorName: "Test Monitor", + checkType: "browser", + }); + await flushPromises(); + + const columns = runsTable(wrapper).props("columns") as any[]; + expect(columns.map((c) => c.id)).toEqual([ + "status", + "last_run_at", + "duration", + "location", + "browser", + "device", + "trigger_type", + "scheduled_at", + ]); + for (const col of columns) { + expect(col.sortable, `column "${col.id}" should be sortable`).toBe(true); + } + }); + + it("should sort on raw values rather than the formatted ones the cells render", async () => { + wrapper = mountRuns({ + monitorId: "mon-1", + monitorName: "Test Monitor", + checkType: "browser", + }); + await flushPromises(); + + const columns = runsTable(wrapper).props("columns") as any[]; + const accessorFor = (id: string) => columns.find((c) => c.id === id)?.accessorKey; + + // Sorting "duration" on the fmtDur() string would order 900ms after 1.2s; + // sorting "status" on the translated label would depend on the locale. + expect(accessorFor("duration")).toBe("durationMs"); + expect(accessorFor("status")).toBe("statusRank"); + expect(accessorFor("location")).toBe("locationName"); + expect(accessorFor("device")).toBe("deviceName"); + }); + + it("should expose the raw sort fields on every row", async () => { + mockSyntheticsServiceGetLocations.mockResolvedValue({ + data: { + locations: [ + { id: "us-east-1", label: "US East", region: "N. Virginia" }, + { id: "eu-west-1", label: "EU West", region: "Ireland" }, + ], + }, + }); + + wrapper = mountRuns({ + monitorId: "mon-1", + monitorName: "Test Monitor", + checkType: "browser", + }); + await flushPromises(); + + const rows = runsTable(wrapper).props("data") as any[]; + expect(rows).toHaveLength(2); + + // run-001 passed in 1240ms, run-002 failed in 29340ms. + expect(rows[0].durationMs).toBe(1240); + expect(rows[1].durationMs).toBe(29340); + // Failures rank ahead of passes so an ascending sort surfaces them first. + expect(rows[0].statusRank).toBeGreaterThan(rows[1].statusRank); + + expect(rows[0].locationName).toBe("US East (N. Virginia)"); + expect(rows[1].locationName).toBe("EU West (Ireland)"); + }); + }); + describe("location label resolution", () => { it("should resolve location labels in locationDurationBreakdown when locations are loaded", async () => { mockSyntheticsServiceGetLocations.mockResolvedValue({ diff --git a/web/src/views/synthetics/MonitorRuns.vue b/web/src/views/synthetics/MonitorRuns.vue index 356a7801e8..b44412150c 100644 --- a/web/src/views/synthetics/MonitorRuns.vue +++ b/web/src/views/synthetics/MonitorRuns.vue @@ -682,7 +682,7 @@ along with this program. If not, see . - + . pagination="client" :page-size="10" :page-size-options="[10, 20, 25, 50]" + sorting="client" + sort-by="last_run_at" + sort-order="desc" row-key="id" :show-global-filter="false" :enable-column-resize="true" @@ -741,17 +744,6 @@ along with this program. If not, see . {{ deviceLabel((row as VisibleRun).device) }} -