diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index 199d7ce18..89ebaf0fa 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -338,10 +338,21 @@ function LogsSheet({ }), enabled: Boolean(selectedId), }); + const selectedLog = log?.data?.length ? log.data[0] : null; + + const { data: attempts } = useQuery({ + ...trpc.tinybird.attempts.queryOptions({ + monitorId: String(monitor.id), + region: selectedLog?.region ?? "", + cronTimestamp: selectedLog?.cronTimestamp ?? 0, + }), + enabled: Boolean(selectedLog), + }); return ( { table.resetRowSelection(); diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.test.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.test.tsx new file mode 100644 index 000000000..d45498276 --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.test.tsx @@ -0,0 +1,48 @@ +import type { RouterOutputs } from "@openstatus/api"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { DataTableBasics } from "./data-table-basics"; + +type TCPLog = Extract< + RouterOutputs["tinybird"]["get"]["data"][number], + { type: "tcp" } +>; + +const tcpLog: TCPLog = { + type: "tcp", + id: "log-1", + uri: "openstatus.dev:443", + latency: 42, + monitorId: "1", + error: false, + region: "ams", + cronTimestamp: 1_700_000_000_000, + trigger: "cron", + timestamp: 1_700_000_000_000, + requestStatus: "success", + errorMessage: null, +}; + +function text(attempts: number | undefined, log: TCPLog = tcpLog) { + return renderToStaticMarkup( + , + ).replace(/<[^>]+>/g, " "); +} + +describe("DataTableBasics attempts row", () => { + it("is hidden when the check passed first time", () => { + expect(text(undefined)).not.toContain("Attempts"); + expect(text(1)).not.toContain("Attempts"); + }); + + it("shows which attempt passed", () => { + expect(text(3)).toContain("Attempts"); + expect(text(3)).toContain("passed on attempt 3"); + }); + + it("says when every attempt failed", () => { + const failed: TCPLog = { ...tcpLog, error: true, requestStatus: "error" }; + expect(text(2, failed)).toContain("all failed"); + }); +}); diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index 66b498481..836944d29 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -31,33 +31,55 @@ type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; export function DataTableBasics({ data, privateLocations, + attempts, }: { data: ResponseLog; privateLocations?: PrivateLocation[]; + attempts?: number; }) { if (data.type === "http") { return ( - + ); } if (data.type === "tcp") { return ( - + ); } if (data.type === "dns") { return ( - + ); } if (data.type === "icmp") { return ( - + ); } if (data.type === "grpc") { return ( - + ); } return null; @@ -66,11 +88,13 @@ export function DataTableBasics({ export function DataTableBasicsHTTP({ data, privateLocations, + attempts, }: { data: Extract & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; + attempts?: number; }) { const privateLocataion = privateLocations?.find( (location) => String(location.id) === String(data.region), @@ -108,6 +132,10 @@ export function DataTableBasicsHTTP({ + {data.id ? ( @@ -395,7 +423,34 @@ function BasicsRow({ ); } -function BasicsRequestRows({ data }: { data: BasicsRequestFields }) { +function BasicsAttemptsRow({ + attempts, + requestStatus, +}: { + attempts?: number; + requestStatus?: string | null; +}) { + if (!attempts || attempts < 2) return null; + + return ( + + {attempts}{" "} + + {requestStatus === "error" + ? "all failed" + : `passed on attempt ${attempts}`} + + + ); +} + +function BasicsRequestRows({ + data, + attempts, +}: { + data: BasicsRequestFields; + attempts?: number; +}) { return ( <> {/* TODO: add colored square like list (see columns) */} @@ -411,6 +466,10 @@ function BasicsRequestRows({ data }: { data: BasicsRequestFields }) {
{data?.requestStatus ?? "unknown"}
+ {data.id ? {data.id} : null} & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; + attempts?: number; }) { return ( - + {data.uri} @@ -509,11 +570,13 @@ export function DataTableBasicsTCP({ export function DataTableBasicsICMP({ data, privateLocations, + attempts, }: { data: Extract & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; + attempts?: number; }) { const packetLoss = data.packetsSent > 0 @@ -522,7 +585,7 @@ export function DataTableBasicsICMP({ return ( - + {data.uri} @@ -544,15 +607,17 @@ export function DataTableBasicsICMP({ export function DataTableBasicsGRPC({ data, privateLocations, + attempts, }: { data: Extract & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; + attempts?: number; }) { return ( - + {data.uri} {data.service ? ( @@ -581,15 +646,17 @@ export function DataTableBasicsGRPC({ export function DataTableBasicsDNS({ data, privateLocations, + attempts, }: { data: Extract & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; + attempts?: number; }) { return ( - + {data.uri} diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx index 337234f86..cf9073e2f 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx @@ -3,6 +3,12 @@ import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { Check, Copy } from "@openstatus/icons"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@openstatus/ui/components/ui/accordion"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; @@ -14,18 +20,22 @@ import { DataTableSheetHeader, DataTableSheetTitle, } from "@/components/data-table/data-table-sheet"; +import { formatMilliseconds } from "@/lib/formatter"; import { DataTableBasics } from "./data-table-basics"; type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; +type Attempt = RouterOutputs["tinybird"]["attempts"]["data"][number]; export function Sheet({ data, + attempts, privateLocations, onClose, showCopyUrl = true, }: { data: ResponseLog | null; + attempts?: Attempt[]; privateLocations?: PrivateLocation[]; onClose: () => void; showCopyUrl?: boolean; @@ -39,7 +49,17 @@ export function Sheet({ Response Logs - + + {attempts?.length ? ( + + ) : null} {showCopyUrl ? ( <> @@ -64,3 +84,51 @@ export function Sheet({ ); } + +function PreviousAttempts({ + attempts, + privateLocations, +}: { + attempts: Attempt[]; + privateLocations?: PrivateLocation[]; +}) { + return ( +
+
+ Previous attempts +
+ + {attempts.map((attempt) => ( + + + + + Attempt {attempt.attempt} + + {attemptSummary(attempt)} + + + + + + + + ))} + +
+ ); +} + +function attemptSummary(attempt: Attempt) { + const latency = formatMilliseconds(attempt.latency); + if (attempt.type === "http") { + return `${attempt.statusCode ?? "no response"} · ${latency}`; + } + if (attempt.type === "grpc" && attempt.grpcCode !== null) { + return `code ${attempt.grpcCode} · ${latency}`; + } + return latency; +} diff --git a/packages/api/src/router/tinybird/index.test.ts b/packages/api/src/router/tinybird/index.test.ts index 369b50f3a..a4e98e928 100644 --- a/packages/api/src/router/tinybird/index.test.ts +++ b/packages/api/src/router/tinybird/index.test.ts @@ -141,3 +141,34 @@ test("tinybird.listInfinite accepts React Query's own paging direction", async ( expect(result).toEqual({ data: [], nextCursor: null, prevCursor: null }); } }); + +// ─── attempts ──────────────────────────────────────────────────── + +test("tinybird.attempts rejects monitor from another workspace", async () => { + const caller = callerForWorkspace(1); + + try { + // Monitor 5 belongs to workspace 3 + await caller.tinybird.attempts({ + monitorId: "5", + region: "ams", + cronTimestamp: 1_700_000_000_000, + }); + throw new Error("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } +}); + +test("tinybird.attempts succeeds for own workspace monitor", async () => { + const caller = callerForWorkspace(1); + + // Monitor 1 belongs to workspace 1 — should not throw + const result = await caller.tinybird.attempts({ + monitorId: "1", + region: "ams", + cronTimestamp: 1_700_000_000_000, + }); + expect(result).toBeDefined(); +}); diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index e9928a4f2..b2483665d 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -176,6 +176,15 @@ export function getGetProcedure(period: "14d", type: Type) { } } +export function getAttemptsProcedure(type: Type) { + if (type === "http") return tb.httpAttempts; + if (type === "tcp") return tb.tcpAttempts; + if (type === "dns") return tb.dnsAttempts; + if (type === "icmp") return tb.icmpAttempts; + if (type === "grpc") return tb.grpcAttempts; + throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); +} + export function getGlobalMetricsProcedure(type: Type) { if (type === "http") return tb.httpGlobalMetricsDaily; if (type === "tcp") return tb.tcpGlobalMetricsDaily; @@ -528,6 +537,33 @@ export const tinybirdRouter = createTRPCRouter({ return await procedure(opts.input); }), + attempts: protectedProcedure + .input( + z.object({ + monitorId: z.string(), + region: z.string(), + cronTimestamp: z.int(), + }), + ) + .query(async (opts) => { + const _monitor = await db.query.monitor.findFirst({ + where: and( + eq(monitor.id, Number.parseInt(opts.input.monitorId)), + eq(monitor.workspaceId, opts.ctx.workspace.id), + ), + }); + + if (!_monitor) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Monitor not found", + }); + } + + const procedure = getAttemptsProcedure(_monitor.jobType as Type); + return await procedure(opts.input); + }), + globalMetrics: protectedProcedure .input( z.object({