Openstatus www.openstatus.dev

feat: speed checker data export (#1740)

* feat: add copy to CSV button for Checker table

Add functionality to export Checker table data to CSV format for easier
comparison in spreadsheet applications. Includes visual feedback via toast
notifications on success/failure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add timing data (DNS, Connect, TLS, TTFB, Transfer) to CSV export

Extend the CSV export to include detailed timing phases for each region check,
enabling deeper performance analysis in spreadsheet applications.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: change CSV copy to file download export

- Replace clipboard copy with file download functionality
- Add Export to CSV button to checker details page (next to search input)
- Filename includes sanitized URL and date (e.g., checker-example.com-2026-01-15.csv)
- Responsive layout: vertical on mobile, horizontal on desktop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: apply automated fixes

* chore: create utils

* fix: escape specific csv values

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Maximilian Kaske
Claude Opus 4.5
autofix-ci[bot]
and committed by
GitHub
b273f41d 5b58cd60

+134 -8
+16 -6
apps/web/src/app/(landing)/play/checker/[slug]/client.tsx
··· 21 21 import { Input } from "@openstatus/ui"; 22 22 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@openstatus/ui"; 23 23 import { useState } from "react"; 24 + import { handleExportCSV } from "../utils"; 24 25 25 26 const STATUS_CODES = { 26 27 "1": "text-muted-foreground", ··· 78 79 79 80 return ( 80 81 <div> 81 - <Input 82 - value={input} 83 - onChange={(e) => setInput(e.target.value)} 84 - placeholder="Search by region, flag, location code, cloud provider or continent" 85 - className="h-auto! rounded-none p-4 text-base md:text-base" 86 - /> 82 + <div className="flex flex-col gap-2 sm:flex-row"> 83 + <Input 84 + value={input} 85 + onChange={(e) => setInput(e.target.value)} 86 + placeholder="Search by region, flag, location code, cloud provider or continent" 87 + className="h-auto! flex-1 rounded-none p-4 text-base md:text-base" 88 + /> 89 + <Button 90 + variant="outline" 91 + className="h-auto! rounded-none p-4 text-base" 92 + onClick={() => handleExportCSV(checks, data.url)} 93 + > 94 + Export to CSV 95 + </Button> 96 + </div> 87 97 <div className="table-wrapper"> 88 98 <table> 89 99 <thead>
+27 -1
apps/web/src/app/(landing)/play/checker/client.tsx
··· 2 2 3 3 import { IconCloudProvider } from "@/components/icon-cloud-provider"; 4 4 import { 5 + type Timing, 5 6 is32CharHex, 6 7 latencyFormatter, 7 8 regionCheckerSchema, ··· 34 35 useTransition, 35 36 } from "react"; 36 37 import { searchParamsParsers } from "./search-params"; 38 + import { handleExportCSV } from "./utils"; 37 39 38 - type Values = { region: string; latency: number; status: number }; 40 + type Values = { 41 + region: string; 42 + latency: number; 43 + status: number; 44 + timing: Timing; 45 + }; 39 46 40 47 type CheckerContextType = { 41 48 values: Values[]; ··· 188 195 region: check.region, 189 196 latency: check.latency, 190 197 status: check.status, 198 + timing: check.timing, 191 199 }; 192 200 } 193 201 return null; ··· 395 403 </Button> 396 404 ); 397 405 } 406 + 407 + export function ExportToCSVButton() { 408 + const { values } = useCheckerContext(); 409 + 410 + if (values.length === 0) { 411 + return null; 412 + } 413 + 414 + return ( 415 + <Button 416 + variant="outline" 417 + className="h-full w-full rounded-none p-4 text-base" 418 + onClick={() => handleExportCSV(values)} 419 + > 420 + Export to CSV 421 + </Button> 422 + ); 423 + }
+5 -1
apps/web/src/app/(landing)/play/checker/page.tsx
··· 7 7 import { 8 8 CheckerProvider, 9 9 DetailsButtonLink, 10 + ExportToCSVButton, 10 11 Form, 11 12 ResponseStatus, 12 13 ResultTable, ··· 42 43 <Form defaultMethod={data?.method} defaultUrl={data?.url} /> 43 44 <ResponseStatus /> 44 45 <ResultTable /> 45 - <DetailsButtonLink /> 46 + <div className="flex gap-2"> 47 + <ExportToCSVButton /> 48 + <DetailsButtonLink /> 49 + </div> 46 50 </CheckerProvider> 47 51 <CustomMDX source={page.content} /> 48 52 </section>
+86
apps/web/src/app/(landing)/play/checker/utils.ts
··· 1 + import { 2 + type Timing, 3 + getTimingPhases, 4 + } from "@/components/ping-response-analysis/utils"; 5 + import { toast } from "@/lib/toast"; 6 + import { type Region, regionDict } from "@openstatus/regions"; 7 + 8 + export type CheckerRow = { 9 + region: string; 10 + latency: number; 11 + status: number; 12 + timing: Timing; 13 + }; 14 + 15 + function escapeCSV(value: string): string { 16 + // Escape values that contain commas, quotes, or newlines by wrapping in quotes 17 + if (value.includes(",") || value.includes('"') || value.includes("\n")) { 18 + return `"${value.replace(/"/g, '""')}"`; 19 + } 20 + return value; 21 + } 22 + 23 + export function convertToCSV(rows: CheckerRow[]): string { 24 + const headers = [ 25 + "Region Code", 26 + "Location", 27 + "Provider", 28 + "Latency (ms)", 29 + "Status", 30 + "DNS (ms)", 31 + "Connect (ms)", 32 + "TLS (ms)", 33 + "TTFB (ms)", 34 + "Transfer (ms)", 35 + ]; 36 + const csvRows = rows.map((row) => { 37 + const regionConfig = regionDict[row.region as Region]; 38 + const timing = getTimingPhases(row.timing); 39 + return [ 40 + escapeCSV(regionConfig.code), 41 + escapeCSV(regionConfig.location), 42 + escapeCSV(regionConfig.provider), 43 + row.latency.toString(), 44 + row.status.toString(), 45 + timing?.dns.toString() ?? "", 46 + timing?.connection.toString() ?? "", 47 + timing?.tls.toString() ?? "", 48 + timing?.ttfb.toString() ?? "", 49 + timing?.transfer.toString() ?? "", 50 + ].join(","); 51 + }); 52 + return [headers.join(","), ...csvRows].join("\n"); 53 + } 54 + 55 + export function downloadCSV(csv: string, filename: string): void { 56 + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); 57 + const url = URL.createObjectURL(blob); 58 + const link = document.createElement("a"); 59 + link.href = url; 60 + link.download = filename; 61 + document.body.appendChild(link); 62 + link.click(); 63 + document.body.removeChild(link); 64 + URL.revokeObjectURL(url); 65 + toast.success("CSV exported successfully"); 66 + } 67 + 68 + export function handleExportCSV( 69 + rows: CheckerRow[], 70 + urlForFilename?: string, 71 + ): void { 72 + const csv = convertToCSV(rows); 73 + const datePart = new Date().toISOString().split("T")[0]; 74 + 75 + let filename: string; 76 + if (urlForFilename) { 77 + const sanitizedUrl = urlForFilename 78 + .replace(/^https?:\/\//, "") 79 + .replace(/[^a-zA-Z0-9.-]/g, "_"); 80 + filename = `checker-${sanitizedUrl}-${datePart}.csv`; 81 + } else { 82 + filename = `checker-results-${datePart}.csv`; 83 + } 84 + 85 + downloadCSV(csv, filename); 86 + }