Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: improvements on response log page (#746)

* wip:

* fix: play checker responsive device

authored by

Maximilian Kaske and committed by
GitHub
d919be99 85c58b01

+482 -106
+24 -8
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/metrics.tsx
··· 1 + import Link from "next/link"; 1 2 import { formatDistanceToNowStrict } from "date-fns"; 2 3 3 4 import type { LatencyMetric, ResponseTimeMetrics } from "@openstatus/tinybird"; ··· 72 73 ) : null} 73 74 <MetricsCard title="total pings" value={current.count} suffix="#" /> 74 75 </div> 75 - <div> 76 + <div className="grid gap-4"> 76 77 <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 md:gap-6"> 77 78 {metricsOrder.map((key) => { 78 79 const value = current[key]; ··· 90 91 ); 91 92 })} 92 93 </div> 93 - <p className="text-muted-foreground mt-4 text-xs"> 94 - Metrics calculated from the{" "} 95 - <span className="font-medium lowercase"> 96 - {periodFormatter(period)} 97 - </span>{" "} 98 - over all the regions and compared with the previous period. 99 - </p> 94 + <div className="grid gap-2"> 95 + <p className="text-muted-foreground text-xs"> 96 + Metrics calculated from the{" "} 97 + <span className="font-medium lowercase"> 98 + {periodFormatter(period)} 99 + </span>{" "} 100 + over all the regions and compared with the previous period. 101 + </p> 102 + {/* restricted to max 3d as we only support it in the list -> TODO: add more periods */} 103 + {failures > 0 && ["1h", "1d", "3d", "7d"].includes(period) ? ( 104 + <p className="text-destructive text-xs"> 105 + The monitor had {failures} failed ping(s). See more in the{" "} 106 + <Link 107 + href={`./data?error=true&period=${period}`} 108 + className=" underline underline-offset-4 hover:no-underline" 109 + > 110 + response logs 111 + </Link> 112 + . 113 + </p> 114 + ) : null} 115 + </div> 100 116 </div> 101 117 </div> 102 118 );
+17 -3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/data-table-wrapper.tsx
··· 4 4 "use client"; 5 5 6 6 import { Suspense, use } from "react"; 7 - import type { Row } from "@tanstack/react-table"; 7 + import type { 8 + ColumnFiltersState, 9 + PaginationState, 10 + Row, 11 + } from "@tanstack/react-table"; 8 12 9 13 import * as assertions from "@openstatus/assertions"; 10 14 import type { OSTinybird } from "@openstatus/tinybird"; ··· 20 24 type T = Awaited<ReturnType<ReturnType<OSTinybird["endpointList"]>>>; 21 25 22 26 // FIXME: use proper type 23 - type Monitor = { 27 + export type Monitor = { 24 28 monitorId: string; 25 29 url: string; 26 30 latency: number; ··· 33 37 assertions?: string | null; 34 38 }; 35 39 36 - export function DataTableWrapper({ data }: { data: Monitor[] }) { 40 + export function DataTableWrapper({ 41 + data, 42 + filters, 43 + pagination, 44 + }: { 45 + data: Monitor[]; 46 + filters?: ColumnFiltersState; 47 + pagination?: PaginationState; 48 + }) { 37 49 return ( 38 50 <DataTable 39 51 columns={columns} 40 52 data={data} 41 53 getRowCanExpand={() => true} 42 54 renderSubComponent={renderSubComponent} 55 + defaultColumnFilters={filters} 56 + defaultPagination={pagination} 43 57 /> 44 58 ); 45 59 }
+74
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/download-csv-button.tsx
··· 1 + "use client"; 2 + 3 + import { Download } from "lucide-react"; 4 + 5 + import { 6 + Button, 7 + Tooltip, 8 + TooltipContent, 9 + TooltipProvider, 10 + TooltipTrigger, 11 + } from "@openstatus/ui"; 12 + 13 + import type { Monitor } from "./data-table-wrapper"; 14 + 15 + function jsonToCsv(jsonData: Record<string, unknown>[]): string { 16 + const csvRows: string[] = []; 17 + const headers = Object.keys(jsonData[0]); 18 + 19 + // Add header row 20 + csvRows.push(headers.join(",")); 21 + 22 + // Add data rows 23 + for (const row of jsonData) { 24 + const values = headers.map((header) => { 25 + const escaped = ("" + row[header]).replace(/"/g, '\\"'); 26 + return `"${escaped}"`; 27 + }); 28 + csvRows.push(values.join(",")); 29 + } 30 + 31 + return csvRows.join("\n"); 32 + } 33 + 34 + function downloadCsv(data: string, filename: string) { 35 + const blob = new Blob([data], { type: "text/csv" }); 36 + const link = document.createElement("a"); 37 + link.href = window.URL.createObjectURL(blob); 38 + link.setAttribute("download", filename); 39 + document.body.appendChild(link); 40 + link.click(); 41 + document.body.removeChild(link); 42 + } 43 + 44 + export function DownloadCSVButton({ 45 + data, 46 + filename, 47 + }: { 48 + data: Monitor[]; 49 + filename: string; 50 + }) { 51 + return ( 52 + <TooltipProvider> 53 + <Tooltip> 54 + <TooltipTrigger asChild> 55 + <Button 56 + variant="outline" 57 + size="icon" 58 + onClick={() => { 59 + const content = jsonToCsv(data); 60 + downloadCsv(content, filename); 61 + }} 62 + > 63 + <Download className="h-4 w-4" /> 64 + </Button> 65 + </TooltipTrigger> 66 + <TooltipContent> 67 + <p> 68 + Download <code>csv</code> file 69 + </p> 70 + </TooltipContent> 71 + </Tooltip> 72 + </TooltipProvider> 73 + ); 74 + }
+5 -5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/loading.tsx
··· 5 5 export default function Loading() { 6 6 return ( 7 7 <div className="grid gap-4"> 8 - <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 9 - <div className="grid gap-1"> 10 - <Skeleton className="h-4 w-12" /> 11 - <Skeleton className="h-10 w-[150px]" /> 12 - </div> 8 + <div className="flex flex-row items-center justify-between"> 9 + <Skeleton className="h-10 w-[150px]" /> 10 + <Skeleton className="h-9 w-9" /> 13 11 </div> 14 12 <div className="grid gap-3"> 15 13 <div className="flex items-center gap-2"> 14 + <Skeleton className="h-8 w-32" /> 15 + <Skeleton className="h-8 w-32" /> 16 16 <Skeleton className="h-8 w-32" /> 17 17 <Skeleton className="h-8 w-16" /> 18 18 </div>
+44 -4
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/page.tsx
··· 1 1 import * as React from "react"; 2 2 import { notFound } from "next/navigation"; 3 + import { format } from "date-fns"; 3 4 import * as z from "zod"; 4 5 5 6 import { OSTinybird } from "@openstatus/tinybird"; ··· 9 10 import { DatePickerPreset } from "../_components/date-picker-preset"; 10 11 import { periods } from "../utils"; 11 12 import { DataTableWrapper } from "./_components/data-table-wrapper"; 13 + import { DownloadCSVButton } from "./_components/download-csv-button"; 12 14 13 15 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 14 16 ··· 17 19 */ 18 20 const searchParamsSchema = z.object({ 19 21 period: z.enum(periods).optional().default("1h"), 22 + // improve coersion + array + ... 23 + region: z 24 + .string() 25 + .optional() 26 + .transform((val) => { 27 + return val?.split(","); 28 + }), 29 + statusCode: z 30 + .string() 31 + .optional() 32 + .transform((val) => { 33 + return val?.split(",").map(parseInt); 34 + }), 35 + error: z 36 + .string() 37 + .optional() 38 + .transform((val) => { 39 + return val?.split(",").map((v) => v === "true"); 40 + }), 41 + pageSize: z.coerce.number().optional().default(10), 42 + pageIndex: z.coerce.number().optional().default(0), 20 43 }); 21 44 22 45 export default async function Page({ ··· 34 57 }); 35 58 36 59 if (!monitor || !search.success) { 37 - return notFound(); 60 + return notFound(); // maybe not if search.success is false, add a toast message 38 61 } 39 62 40 - const allowedPeriods = ["1h", "1d"] as const; 63 + const allowedPeriods = ["1h", "1d", "3d", "7d"] as const; 41 64 const period = allowedPeriods.find((i) => i === search.data.period) || "1d"; 42 65 43 66 const data = await tb.endpointList(period)({ ··· 49 72 50 73 return ( 51 74 <div className="grid gap-4"> 52 - <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 75 + <div className="flex flex-row items-center justify-between gap-4"> 53 76 <DatePickerPreset defaultValue={period} values={allowedPeriods} /> 77 + {/* <DownloadCSVButton 78 + data={data} 79 + filename={`${format(new Date(), "yyyy-mm-dd")}-${period}-${ 80 + monitor.name 81 + }`} 82 + /> */} 54 83 </div> 55 - <DataTableWrapper data={data} /> 84 + <DataTableWrapper 85 + data={data} 86 + filters={[ 87 + { id: "statusCode", value: search.data.statusCode }, 88 + { id: "region", value: search.data.region }, 89 + { id: "error", value: search.data.error }, 90 + ].filter((v) => v.value !== undefined)} 91 + pagination={{ 92 + pageIndex: search.data.pageIndex, 93 + pageSize: search.data.pageSize, 94 + }} 95 + /> 56 96 </div> 57 97 ); 58 98 }
+6 -5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/layout.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 - import { Badge } from "@openstatus/ui"; 4 - 5 3 import { Header } from "@/components/dashboard/header"; 6 4 import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 7 5 import { StatusDotWithTooltip } from "@/components/monitor/status-dot-with-tooltip"; 6 + import { TagBadgeWithTooltip } from "@/components/monitor/tag-badge-with-tooltip"; 8 7 import { api } from "@/trpc/server"; 9 8 10 9 export default async function Layout({ ··· 37 36 status={monitor.status} 38 37 /> 39 38 <span className="text-muted-foreground/50 text-xs">•</span> 40 - <Badge className="inline-flex" variant="secondary"> 41 - {monitor.method} 42 - </Badge> 39 + <TagBadgeWithTooltip 40 + tags={monitor.monitorTagsToMonitors.map( 41 + ({ monitorTag }) => monitorTag, 42 + )} 43 + /> 43 44 <span className="text-muted-foreground/50 text-xs">•</span> 44 45 <span className="text-sm"> 45 46 every <code>{monitor.periodicity}</code>
+1
apps/web/src/app/not-found.tsx
··· 9 9 10 10 export default function NotFound() { 11 11 const router = useRouter(); 12 + // user should go back to dashboard 12 13 13 14 return ( 14 15 <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8">
+2 -2
apps/web/src/app/play/checker/[id]/_components/response-header-table.tsx
··· 28 28 {Object.entries(headers).map(([key, value]) => ( 29 29 <TableRow key={key}> 30 30 <TableCell className="group"> 31 - <div className="flex items-center justify-between gap-4"> 31 + <div className="min-[130px] flex items-center justify-between gap-1"> 32 32 <code className="break-all font-medium">{key}</code> 33 33 <CopyToClipboardButton 34 34 copyValue={key} ··· 37 37 </div> 38 38 </TableCell> 39 39 <TableCell className="group"> 40 - <div className="flex items-center justify-between gap-4"> 40 + <div className="flex items-center justify-between gap-1"> 41 41 <code className="break-all">{value}</code> 42 42 <CopyToClipboardButton 43 43 copyValue={value}
+5 -5
apps/web/src/app/play/checker/[id]/page.tsx
··· 50 50 <> 51 51 <BackButton href="/play/checker" /> 52 52 <Shell className="flex flex-col gap-8"> 53 - <div className="flex items-center justify-between gap-4"> 54 - <div className="flex flex-col gap-1"> 55 - <h1 className="text-3xl font-semibold"> 56 - <span className="truncate">{data.url}</span> 53 + <div className="flex justify-between gap-4"> 54 + <div className="flex max-w-[calc(100%-50px)] flex-col gap-1"> 55 + <h1 className="text-wrap truncate text-lg font-semibold sm:text-xl md:text-3xl"> 56 + {data.url} 57 57 </h1> 58 - <p className="text-muted-foreground"> 58 + <p className="text-muted-foreground text-sm sm:text-base"> 59 59 {timestampFormatter(data.time)} 60 60 </p> 61 61 </div>
+13 -12
apps/web/src/components/data-table/columns.tsx
··· 2 2 3 3 import type { ColumnDef } from "@tanstack/react-table"; 4 4 import { format } from "date-fns"; 5 - import { Check, X } from "lucide-react"; 6 5 import * as z from "zod"; 7 6 8 7 import type { Ping } from "@openstatus/tinybird"; ··· 19 18 20 19 export const columns: ColumnDef<Ping>[] = [ 21 20 { 22 - id: "state", 21 + accessorKey: "error", 22 + header: () => null, 23 23 cell: ({ row }) => { 24 24 if (row.original.error) 25 - return ( 26 - <div className="max-w-max rounded-full bg-rose-500 p-1"> 27 - <X className="text-background h-3 w-3" /> 28 - </div> 29 - ); 30 - return ( 31 - <div className="max-w-max rounded-full bg-green-500 p-1"> 32 - <Check className="text-background h-3 w-3" /> 33 - </div> 34 - ); 25 + return <div className="h-2.5 w-2.5 rounded-full bg-rose-500" />; 26 + return <div className="h-2.5 w-2.5 rounded-full bg-green-500" />; 35 27 }, 28 + filterFn: (row, id, value) => value.includes(row.getValue(id)), 36 29 }, 37 30 { 38 31 accessorKey: "cronTimestamp", ··· 86 79 header: ({ column }) => ( 87 80 <DataTableColumnHeader column={column} title="Latency (ms)" /> 88 81 ), 82 + filterFn: (row, id, value) => { 83 + const { select, input } = value || {}; 84 + if (select === "min" && input) 85 + return parseInt(row.getValue(id)) > parseInt(input); 86 + if (select === "max" && input) 87 + return parseInt(row.getValue(id)) < parseInt(input); 88 + return true; 89 + }, 89 90 }, 90 91 { 91 92 accessorKey: "region",
+24 -4
apps/web/src/components/data-table/data-table-faceted-filter.tsx
··· 1 1 import * as React from "react"; 2 + import { useRouter } from "next/navigation"; 2 3 import type { Column } from "@tanstack/react-table"; 3 4 import { Check, PlusCircle } from "lucide-react"; 4 5 ··· 18 19 Separator, 19 20 } from "@openstatus/ui"; 20 21 22 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 21 23 import { cn } from "@/lib/utils"; 22 24 23 25 interface DataTableFacetedFilter<TData, TValue> { ··· 25 27 title?: string; 26 28 options: { 27 29 label: string; 28 - value: string | number; 30 + value: string | number | boolean; 29 31 icon?: React.ComponentType<{ className?: string }>; 30 32 }[]; 31 33 } ··· 35 37 title, 36 38 options, 37 39 }: DataTableFacetedFilter<TData, TValue>) { 40 + const router = useRouter(); 41 + const updateSearchParams = useUpdateSearchParams(); 42 + 38 43 const facets = column?.getFacetedUniqueValues(); 39 44 const selectedValues = new Set( 40 - column?.getFilterValue() as (string | number)[], 45 + column?.getFilterValue() as (string | number | boolean)[], 41 46 ); 42 47 48 + const updatePageSearchParams = ( 49 + values: Record<string, number | string | null>, 50 + ) => { 51 + const newSearchParams = updateSearchParams(values); 52 + router.replace(`?${newSearchParams}`, { scroll: false }); 53 + }; 54 + 43 55 return ( 44 56 <Popover> 45 57 <PopoverTrigger asChild> ··· 69 81 .map((option) => ( 70 82 <Badge 71 83 variant="secondary" 72 - key={option.value} 84 + key={String(option.value)} 73 85 className="rounded-sm px-1 font-normal" 74 86 > 75 87 {option.label} ··· 91 103 const isSelected = selectedValues.has(option.value); 92 104 return ( 93 105 <CommandItem 94 - key={option.value} 106 + key={String(option.value)} 95 107 onSelect={() => { 96 108 if (isSelected) { 97 109 selectedValues.delete(option.value); ··· 102 114 column?.setFilterValue( 103 115 filterValues.length ? filterValues : undefined, 104 116 ); 117 + 118 + // update search params 119 + const key = column?.id; 120 + if (key) { 121 + updatePageSearchParams({ 122 + [key]: filterValues?.join(",") || null, 123 + }); 124 + } 105 125 }} 106 126 > 107 127 <div
+93
apps/web/src/components/data-table/data-table-faceted-input-dropdown.tsx
··· 1 + import * as React from "react"; 2 + import type { Column } from "@tanstack/react-table"; 3 + import { PlusCircle } from "lucide-react"; 4 + 5 + import { 6 + Select, 7 + SelectContent, 8 + SelectGroup, 9 + SelectItem, 10 + SelectTrigger, 11 + SelectValue, 12 + Separator, 13 + } from "@openstatus/ui"; 14 + 15 + interface DataTableFacetedInputDropdownProps<TData, TValue> { 16 + column?: Column<TData, TValue>; 17 + title?: string; 18 + options: { 19 + label: string; 20 + value: string | number | boolean; 21 + icon?: React.ComponentType<{ className?: string }>; 22 + }[]; 23 + } 24 + 25 + export function DataTableFacetedInputDropdown<TData, TValue>({ 26 + column, 27 + title, 28 + options, 29 + }: DataTableFacetedInputDropdownProps<TData, TValue>) { 30 + const selectedValue = column?.getFilterValue() as { 31 + input?: number; 32 + select?: string; 33 + }; 34 + 35 + return ( 36 + <div className="border-input ring-offset-background focus-within:ring-ring group flex h-8 items-center overflow-hidden rounded-md border border-dashed bg-transparent text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"> 37 + <Select 38 + value={selectedValue?.select || ""} 39 + onValueChange={(value) => { 40 + column?.setFilterValue({ 41 + ...selectedValue, 42 + select: value, 43 + }); 44 + }} 45 + > 46 + <SelectTrigger className="focus:ring-offet-0 hover:bg-muted h-8 max-w-min space-x-2 rounded-none border-0 ring-offset-inherit focus:ring-0"> 47 + <SelectValue 48 + placeholder={ 49 + <div className="flex items-center text-xs font-medium"> 50 + <PlusCircle className="mr-2 h-4 w-4" /> 51 + {title} 52 + </div> 53 + } 54 + /> 55 + </SelectTrigger> 56 + <SelectContent> 57 + <SelectGroup> 58 + {options?.map((option) => { 59 + return ( 60 + <SelectItem 61 + key={String(option.value)} 62 + value={String(option.value)} 63 + > 64 + {option.icon && ( 65 + <option.icon className="text-muted-foreground mr-2 h-4 w-4" /> 66 + )} 67 + {option.label} 68 + </SelectItem> 69 + ); 70 + })} 71 + </SelectGroup> 72 + </SelectContent> 73 + </Select> 74 + <Separator orientation="vertical" className="h-4" /> 75 + <input 76 + className="placeholder:text-muted-foreground bg-background w-24 rounded-md px-3 py-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" 77 + type="number" 78 + placeholder="4000" 79 + min={0} 80 + value={selectedValue?.input || ""} 81 + onChange={(e) => { 82 + column?.setFilterValue({ 83 + ...selectedValue, 84 + input: parseInt(e.target.value), 85 + }); 86 + }} 87 + /> 88 + <div className="border-input bg-muted flex h-full items-center p-2 text-sm"> 89 + ms 90 + </div> 91 + </div> 92 + ); 93 + }
+35 -5
apps/web/src/components/data-table/data-table-pagination.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 1 4 import type { Table } from "@tanstack/react-table"; 2 5 import { 3 6 ChevronLeft, ··· 15 18 SelectValue, 16 19 } from "@openstatus/ui"; 17 20 21 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 22 + 23 + // REMINDER: pageIndex pagination issue - jumping back to 0 after change 24 + 18 25 interface DataTablePaginationProps<TData> { 19 26 table: Table<TData>; 20 27 } ··· 22 29 export function DataTablePagination<TData>({ 23 30 table, 24 31 }: DataTablePaginationProps<TData>) { 32 + const updateSearchParams = useUpdateSearchParams(); 33 + const router = useRouter(); 34 + 35 + const updatePageSearchParams = ( 36 + values: Record<string, number | string | null>, 37 + ) => { 38 + const newSearchParams = updateSearchParams(values); 39 + router.replace(`?${newSearchParams}`, { scroll: false }); 40 + }; 41 + 25 42 return ( 26 43 <div className="flex items-center justify-between px-2"> 27 - <div /> 44 + <div> 45 + <p className="text-muted-foreground text-sm"> 46 + {table.getFilteredRowModel().rows.length} row(s) filtered 47 + </p> 48 + </div> 28 49 <div className="flex items-center space-x-6 lg:space-x-8"> 29 50 <div className="flex items-center space-x-2"> 30 51 <p className="text-sm font-medium">Rows per page</p> ··· 32 53 value={`${table.getState().pagination.pageSize}`} 33 54 onValueChange={(value) => { 34 55 table.setPageSize(Number(value)); 56 + updatePageSearchParams({ pageSize: value }); 35 57 }} 36 58 > 37 59 <SelectTrigger className="h-8 w-[70px]"> ··· 54 76 <Button 55 77 variant="outline" 56 78 className="hidden h-8 w-8 p-0 lg:flex" 57 - onClick={() => table.setPageIndex(0)} 79 + onClick={() => { 80 + table.setPageIndex(0); 81 + }} 58 82 disabled={!table.getCanPreviousPage()} 59 83 > 60 84 <span className="sr-only">Go to first page</span> ··· 63 87 <Button 64 88 variant="outline" 65 89 className="h-8 w-8 p-0" 66 - onClick={() => table.previousPage()} 90 + onClick={() => { 91 + table.previousPage(); 92 + }} 67 93 disabled={!table.getCanPreviousPage()} 68 94 > 69 95 <span className="sr-only">Go to previous page</span> ··· 72 98 <Button 73 99 variant="outline" 74 100 className="h-8 w-8 p-0" 75 - onClick={() => table.nextPage()} 101 + onClick={() => { 102 + table.nextPage(); 103 + }} 76 104 disabled={!table.getCanNextPage()} 77 105 > 78 106 <span className="sr-only">Go to next page</span> ··· 81 109 <Button 82 110 variant="outline" 83 111 className="hidden h-8 w-8 p-0 lg:flex" 84 - onClick={() => table.setPageIndex(table.getPageCount() - 1)} 112 + onClick={() => { 113 + table.setPageIndex(table.getPageCount() - 1); 114 + }} 85 115 disabled={!table.getCanNextPage()} 86 116 > 87 117 <span className="sr-only">Go to last page</span>
+38 -3
apps/web/src/components/data-table/data-table-toolbar.tsx
··· 1 1 "use client"; 2 2 3 + import { useRouter } from "next/navigation"; 3 4 import type { Table } from "@tanstack/react-table"; 4 5 import { X } from "lucide-react"; 5 6 ··· 7 8 import { flyRegionsDict } from "@openstatus/utils"; 8 9 9 10 import { codesDict } from "@/data/code-dictionary"; 11 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 10 12 import { DataTableFacetedFilter } from "./data-table-faceted-filter"; 13 + import { DataTableFacetedInputDropdown } from "./data-table-faceted-input-dropdown"; 11 14 12 15 interface DataTableToolbarProps<TData> { 13 16 table: Table<TData>; ··· 16 19 export function DataTableToolbar<TData>({ 17 20 table, 18 21 }: DataTableToolbarProps<TData>) { 22 + const router = useRouter(); 23 + const updateSearchParams = useUpdateSearchParams(); 19 24 const isFiltered = table.getState().columnFilters.length > 0; 20 25 21 26 return ( 22 27 <div className="flex flex-wrap items-center justify-between gap-3"> 23 - <div className="flex flex-1 items-center gap-2"> 28 + <div className="flex flex-1 flex-wrap items-center gap-2"> 24 29 {table.getColumn("statusCode") && ( 25 30 <DataTableFacetedFilter 26 31 column={table.getColumn("statusCode")} ··· 47 52 })} 48 53 /> 49 54 )} 55 + {table.getColumn("error") && ( 56 + <DataTableFacetedFilter 57 + column={table.getColumn("error")} 58 + title="Request" 59 + options={[ 60 + // once we include 'degraded' requests, we can revert error to a number 61 + { value: true, label: "Failed" }, 62 + { value: false, label: "Success" }, 63 + ]} 64 + /> 65 + )} 66 + <DataTableFacetedInputDropdown 67 + title="Latency" 68 + column={table.getColumn("latency")} 69 + options={[ 70 + { value: "min", label: "Min." }, 71 + { value: "max", label: "Max." }, 72 + ]} 73 + /> 50 74 {isFiltered && ( 51 75 <Button 52 76 variant="ghost" 53 - onClick={() => table.resetColumnFilters()} 77 + onClick={() => { 78 + table.resetColumnFilters(); 79 + 80 + // reset filter search params (but not period e.g.) 81 + const newSearchParams = updateSearchParams({ 82 + error: null, 83 + statusCode: null, 84 + region: null, 85 + }); 86 + router.replace(`?${newSearchParams}`, { 87 + scroll: false, 88 + }); 89 + }} 54 90 className="h-8 px-2 lg:px-3" 55 91 > 56 92 Reset ··· 58 94 </Button> 59 95 )} 60 96 </div> 61 - {/* <DataTableDateRangePicker /> */} 62 97 </div> 63 98 ); 64 99 }
+11 -3
apps/web/src/components/data-table/data-table.tsx
··· 5 5 ColumnDef, 6 6 ColumnFiltersState, 7 7 ExpandedState, 8 + PaginationState, 8 9 Row, 9 10 SortingState, 10 11 } from "@tanstack/react-table"; ··· 36 37 renderSubComponent(props: { row: Row<TData> }): React.ReactElement; 37 38 getRowCanExpand(row: Row<TData>): boolean; 38 39 autoResetExpanded?: boolean; 40 + defaultColumnFilters?: ColumnFiltersState; 41 + defaultPagination?: PaginationState; 39 42 } 40 43 41 44 export function DataTable<TData, TValue>({ ··· 44 47 renderSubComponent, 45 48 getRowCanExpand, 46 49 autoResetExpanded, 50 + defaultColumnFilters = [], 51 + defaultPagination = { pageIndex: 0, pageSize: 10 }, 47 52 }: DataTableProps<TData, TValue>) { 48 53 const [sorting, setSorting] = React.useState<SortingState>([]); 49 - const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( 50 - [], 51 - ); 54 + const [columnFilters, setColumnFilters] = 55 + React.useState<ColumnFiltersState>(defaultColumnFilters); 52 56 const [expanded, setExpanded] = React.useState<ExpandedState>({}); 57 + const [pagination, setPagination] = 58 + React.useState<PaginationState>(defaultPagination); 53 59 54 60 const table = useReactTable({ 55 61 data, 56 62 columns, 57 63 getCoreRowModel: getCoreRowModel(), 64 + onPaginationChange: setPagination, 58 65 getPaginationRowModel: getPaginationRowModel(), 59 66 onSortingChange: setSorting, 60 67 getSortedRowModel: getSortedRowModel(), ··· 68 75 sorting, 69 76 columnFilters, 70 77 expanded, 78 + pagination, 71 79 }, 72 80 }); 73 81
+2 -27
apps/web/src/components/data-table/monitor/columns.tsx
··· 11 11 } from "@openstatus/tinybird"; 12 12 import { Tracker } from "@openstatus/tracker"; 13 13 import { 14 - Badge, 15 14 Tooltip, 16 15 TooltipContent, 17 16 TooltipProvider, ··· 19 18 } from "@openstatus/ui"; 20 19 21 20 import { StatusDotWithTooltip } from "@/components/monitor/status-dot-with-tooltip"; 22 - import { TagBadge } from "@/components/monitor/tag-badge"; 21 + import { TagBadgeWithTooltip } from "@/components/monitor/tag-badge-with-tooltip"; 23 22 import { Bar } from "@/components/tracker/tracker"; 24 23 import { DataTableRowActions } from "./data-table-row-actions"; 25 24 ··· 52 51 header: "Tags", 53 52 cell: ({ row }) => { 54 53 const { tags } = row.original; 55 - const [first, second, ...rest] = tags || []; 56 - return ( 57 - <div className="flex gap-2"> 58 - {first ? <TagBadge {...first} /> : null} 59 - {second ? <TagBadge {...second} /> : null} 60 - {rest.length > 0 ? <TagsTooltip tags={rest || []} /> : null} 61 - </div> 62 - ); 54 + return <TagBadgeWithTooltip tags={tags} />; 63 55 }, 64 56 filterFn: (row, id, value) => { 65 57 if (!Array.isArray(value)) return true; ··· 153 145 }, 154 146 }, 155 147 ]; 156 - 157 - function TagsTooltip({ tags }: { tags: MonitorTag[] }) { 158 - return ( 159 - <TooltipProvider> 160 - <Tooltip delayDuration={200}> 161 - <TooltipTrigger> 162 - <Badge variant="secondary">+{tags.length}</Badge> 163 - </TooltipTrigger> 164 - <TooltipContent side="top" className="flex gap-2"> 165 - {tags.map((tag) => ( 166 - <TagBadge key={tag.id} {...tag} /> 167 - ))} 168 - </TooltipContent> 169 - </Tooltip> 170 - </TooltipProvider> 171 - ); 172 - } 173 148 174 149 function HeaderTooltip({ label, content }: { label: string; content: string }) { 175 150 return (
+1 -1
apps/web/src/components/data-table/monitor/data-table-toolbar.tsx
··· 21 21 22 22 return ( 23 23 <div className="flex flex-wrap items-center justify-between gap-3"> 24 - <div className="flex flex-1 items-center gap-2"> 24 + <div className="flex flex-1 flex-wrap items-center gap-2"> 25 25 <Input 26 26 placeholder="Filter names..." 27 27 value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
+34
apps/web/src/components/monitor/tag-badge-with-tooltip.tsx
··· 1 + import type { MonitorTag } from "@openstatus/db/src/schema"; 2 + import { 3 + Badge, 4 + Tooltip, 5 + TooltipContent, 6 + TooltipProvider, 7 + TooltipTrigger, 8 + } from "@openstatus/ui"; 9 + 10 + import { TagBadge } from "./tag-badge"; 11 + 12 + export function TagBadgeWithTooltip({ tags }: { tags?: MonitorTag[] }) { 13 + const [first, second, ...rest] = tags || []; 14 + return ( 15 + <div className="flex gap-2"> 16 + {first ? <TagBadge {...first} /> : null} 17 + {second ? <TagBadge {...second} /> : null} 18 + {rest.length > 0 ? ( 19 + <TooltipProvider> 20 + <Tooltip delayDuration={200}> 21 + <TooltipTrigger> 22 + <Badge variant="secondary">+{rest.length}</Badge> 23 + </TooltipTrigger> 24 + <TooltipContent side="top" className="flex gap-2"> 25 + {rest.map((tag) => ( 26 + <TagBadge key={tag.id} {...tag} /> 27 + ))} 28 + </TooltipContent> 29 + </Tooltip> 30 + </TooltipProvider> 31 + ) : null} 32 + </div> 33 + ); 34 + }
+2 -2
apps/web/src/components/monitor/tag-badge.tsx
··· 2 2 3 3 function getStyle(color: string) { 4 4 return { 5 - borderColor: `${color}20`, 6 - backgroundColor: `${color}30`, 5 + borderColor: `${color}10`, 6 + backgroundColor: `${color}20`, 7 7 color, 8 8 }; 9 9 }
+18 -12
packages/api/src/router/monitor.ts
··· 153 153 154 154 getMonitorById: protectedProcedure 155 155 .input(z.object({ id: z.number() })) 156 - .output(selectMonitorSchema) // REMINDER: use more! 157 156 .query(async (opts) => { 158 - const currentMonitor = await opts.ctx.db 159 - .select() 160 - .from(monitor) 161 - .where( 162 - and( 163 - eq(monitor.id, opts.input.id), 164 - eq(monitor.workspaceId, opts.ctx.workspace.id), 165 - ), 166 - ) 167 - .get(); 157 + const _monitor = await opts.ctx.db.query.monitor.findFirst({ 158 + where: and( 159 + eq(monitor.id, opts.input.id), 160 + eq(monitor.workspaceId, opts.ctx.workspace.id), 161 + ), 162 + with: { 163 + monitorTagsToMonitors: { with: { monitorTag: true } }, 164 + }, 165 + }); 168 166 169 - const parsedMonitor = selectMonitorSchema.safeParse(currentMonitor); 167 + const parsedMonitor = selectMonitorSchema 168 + .extend({ 169 + monitorTagsToMonitors: z 170 + .object({ 171 + monitorTag: selectMonitorTagSchema, 172 + }) 173 + .array(), 174 + }) 175 + .safeParse(_monitor); 170 176 171 177 if (!parsedMonitor.success) { 172 178 console.log(parsedMonitor.error);
+2 -1
packages/tinybird/pipes/__ttl_3d.pipe
··· 12 12 statusCode, 13 13 url, 14 14 workspaceId, 15 - cronTimestamp 15 + cronTimestamp, 16 + timestamp 16 17 FROM ping_response__v8 17 18 18 19 TYPE materialized
+14
packages/tinybird/pipes/__ttl_3d_list_get.pipe
··· 1 + VERSION 1 2 + 3 + NODE __ttl_3d_list_get__v1_0 4 + SQL > 5 + 6 + % 7 + SELECT latency, monitorId, error, region, statusCode, url, workspaceId, cronTimestamp, timestamp 8 + FROM __ttl_3d_mv__v1 9 + WHERE 10 + monitorId = {{ String(monitorId, '1') }} 11 + {% if defined(url) %} AND url = {{ String(url) }} {% end %} 12 + ORDER BY cronTimestamp DESC 13 + 14 +
+2 -1
packages/tinybird/pipes/__ttl_7d.pipe
··· 12 12 statusCode, 13 13 url, 14 14 workspaceId, 15 - cronTimestamp 15 + cronTimestamp, 16 + timestamp 16 17 FROM ping_response__v8 17 18 18 19 TYPE materialized
+14
packages/tinybird/pipes/__ttl_7d_list_get.pipe
··· 1 + VERSION 1 2 + 3 + NODE __ttl_7d_list_get__v1_0 4 + SQL > 5 + 6 + % 7 + SELECT latency, monitorId, error, region, statusCode, url, workspaceId, cronTimestamp, timestamp 8 + FROM __ttl_7d_mv__v1 9 + WHERE 10 + monitorId = {{ String(monitorId, '1') }} 11 + {% if defined(url) %} AND url = {{ String(url) }} {% end %} 12 + ORDER BY cronTimestamp DESC 13 + 14 +
+1 -3
packages/tinybird/src/os-client.ts
··· 164 164 }; 165 165 } 166 166 167 - // TBH: not sure if we need more than 1d for that, better allow the user 168 - // to click on a specific region and time 169 - endpointList(period: "1h" | "1d") { 167 + endpointList(period: "1h" | "1d" | "3d" | "7d") { 170 168 const parameters = z.object({ 171 169 monitorId: z.string(), 172 170 url: z.string().optional(),