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) }} -