mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
</template>
|
||||
|
||||
<div class="px-page-edge min-h-0 flex-1 overflow-y-auto py-4">
|
||||
<OSkeleton v-if="loading" class="h-80 w-full" />
|
||||
<ProtocolRunSummarySkeleton v-if="loading" />
|
||||
|
||||
<OEmptyState
|
||||
v-else-if="!run"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: vi.fn(() => ({ t: (key: string) => key })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feedback/Skeleton/OSkeleton.vue", () => ({
|
||||
default: {
|
||||
name: "OSkeleton",
|
||||
template: '<div data-test="oskeleton">Loading...</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
import { useI18n } from "vue-i18n";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="t('synthetics.protocolRun.loading')"
|
||||
aria-live="polite"
|
||||
class="flex max-w-[53.75rem] flex-col gap-4"
|
||||
data-test="synthetics-protocol-run-skeleton"
|
||||
>
|
||||
<!-- Mirrors the "Result" card: accent-strip header bar + grid-cols-2 label/value cells -->
|
||||
<div class="rounded-default border-border-default border">
|
||||
<div class="border-border-default flex items-center border-b px-3 py-2">
|
||||
<div class="rounded-default bg-accent mr-2 h-4 w-[0.1875rem] shrink-0" />
|
||||
<OSkeleton type="text" class="h-4 w-16" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 px-3 py-3">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="rounded-default bg-surface-subtle flex flex-col gap-1.5 p-3"
|
||||
>
|
||||
<OSkeleton type="text" class="h-3 w-16" />
|
||||
<OSkeleton type="text" class="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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",
|
||||
|
||||
@@ -9740,6 +9740,7 @@
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"init": "init",
|
||||
"loading": "Loading run details",
|
||||
"location": "Location",
|
||||
"notFound": "Run not found",
|
||||
"passed": "Passed",
|
||||
|
||||
@@ -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: '<div data-test="synthetic-monitoring-move-dialog" />',
|
||||
props: ["type", "moduleId", "activeFolderId", "open"],
|
||||
},
|
||||
SelectFolderDropDown: {
|
||||
name: "SelectFolderDropDown",
|
||||
template: "<div />",
|
||||
props: ["type", "activeFolderId", "disableDropdown"],
|
||||
emits: ["folder-selected"],
|
||||
},
|
||||
ODropdown: {
|
||||
template:
|
||||
'<div><div data-test="odropdown-trigger"><slot name="trigger" /></div><div v-if="true"><slot /></div></div>',
|
||||
@@ -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<typeof import("@/utils/synthetics/buildPayload")>(
|
||||
"@/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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,11 +192,12 @@
|
||||
<!-- Duplicate check dialog -->
|
||||
<ODialog
|
||||
v-model:open="showDuplicateDialog"
|
||||
persistent
|
||||
size="sm"
|
||||
:title="t('synthetics.dialog.duplicateTitle')"
|
||||
:primary-button-label="t('common.save')"
|
||||
:secondary-button-label="t('common.cancel')"
|
||||
:primary-button-disabled="isDuplicating || !duplicateName.trim()"
|
||||
:primary-button-disabled="isDuplicating || !duplicateName.trim() || !duplicateSource"
|
||||
data-test="synthetic-monitoring-duplicate-dialog"
|
||||
@click:primary="saveDuplicate"
|
||||
@click:secondary="showDuplicateDialog = false"
|
||||
@@ -208,12 +209,18 @@
|
||||
:placeholder="t('synthetics.checkDetails.namePlaceholder')"
|
||||
data-test="synthetic-monitoring-duplicate-name-input"
|
||||
/>
|
||||
<OInput
|
||||
v-model="duplicateFolder"
|
||||
:label="t('synthetics.checkDetails.folder')"
|
||||
:placeholder="t('synthetics.checkDetails.folderPlaceholder')"
|
||||
data-test="synthetic-monitoring-duplicate-folder-input"
|
||||
/>
|
||||
<!-- Wrapper carries the data-test: SelectFolderDropDown has a fragment
|
||||
root, so attrs cannot fall through to it. Keyed on the source check
|
||||
so the dropdown re-seeds its selection from :active-folder-id for
|
||||
each row it is opened against. -->
|
||||
<div data-test="synthetic-monitoring-duplicate-folder-select">
|
||||
<SelectFolderDropDown
|
||||
:key="String(duplicateTarget?.id ?? '')"
|
||||
type="synthetics"
|
||||
:active-folder-id="duplicateFolder"
|
||||
@folder-selected="duplicateFolder = $event.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ODialog>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const showDuplicateDialog = ref(false);
|
||||
const duplicateTarget = ref<any>(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<any>(null);
|
||||
// "browser" | "http" | "tcp" | "tls" | "ssh" — picks the mapper/payload builder.
|
||||
const duplicateSourceType = ref<string>("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<string, unknown>;
|
||||
// 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<string, unknown>);
|
||||
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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -116,6 +116,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:override-monitor-name="monitorName"
|
||||
:override-run-id="selectedRunId"
|
||||
:override-execution-id="selectedExecutionId"
|
||||
:override-monitor-type="resolvedCheckType"
|
||||
@update-status="onRunStatusUpdate"
|
||||
/>
|
||||
</ODrawer>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -246,6 +246,7 @@ const baseStubs = {
|
||||
inheritAttrs: true,
|
||||
},
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
template: '<table data-test="monitor-runs-runs-table" />',
|
||||
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({
|
||||
|
||||
@@ -682,7 +682,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
|
||||
<!-- Runs table -->
|
||||
<OCard class="p-0" :key="tableFilterKey">
|
||||
<OCard class="p-0">
|
||||
<OTable
|
||||
:columns="runColumns"
|
||||
:data="visibleRuns"
|
||||
@@ -690,6 +690,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
{{ deviceLabel((row as VisibleRun).device) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-error="{ row }">
|
||||
<span
|
||||
v-if="(row as VisibleRun).errorSnippet"
|
||||
class="cursor-pointer"
|
||||
@click.stop="filterByError((row as VisibleRun).errorPattern)"
|
||||
>
|
||||
<OBadge variant="error-outline" size="sm" class="max-w-50 truncate">
|
||||
{{ (row as VisibleRun).errorSnippet }}
|
||||
</OBadge>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-trigger_type="{ row }">
|
||||
<span class="text-text-body text-sm">
|
||||
{{ (row as VisibleRun).triggerType }}
|
||||
@@ -1320,13 +1312,6 @@ async function handleEmptyStateAction(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Composite key that changes when any filter changes — ensures the runs
|
||||
// table fully re-renders with the filtered data.
|
||||
const tableFilterKey = computed(
|
||||
() =>
|
||||
`${statusFilter.value}|${browserFilter.value}|${deviceFilter.value}|${locationFilter.value}|${errorFilter.value ?? ""}`,
|
||||
);
|
||||
|
||||
// ── Select options (dynamic from run data) ──────────────────────────────
|
||||
function uniqueValues(key: "browser" | "device" | "location"): string[] {
|
||||
const vals = new Set(allRuns.value.map((r) => r[key]).filter(Boolean));
|
||||
@@ -2020,19 +2005,29 @@ const failedStepOptions = computed<SelectOption[]>(() => {
|
||||
interface VisibleRun {
|
||||
id: number;
|
||||
statusBadgeVariant: BadgeVariant;
|
||||
statusIcon: string;
|
||||
statusLabel: string;
|
||||
/** Severity order for sorting the status column — ascending surfaces the
|
||||
* runs that need attention first. Sorting the translated `statusLabel`
|
||||
* instead would make the order depend on the active locale. */
|
||||
statusRank: number;
|
||||
scheduledTs: number;
|
||||
lastRunTs: number;
|
||||
triggerType: string;
|
||||
duration: string;
|
||||
/** Raw milliseconds behind the formatted `duration` — the column sorts on
|
||||
* this so "900ms" doesn't order after "1.2s". */
|
||||
durationMs: number;
|
||||
location: string;
|
||||
/** Resolved location label, i.e. what the cell actually renders. */
|
||||
locationName: string;
|
||||
browser: string;
|
||||
device: string;
|
||||
errorSnippet: string | null;
|
||||
errorPattern: string | null;
|
||||
/** Resolved device label, i.e. what the cell actually renders. */
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = { fail: 0, error: 1, warning: 2, pass: 3 };
|
||||
|
||||
const visibleRuns = computed<VisibleRun[]>(() => {
|
||||
return filteredRuns.value.map((run) => {
|
||||
const isPass = run.status === "pass";
|
||||
@@ -2041,7 +2036,6 @@ const visibleRuns = computed<VisibleRun[]>(() => {
|
||||
return {
|
||||
id: run.id,
|
||||
statusBadgeVariant: isPass ? "success-soft" : isWarning ? "warning-soft" : "error-soft",
|
||||
statusIcon: isPass || isWarning ? "check_circle" : "cancel",
|
||||
statusLabel: isPass
|
||||
? t("synthetics.results.passed")
|
||||
: isWarning
|
||||
@@ -2049,6 +2043,7 @@ const visibleRuns = computed<VisibleRun[]>(() => {
|
||||
: isError
|
||||
? t("synthetics.results.error")
|
||||
: t("synthetics.results.failed"),
|
||||
statusRank: STATUS_RANK[run.status] ?? 0,
|
||||
scheduledTs: run.scheduledTs,
|
||||
lastRunTs: run.timestamp,
|
||||
triggerType:
|
||||
@@ -2056,37 +2051,47 @@ const visibleRuns = computed<VisibleRun[]>(() => {
|
||||
? t("synthetics.runs.triggerManual")
|
||||
: t("synthetics.runs.triggerSchedule"),
|
||||
duration: fmtDur(run.duration),
|
||||
durationMs: run.duration,
|
||||
location: run.location,
|
||||
locationName: locationLabel(run.location),
|
||||
browser: run.browser,
|
||||
device: run.device,
|
||||
errorSnippet: run.errorPattern
|
||||
? run.errorPattern.split(":")[0] + (run.failedStep ? " · " + run.failedStep : "")
|
||||
: null,
|
||||
errorPattern: run.errorPattern,
|
||||
deviceName: deviceLabel(run.device),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Every column sorts on the raw value behind what its cell slot renders, so
|
||||
// the order the user sees matches the order they asked for.
|
||||
const runColumns = computed<OTableColumnDef[]>(() => {
|
||||
const cols: OTableColumnDef[] = [
|
||||
{ id: "status", header: t("synthetics.table.status"), accessorKey: "status", size: 60 },
|
||||
{
|
||||
id: "status",
|
||||
header: t("synthetics.table.status"),
|
||||
accessorKey: "statusRank",
|
||||
size: 60,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "last_run_at",
|
||||
header: t("synthetics.table.lastRunAt"),
|
||||
accessorKey: "lastRunTs",
|
||||
size: 100,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: t("synthetics.results.duration"),
|
||||
accessorKey: "duration",
|
||||
accessorKey: "durationMs",
|
||||
size: 50,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
header: t("synthetics.results.location"),
|
||||
accessorKey: "location",
|
||||
accessorKey: "locationName",
|
||||
size: 110,
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
if (isBrowser.value) {
|
||||
@@ -2096,8 +2101,15 @@ const runColumns = computed<OTableColumnDef[]>(() => {
|
||||
header: t("synthetics.results.steps.browser"),
|
||||
accessorKey: "browser",
|
||||
size: 100,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
header: t("synthetics.results.device"),
|
||||
accessorKey: "deviceName",
|
||||
size: 90,
|
||||
sortable: true,
|
||||
},
|
||||
{ id: "device", header: t("synthetics.results.device"), accessorKey: "device", size: 90 },
|
||||
);
|
||||
}
|
||||
cols.push(
|
||||
@@ -2106,12 +2118,14 @@ const runColumns = computed<OTableColumnDef[]>(() => {
|
||||
header: t("synthetics.table.trigger"),
|
||||
accessorKey: "triggerType",
|
||||
size: 90,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
id: "scheduled_at",
|
||||
header: t("synthetics.table.scheduledAt"),
|
||||
accessorKey: "scheduledTs",
|
||||
size: 100,
|
||||
sortable: true,
|
||||
},
|
||||
);
|
||||
return cols;
|
||||
@@ -2384,12 +2398,6 @@ const errorChartOption = computed(() => {
|
||||
});
|
||||
|
||||
// ── Methods ──────────────────────────────────────────────────────────────
|
||||
function filterByError(pattern: string | null) {
|
||||
if (!pattern) return;
|
||||
errorFilter.value = pattern;
|
||||
statusFilter.value = "fail";
|
||||
}
|
||||
|
||||
function filterByErrorPattern(pattern: string) {
|
||||
errorFilter.value = pattern;
|
||||
statusFilter.value = "fail";
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
import { ref } from "vue";
|
||||
import RunDetail from "./RunDetail.vue";
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
@@ -24,6 +25,7 @@ vi.mock("vue-router", () => ({
|
||||
}),
|
||||
useRoute: () => ({
|
||||
params: { id: "mon-1", runId: "4821", executionId: "exec-1" },
|
||||
query: {},
|
||||
}),
|
||||
RouterLink: {
|
||||
name: "RouterLinkStub",
|
||||
@@ -70,6 +72,15 @@ const mockRunDetail = {
|
||||
traceKey: null,
|
||||
};
|
||||
|
||||
// ── Controllable `loading` ref shared with the mocked composable, so
|
||||
// RunDetail's direct `synthetics.loading.value = true` write (the defensive
|
||||
// loading fix) is observable in the rendered template. `fetchRun` mirrors the
|
||||
// real composable's contract by flipping it back to false once "done".
|
||||
const mockLoading = ref(false);
|
||||
const mockFetchRun = vi.fn(async () => {
|
||||
mockLoading.value = false;
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useSyntheticResults", () => ({
|
||||
default: () => ({
|
||||
kpi: {
|
||||
@@ -86,56 +97,88 @@ vi.mock("@/composables/useSyntheticResults", () => ({
|
||||
buckets: { value: [] },
|
||||
runs: { value: [] },
|
||||
runDetail: { value: { ...mockRunDetail } },
|
||||
loading: { value: false },
|
||||
loading: mockLoading,
|
||||
error: { value: null },
|
||||
hasLoadedOnce: { value: true },
|
||||
fetchAll: vi.fn(),
|
||||
fetchRun: vi.fn(),
|
||||
fetchRun: mockFetchRun,
|
||||
cancelAll: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Controllable mock for the monitor-type lookup (resolveMonitorType) and
|
||||
// the locations prefetch, so tests can assert whether the API was hit and
|
||||
// control the timing of its resolution.
|
||||
const mockGetSynthetics = vi.fn().mockResolvedValue({ data: { type: "browser" } });
|
||||
const mockGetLocations = vi.fn().mockResolvedValue({ data: { locations: [] } });
|
||||
|
||||
vi.mock("@/services/synthetics", () => ({
|
||||
default: {
|
||||
get: (...args: any[]) => mockGetSynthetics(...args),
|
||||
getLocations: (...args: any[]) => mockGetLocations(...args),
|
||||
presignArtifacts: vi.fn().mockResolvedValue({ data: { urls: [] } }),
|
||||
artifactUrl: vi.fn(() => ""),
|
||||
},
|
||||
}));
|
||||
|
||||
const stubs = {
|
||||
OCard: {
|
||||
template: '<div class="ocard-stub"><slot /></div>',
|
||||
},
|
||||
OCardSection: {
|
||||
template: '<div class="ocardsection-stub"><slot /></div>',
|
||||
props: ["role"],
|
||||
},
|
||||
OSeparator: {
|
||||
template: '<div class="oseparator-stub" />',
|
||||
},
|
||||
OButton: {
|
||||
template:
|
||||
'<button class="obutton-stub" @click="$emit(\'click\')"><slot /><slot name="prefix" /><slot name="suffix" /></button>',
|
||||
props: ["disabled", "iconLeft"],
|
||||
},
|
||||
OIcon: {
|
||||
template: '<span class="oicon-stub" />',
|
||||
props: ["name"],
|
||||
},
|
||||
OBadge: {
|
||||
template: '<span class="obadge-stub"><slot /></span>',
|
||||
props: ["variant", "size", "icon"],
|
||||
},
|
||||
BetaBadge: {
|
||||
template: '<span data-test="beta-badge">BETA</span>',
|
||||
},
|
||||
// Protocol (non-browser) runs delegate entirely to ProtocolRunSummary —
|
||||
// stub it so branch-selection tests don't need to satisfy its own
|
||||
// composable/service dependencies.
|
||||
ProtocolRunSummary: {
|
||||
name: "ProtocolRunSummary",
|
||||
props: ["monitorId", "runId", "executionId", "drawerMode", "locationNames"],
|
||||
template: '<div data-test="protocol-run-summary-stub" />',
|
||||
},
|
||||
};
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
return mount(RunDetail, {
|
||||
props,
|
||||
global: { stubs },
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunDetail", () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(RunDetail, {
|
||||
global: {
|
||||
stubs: {
|
||||
OCard: {
|
||||
template: '<div class="ocard-stub"><slot /></div>',
|
||||
},
|
||||
OCardSection: {
|
||||
template: '<div class="ocardsection-stub"><slot /></div>',
|
||||
props: ["role"],
|
||||
},
|
||||
OSeparator: {
|
||||
template: '<div class="oseparator-stub" />',
|
||||
},
|
||||
OButton: {
|
||||
template:
|
||||
'<button class="obutton-stub" @click="$emit(\'click\')"><slot /><slot name="prefix" /><slot name="suffix" /></button>',
|
||||
props: ["disabled", "iconLeft"],
|
||||
},
|
||||
OIcon: {
|
||||
template: '<span class="oicon-stub" />',
|
||||
props: ["name"],
|
||||
},
|
||||
OBadge: {
|
||||
template: '<span class="obadge-stub"><slot /></span>',
|
||||
props: ["variant", "size", "icon"],
|
||||
},
|
||||
BetaBadge: {
|
||||
template: '<span data-test="beta-badge">BETA</span>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.clearAllMocks();
|
||||
mockLoading.value = false;
|
||||
mockGetSynthetics.mockResolvedValue({ data: { type: "browser" } });
|
||||
mockGetLocations.mockResolvedValue({ data: { locations: [] } });
|
||||
});
|
||||
|
||||
it("should render the run detail page shell", () => {
|
||||
@@ -175,4 +218,71 @@ describe("RunDetail", () => {
|
||||
it("should render the Beta badge in the page title", () => {
|
||||
expect(wrapper.find('[data-test="beta-badge"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe("monitor type resolution (drawer mode override)", () => {
|
||||
it("skips the resolveMonitorType fetch and delegates to ProtocolRunSummary when overrideMonitorType is already known", async () => {
|
||||
// The outer beforeEach already mounted the default (non-drawer) wrapper,
|
||||
// which calls syntheticsService.get once — clear that call so this
|
||||
// assertion only reflects the wrapper mounted below.
|
||||
mockGetSynthetics.mockClear();
|
||||
const w = mountComponent({
|
||||
drawerMode: true,
|
||||
overrideMonitorId: "mon-2",
|
||||
overrideRunId: "run-2",
|
||||
overrideExecutionId: "exec-2",
|
||||
overrideMonitorType: "http",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(mockGetSynthetics).not.toHaveBeenCalled();
|
||||
expect(w.find('[data-test="protocol-run-summary-stub"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="synthetics-run-detail"]').exists()).toBe(false);
|
||||
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("falls back to resolveMonitorType via syntheticsService.get when overrideMonitorType is empty", async () => {
|
||||
mockGetSynthetics.mockClear();
|
||||
const w = mountComponent({
|
||||
drawerMode: true,
|
||||
overrideMonitorId: "mon-2",
|
||||
overrideRunId: "run-2",
|
||||
overrideExecutionId: "exec-2",
|
||||
overrideMonitorType: "",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(mockGetSynthetics).toHaveBeenCalledWith("org-1", "mon-2", "");
|
||||
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defensive loading state", () => {
|
||||
it("sets loading synchronously once loadRun starts, before monitor type resolution settles", async () => {
|
||||
// Keep resolveMonitorType's underlying fetch pending so monitorType is
|
||||
// still unresolved (null) at assertion time.
|
||||
let resolveGet!: (value: unknown) => void;
|
||||
mockGetSynthetics.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveGet = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const w = mountComponent();
|
||||
|
||||
// No flushPromises yet — assert immediately after mount, while
|
||||
// resolveMonitorType() is still pending.
|
||||
expect(w.find('[data-test="synthetics-run-detail-info-skeleton"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="synthetics-run-detail-info-bar"]').exists()).toBe(false);
|
||||
|
||||
// Settle the pending fetch so the component doesn't leak a dangling
|
||||
// promise across tests.
|
||||
resolveGet({ data: { type: "browser" } });
|
||||
await flushPromises();
|
||||
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,6 +543,7 @@ interface Props {
|
||||
overrideMonitorName?: string;
|
||||
overrideRunId?: string;
|
||||
overrideExecutionId?: string;
|
||||
overrideMonitorType?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
drawerMode: false,
|
||||
@@ -550,6 +551,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
overrideMonitorName: "",
|
||||
overrideRunId: "",
|
||||
overrideExecutionId: "",
|
||||
overrideMonitorType: "",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -592,8 +594,12 @@ const synthetics = useSyntheticResults();
|
||||
|
||||
// ── Monitor type — protocol runs render ProtocolRunSummary instead ──────────
|
||||
// null until resolved; browser view only fetches once known (avoids running
|
||||
// the steps/screenshot query for protocol runs).
|
||||
const monitorType = ref<string | null>(null);
|
||||
// the steps/screenshot query for protocol runs). In drawer mode the parent
|
||||
// (MonitorResults.vue) already knows the type — seed from it to skip the
|
||||
// redundant fetch and the render gap while this would otherwise be null.
|
||||
const monitorType = ref<string | null>(
|
||||
props.drawerMode && props.overrideMonitorType ? props.overrideMonitorType : null,
|
||||
);
|
||||
|
||||
async function resolveMonitorType() {
|
||||
try {
|
||||
@@ -1070,6 +1076,10 @@ watch(
|
||||
// ── Fetch data on mount / route change ────────────────────────────────────
|
||||
async function loadRun() {
|
||||
if (!runIdParam.value || !executionIdParam.value) return;
|
||||
// Show the skeleton (rather than the empty browser layout) for as long as
|
||||
// the monitor type is unresolved, since we don't yet know which branch —
|
||||
// this one or ProtocolRunSummary — will end up rendering.
|
||||
synthetics.loading.value = true;
|
||||
// Resolve the monitor type first — protocol runs are rendered by
|
||||
// ProtocolRunSummary (which fetches its own row), so skip the browser
|
||||
// steps/screenshot query for them.
|
||||
|
||||
Reference in New Issue
Block a user