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) }}
-
-
-
- {{ (row as VisibleRun).errorSnippet }}
-
-
-
{{ (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(() => {
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 = { fail: 0, error: 1, warning: 2, pass: 3 };
+
const visibleRuns = computed(() => {
return filteredRuns.value.map((run) => {
const isPass = run.status === "pass";
@@ -2041,7 +2036,6 @@ const visibleRuns = computed(() => {
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(() => {
: 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(() => {
? 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(() => {
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(() => {
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(() => {
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";
diff --git a/web/src/views/synthetics/RunDetail.spec.ts b/web/src/views/synthetics/RunDetail.spec.ts
index 6c3809face..b7810b09ac 100644
--- a/web/src/views/synthetics/RunDetail.spec.ts
+++ b/web/src/views/synthetics/RunDetail.spec.ts
@@ -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: '
',
+ },
+ OCardSection: {
+ template: '
',
+ props: ["role"],
+ },
+ OSeparator: {
+ template: '',
+ },
+ OButton: {
+ template:
+ '',
+ props: ["disabled", "iconLeft"],
+ },
+ OIcon: {
+ template: '',
+ props: ["name"],
+ },
+ OBadge: {
+ template: '',
+ props: ["variant", "size", "icon"],
+ },
+ BetaBadge: {
+ template: 'BETA',
+ },
+ // 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: '',
+ },
+};
+
+function mountComponent(props: Record = {}) {
+ return mount(RunDetail, {
+ props,
+ global: { stubs },
+ });
+}
+
describe("RunDetail", () => {
let wrapper: VueWrapper;
beforeEach(async () => {
- wrapper = mount(RunDetail, {
- global: {
- stubs: {
- OCard: {
- template: '
',
- },
- OCardSection: {
- template: '
',
- props: ["role"],
- },
- OSeparator: {
- template: '',
- },
- OButton: {
- template:
- '',
- props: ["disabled", "iconLeft"],
- },
- OIcon: {
- template: '',
- props: ["name"],
- },
- OBadge: {
- template: '',
- props: ["variant", "size", "icon"],
- },
- BetaBadge: {
- template: 'BETA',
- },
- },
- },
- });
+ 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();
+ });
+ });
});
diff --git a/web/src/views/synthetics/RunDetail.vue b/web/src/views/synthetics/RunDetail.vue
index b7a041b331..1d6df4eef1 100644
--- a/web/src/views/synthetics/RunDetail.vue
+++ b/web/src/views/synthetics/RunDetail.vue
@@ -543,6 +543,7 @@ interface Props {
overrideMonitorName?: string;
overrideRunId?: string;
overrideExecutionId?: string;
+ overrideMonitorType?: string;
}
const props = withDefaults(defineProps(), {
drawerMode: false,
@@ -550,6 +551,7 @@ const props = withDefaults(defineProps(), {
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(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(
+ 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.