diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx index 82c08d344..6a1a7517e 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -10,6 +10,7 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useParams, usePathname, useRouter } from "next/navigation"; @@ -66,12 +67,22 @@ export function NavActions() { const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const curlCommand = + monitor?.jobType === "http" ? buildCurlCommand(monitor) : null; + const actions = getActions({ edit: () => router.push(`/monitors/${id}/edit`), "copy-id": async () => { await navigator.clipboard.writeText(id); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": curlCommand + ? async () => { + await navigator.clipboard.writeText(curlCommand); + toast.success("cURL command copied to clipboard"); + } + : undefined, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: Number.parseInt(id), @@ -87,7 +98,7 @@ export function NavActions() { }, }); }, - }); + }).filter((action) => action.id !== "copy-curl" || Boolean(curlCommand)); async function testAction() { if (monitor?.jobType === "http") { diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx index 3a8200898..41d53cc73 100644 --- a/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRouter } from "next/navigation"; @@ -29,14 +30,22 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { }), ); const router = useRouter(); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const isHttp = row.original.jobType === "http"; const actions = getActions({ edit: () => router.push(`/monitors/${row.original.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(row.original.id.toString()); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": isHttp + ? async () => { + await navigator.clipboard.writeText(buildCurlCommand(row.original)); + toast.success("cURL command copied to clipboard"); + } + : undefined, // export: () => setOpenDialog(true), - }); + }).filter((action) => action.id !== "copy-curl" || isHttp); return ( <> diff --git a/apps/dashboard/src/components/nav/nav-monitors.tsx b/apps/dashboard/src/components/nav/nav-monitors.tsx index 8464b927e..cea0b00b9 100644 --- a/apps/dashboard/src/components/nav/nav-monitors.tsx +++ b/apps/dashboard/src/components/nav/nav-monitors.tsx @@ -18,7 +18,9 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; +import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; +import { buildCurlCommand } from "@openstatus/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import Link from "next/link"; @@ -43,6 +45,7 @@ export function NavMonitors() { const [openDialog, setOpenDialog] = useState(false); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const { isMobile, setOpenMobile } = useSidebar(); + const { copy } = useCopyToClipboard(); const trpc = useTRPC(); const router = useRouter(); const pathname = usePathname(); @@ -128,12 +131,22 @@ export function NavMonitors() { ) : monitors && monitors.length > 0 ? ( monitors.map((item) => { const isActive = pathname.startsWith(`/monitors/${item.id}/`); + // curl only speaks HTTP — the action is hidden for tcp/dns monitors + const isHttp = item.jobType === "http"; const actions = getActions({ edit: () => router.push(`/monitors/${item.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(item.id.toString()); toast.success("Monitor ID copied to clipboard"); }, + "copy-curl": isHttp + ? async () => { + const copied = await copy(buildCurlCommand(item), { + withToast: "cURL command copied to clipboard", + }); + if (!copied) toast.error("Failed to copy cURL command"); + } + : undefined, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: item.id, @@ -150,7 +163,7 @@ export function NavMonitors() { }); }, // export: () => setOpenDialog(true), - }); + }).filter((action) => action.id !== "copy-curl" || isHttp); return ( { + it("renders a plain GET without an explicit method", () => { + expect(buildCurlCommand({ url: "https://example.com" })).toBe( + "curl \\\n 'https://example.com' \\\n -H 'User-Agent: OpenStatus/1.0'", + ); + }); + + it("keeps the method explicit when a GET carries a body", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "GET", + body: "hello", + }); + expect(command).toContain("-X GET"); + expect(command).toContain("--data-raw 'hello'"); + }); + + it("defaults POST to application/json", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "POST", + body: '{"a":1}', + }); + expect(command).toContain("-H 'Content-Type: application/json'"); + expect(command).toContain(`--data-raw '{"a":1}'`); + }); + + it("does not override a custom content type or user agent", () => { + const command = buildCurlCommand({ + url: "https://example.com", + method: "POST", + headers: [ + { key: "content-type", value: "text/plain" }, + { key: "user-agent", value: "custom" }, + ], + }); + expect(command).not.toContain("application/json"); + expect(command).not.toContain("OpenStatus/1.0"); + expect(command).toContain("-H 'content-type: text/plain'"); + }); + + it("skips headers without a key", () => { + const command = buildCurlCommand({ + url: "https://example.com", + headers: [ + { key: " ", value: "ignored" }, + { key: "X-Key", value: "kept" }, + ], + }); + expect(command).not.toContain("ignored"); + expect(command).toContain("-H 'X-Key: kept'"); + }); + + it("escapes single quotes so the command stays a single argument", () => { + const command = buildCurlCommand({ + url: "https://example.com/?q=it's", + headers: [{ key: "X-Quote", value: "a'b" }], + }); + expect(command).toContain(`'https://example.com/?q=it'\\''s'`); + expect(command).toContain(`-H 'X-Quote: a'\\''b'`); + }); + + it("adds -L only when redirects are followed", () => { + expect( + buildCurlCommand({ url: "https://example.com", followRedirects: true }), + ).toContain("-L"); + expect( + buildCurlCommand({ url: "https://example.com", followRedirects: false }), + ).not.toContain("-L"); + }); + + it("converts the timeout to seconds", () => { + expect( + buildCurlCommand({ url: "https://example.com", timeout: 45000 }), + ).toContain("--max-time 45"); + expect( + buildCurlCommand({ url: "https://example.com", timeout: 1500 }), + ).toContain("--max-time 1.5"); + expect( + buildCurlCommand({ url: "https://example.com", timeout: 0 }), + ).not.toContain("--max-time"); + }); +}); diff --git a/packages/utils/src/curl.ts b/packages/utils/src/curl.ts new file mode 100644 index 000000000..b538477a2 --- /dev/null +++ b/packages/utils/src/curl.ts @@ -0,0 +1,54 @@ +const DEFAULT_USER_AGENT = "OpenStatus/1.0"; + +export type CurlRequest = { + url: string; + method?: string | null; + body?: string | null; + headers?: { key: string; value: string }[] | null; + followRedirects?: boolean | null; + /** Milliseconds, as stored on the monitor. */ + timeout?: number | null; +}; + +function quote(value: string) { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function seconds(milliseconds: number) { + return String(Number((milliseconds / 1000).toFixed(3))); +} + +/** + * Renders the HTTP request a monitor performs as a runnable `curl` command, + * mirroring the defaults `apps/checker` applies (user agent, POST content + * type, redirect policy, timeout). + */ +export function buildCurlCommand(request: CurlRequest): string { + const method = (request.method ?? "GET").toUpperCase(); + const body = request.body ?? ""; + const headers = (request.headers ?? []).filter((h) => h.key.trim() !== ""); + const hasHeader = (name: string) => + headers.some((h) => h.key.toLowerCase() === name); + + const args: string[] = []; + + // `--data-raw` on its own makes curl switch to POST, so a body needs `-X`. + if (method !== "GET" || body) args.push(`-X ${method}`); + args.push(quote(request.url)); + + if (!hasHeader("user-agent")) { + args.push(`-H ${quote(`User-Agent: ${DEFAULT_USER_AGENT}`)}`); + } + for (const header of headers) { + args.push(`-H ${quote(`${header.key}: ${header.value}`)}`); + } + if (method === "POST" && !hasHeader("content-type")) { + args.push(`-H ${quote("Content-Type: application/json")}`); + } + + if (body) args.push(`--data-raw ${quote(body)}`); + if (request.followRedirects) args.push("-L"); + if (request.timeout) args.push(`--max-time ${seconds(request.timeout)}`); + + return ["curl", ...args].join(" \\\n "); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 65ae7343a..5561ea7dd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,7 @@ export { MONITOR_STATUSES, MONITOR_JOB_TYPES, } from "./constants"; +export { buildCurlCommand, type CurlRequest } from "./curl"; export { yieldMany, iteratorToStream } from "./stream"; export { statusLabel, type PageUpdateStatus } from "./status";