Openstatus www.openstatus.dev

feat: data table floating actions (#872)

* feat: data-table-floating-actions

* feat: add tags

* feat: improve toast promise

* chore: improve color

* chore: improve floating actions bar

* feat: replace dropdown with combobox

* fix: bubble ring

* fix: stuff

* chore: remove helper callout on layout

* fix: mobile pagination

* chore: format

authored by

Maximilian Kaske and committed by
GitHub
9fb258ae 5b5f2f74

+802 -80
+6 -6
apps/server/src/v1/check/post.test.ts
··· 25 25 Promise.resolve( 26 26 new Response( 27 27 '{"status":200,"latency":100,"body":"Hello World","headers":{"Content-Type":"application/json"},"time":1234567890,"timing":{"dnsStart":1,"dnsDone":2,"connectStart":3,"connectDone":4,"tlsHandshakeStart":5,"tlsHandshakeDone":6,"firstByteStart":7,"firstByteDone":8,"transferStart":9,"transferDone":10},"region":"ams"}', 28 - { status: 200, headers: { "content-type": "application/json" } } 29 - ) 30 - ) 28 + { status: 200, headers: { "content-type": "application/json" } }, 29 + ), 30 + ), 31 31 ); 32 32 33 33 const res = await api.request("/check", { ··· 94 94 Promise.resolve( 95 95 new Response( 96 96 '{"status":200,"latency":100,"body":"Hello World","headers":{"Content-Type":"application/json"},"time":1234567890,"timing":{"dnsStart":1,"dnsDone":2,"connectStart":3,"connectDone":4,"tlsHandshakeStart":5,"tlsHandshakeDone":6,"firstByteStart":7,"firstByteDone":8,"transferStart":9,"transferDone":10},"region":"ams"}', 97 - { status: 200, headers: { "content-type": "application/json" } } 98 - ) 99 - ) 97 + { status: 200, headers: { "content-type": "application/json" } }, 98 + ), 99 + ), 100 100 ); 101 101 102 102 const res = await api.request("/check", {
+9 -9
apps/server/src/v1/check/post.ts
··· 107 107 if (aggregated) { 108 108 // This is ugly 109 109 const dnsArray = fulfilledRequest.map( 110 - (r) => r.timing.dnsDone - r.timing.dnsStart 110 + (r) => r.timing.dnsDone - r.timing.dnsStart, 111 111 ); 112 112 const connectArray = fulfilledRequest.map( 113 - (r) => r.timing.connectDone - r.timing.connectStart 113 + (r) => r.timing.connectDone - r.timing.connectStart, 114 114 ); 115 115 const tlsArray = fulfilledRequest.map( 116 - (r) => r.timing.tlsHandshakeDone - r.timing.tlsHandshakeStart 116 + (r) => r.timing.tlsHandshakeDone - r.timing.tlsHandshakeStart, 117 117 ); 118 118 const firstArray = fulfilledRequest.map( 119 - (r) => r.timing.firstByteDone - r.timing.firstByteStart 119 + (r) => r.timing.firstByteDone - r.timing.firstByteStart, 120 120 ); 121 121 const transferArray = fulfilledRequest.map( 122 - (r) => r.timing.transferDone - r.timing.transferStart 122 + (r) => r.timing.transferDone - r.timing.transferStart, 123 123 ); 124 124 const latencyArray = fulfilledRequest.map((r) => r.latency); 125 125 126 126 const dnsPercentile = percentile([50, 75, 95, 99], dnsArray) as number[]; 127 127 const connectPercentile = percentile( 128 128 [50, 75, 95, 99], 129 - connectArray 129 + connectArray, 130 130 ) as number[]; 131 131 const tlsPercentile = percentile([50, 75, 95, 99], tlsArray) as number[]; 132 132 const firstPercentile = percentile( 133 133 [50, 75, 95, 99], 134 - firstArray 134 + firstArray, 135 135 ) as number[]; 136 136 137 137 const transferPercentile = percentile( 138 138 [50, 75, 95, 99], 139 - transferArray 139 + transferArray, 140 140 ) as number[]; 141 141 const latencyPercentile = percentile( 142 142 [50, 75, 95, 99], 143 - latencyArray 143 + latencyArray, 144 144 ) as number[]; 145 145 146 146 const aggregatedDNS = AggregatedResponseSchema.parse({
+1
apps/web/package.json
··· 35 35 "@openstatus/ui": "workspace:*", 36 36 "@openstatus/upstash": "workspace:*", 37 37 "@openstatus/utils": "workspace:*", 38 + "@radix-ui/react-portal": "^1.0.4", 38 39 "@sentry/integrations": "7.116.0", 39 40 "@sentry/nextjs": "7.116.0", 40 41 "@stripe/stripe-js": "2.1.6",
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/(overview)/layout.tsx
··· 5 5 6 6 export default async function Layout({ children }: { children: ReactNode }) { 7 7 return ( 8 - <AppPageLayout withHelpCallout> 8 + <AppPageLayout> 9 9 <Header 10 10 title="Incidents" 11 11 description="Overview of all your incidents."
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/layout.tsx
··· 12 12 const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 13 13 14 14 return ( 15 - <AppPageLayout withHelpCallout> 15 + <AppPageLayout> 16 16 <Header 17 17 title="Monitors" 18 18 description="Overview of all your monitors."
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx
··· 14 14 const isLimitReached = 15 15 await api.notification.isNotificationLimitReached.query(); 16 16 return ( 17 - <AppPageLayout withHelpCallout> 17 + <AppPageLayout> 18 18 <Header 19 19 title="Notifications" 20 20 description="Overview of all your notification channels."
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/layout.tsx
··· 7 7 children: React.ReactNode; 8 8 }) { 9 9 return ( 10 - <AppPageLayout withHelpCallout> 10 + <AppPageLayout> 11 11 <Header 12 12 title="Real User Monitoring" 13 13 description="Get speed insights for your application."
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/layout.tsx
··· 10 10 const isLimitReached = await api.page.isPageLimitReached.query(); 11 11 12 12 return ( 13 - <AppPageLayout withHelpCallout> 13 + <AppPageLayout> 14 14 <Header 15 15 title="Status Pages" 16 16 description="Overview of all your pages."
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-reports/(overview)/layout.tsx
··· 8 8 9 9 export default async function Layout({ children }: { children: ReactNode }) { 10 10 return ( 11 - <AppPageLayout withHelpCallout> 11 + <AppPageLayout> 12 12 <Header 13 13 title="Status Reports" 14 14 description="Overview of all your status reports and updates."
+2
apps/web/src/components/data-table/data-table-skeleton.tsx
··· 16 16 rows?: number; 17 17 } 18 18 19 + // TODO: add checkbox skeleton (for MonitorTable e.g.) 20 + 19 21 export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { 20 22 return ( 21 23 <div className="rounded-md border">
+78 -17
apps/web/src/components/data-table/monitor/columns.tsx
··· 17 17 import { Tracker } from "@openstatus/tracker"; 18 18 import { 19 19 Badge, 20 + Checkbox, 20 21 Tooltip, 21 22 TooltipContent, 22 23 TooltipProvider, ··· 28 29 import { Bar } from "@/components/tracker/tracker"; 29 30 import { isActiveMaintenance } from "@/lib/maintenances/utils"; 30 31 32 + import { Eye, EyeOff, Radio, View } from "lucide-react"; 31 33 import { DataTableRowActions } from "./data-table-row-actions"; 32 34 33 35 export const columns: ColumnDef<{ ··· 39 41 tags?: MonitorTag[]; 40 42 }>[] = [ 41 43 { 44 + id: "id", 45 + accessorKey: "id", 46 + accessorFn: (row) => row.monitor.id, 47 + }, 48 + { 49 + id: "select", 50 + header: ({ table }) => ( 51 + <Checkbox 52 + checked={ 53 + table.getIsAllPageRowsSelected() || 54 + (table.getIsSomePageRowsSelected() && "indeterminate") 55 + } 56 + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} 57 + aria-label="Select all" 58 + className="translate-y-[2px]" 59 + /> 60 + ), 61 + cell: ({ row }) => ( 62 + <Checkbox 63 + checked={row.getIsSelected()} 64 + onCheckedChange={(value) => row.toggleSelected(!!value)} 65 + aria-label="Select row" 66 + className="translate-y-[2px]" 67 + /> 68 + ), 69 + }, 70 + { 71 + accessorKey: "active", 72 + accessorFn: (row) => row.monitor.active, 73 + header: () => ( 74 + <div className="w-4"> 75 + <Radio className="h-4 w-4" /> 76 + </div> 77 + ), 78 + cell: ({ row }) => { 79 + const { active, status } = row.original.monitor; 80 + const maintenance = isActiveMaintenance(row.original.maintenances); 81 + return ( 82 + <div className="flex w-4 items-center justify-center"> 83 + <StatusDotWithTooltip 84 + active={active} 85 + status={status} 86 + maintenance={maintenance} 87 + /> 88 + </div> 89 + ); 90 + }, 91 + }, 92 + { 42 93 accessorKey: "name", 43 94 accessorFn: (row) => row.monitor.name, // used for filtering as name is nested within the monitor object 44 95 header: "Name", 45 96 cell: ({ row }) => { 46 - const { active, status, name, public: _public } = row.original.monitor; 47 - const maintenance = isActiveMaintenance(row.original.maintenances); 97 + const { name, public: _public } = row.original.monitor; 48 98 return ( 49 99 <div className="flex gap-2"> 50 100 <Link 51 101 href={`./monitors/${row.original.monitor.id}/overview`} 52 - className="group flex max-w-[150px] items-center gap-2 md:max-w-[250px]" 102 + className="group flex max-w-full items-center gap-2" 53 103 > 54 - <StatusDotWithTooltip 55 - active={active} 56 - status={status} 57 - maintenance={maintenance} 58 - /> 59 104 <span className="truncate group-hover:underline">{name}</span> 60 105 </Link> 61 106 {_public ? <Badge variant="secondary">public</Badge> : null} 62 107 </div> 63 108 ); 64 - }, 65 - }, 66 - { 67 - // REMINDER: visibility is handled within the `<DataTable />` 68 - accessorKey: "public", 69 - accessorFn: (row) => row.monitor.public, 70 - filterFn: (row, _id, value) => { 71 - if (!Array.isArray(value)) return true; 72 - return value.includes(row.original.monitor.public); 73 109 }, 74 110 }, 75 111 { ··· 86 122 return value.some((item) => 87 123 row.original.tags?.some((tag) => tag.name === item), 88 124 ); 125 + }, 126 + }, 127 + { 128 + accessorKey: "public", 129 + accessorFn: (row) => row.monitor.public, 130 + header: () => ( 131 + <div className="w-4"> 132 + <View className="h-4 w-4" /> 133 + </div> 134 + ), 135 + cell: ({ row }) => { 136 + const { public: _public } = row.original.monitor; 137 + return ( 138 + <> 139 + {_public ? ( 140 + <Eye className="h-4 w-4" /> 141 + ) : ( 142 + <EyeOff className="h-4 w-4" /> 143 + )} 144 + </> 145 + ); 146 + }, 147 + filterFn: (row, _id, value) => { 148 + if (!Array.isArray(value)) return true; 149 + return value.includes(row.original.monitor.public); 89 150 }, 90 151 }, 91 152 {
+358
apps/web/src/components/data-table/monitor/data-table-floating-actions.tsx
··· 1 + "use client"; 2 + 3 + import * as Portal from "@radix-ui/react-portal"; 4 + import type { Table } from "@tanstack/react-table"; 5 + import { useEffect, useState, useTransition } from "react"; 6 + 7 + import { Kbd } from "@/components/kbd"; 8 + import { LoadingAnimation } from "@/components/loading-animation"; 9 + import { toast, toastAction } from "@/lib/toast"; 10 + import { cn } from "@/lib/utils"; 11 + import { api } from "@/trpc/client"; 12 + import type { Monitor, MonitorTag } from "@openstatus/db/src/schema"; 13 + import { 14 + AlertDialog, 15 + AlertDialogAction, 16 + AlertDialogCancel, 17 + AlertDialogContent, 18 + AlertDialogDescription, 19 + AlertDialogFooter, 20 + AlertDialogHeader, 21 + AlertDialogTitle, 22 + AlertDialogTrigger, 23 + Button, 24 + Command, 25 + CommandEmpty, 26 + CommandGroup, 27 + CommandInput, 28 + CommandItem, 29 + CommandList, 30 + Popover, 31 + PopoverContent, 32 + PopoverTrigger, 33 + Select, 34 + SelectContent, 35 + SelectGroup, 36 + SelectItem, 37 + SelectLabel, 38 + SelectTrigger, 39 + SelectValue, 40 + Tooltip, 41 + TooltipContent, 42 + TooltipProvider, 43 + TooltipTrigger, 44 + } from "@openstatus/ui"; 45 + import { Check, Minus, X } from "lucide-react"; 46 + import { useRouter } from "next/navigation"; 47 + 48 + interface DataTableFloatingActions<TData> { 49 + table: Table<TData>; 50 + actions?: []; 51 + tags?: MonitorTag[]; 52 + } 53 + 54 + export function DataTableFloatingActions<TData>({ 55 + table, 56 + tags, 57 + }: DataTableFloatingActions<TData>) { 58 + const router = useRouter(); 59 + const [alertOpen, setAlertOpen] = useState(false); 60 + const [isPending, startTransition] = useTransition(); 61 + const [method, setMethod] = useState< 62 + "delete" | "active" | "public" | "tag" | null 63 + >(null); 64 + const rows = table.getFilteredSelectedRowModel().rows; 65 + 66 + // clear selection on escape key 67 + useEffect(() => { 68 + function handleKeyDown(event: KeyboardEvent) { 69 + if (event.key === "Escape") { 70 + table.toggleAllRowsSelected(false); 71 + } 72 + } 73 + 74 + window.addEventListener("keydown", handleKeyDown); 75 + return () => window.removeEventListener("keydown", handleKeyDown); 76 + }, [table]); 77 + 78 + if (table.getFilteredSelectedRowModel().rows.length === 0) { 79 + return null; 80 + } 81 + 82 + function handleUpdates(props: Partial<Pick<Monitor, "active" | "public">>) { 83 + startTransition(async () => { 84 + toast.promise( 85 + async () => { 86 + await api.monitor.updateMonitors.mutate({ 87 + ids: rows.map((row) => row.getValue("id")), 88 + ...props, 89 + }); 90 + router.refresh(); 91 + }, 92 + { 93 + loading: "Updating monitor(s)...", 94 + success: "Monitor(s) updated!", 95 + error: "Something went wrong!", 96 + finally: () => {}, 97 + }, 98 + ); 99 + }); 100 + } 101 + 102 + function handleTagUpdates(props: { 103 + tagId: number; 104 + action: "add" | "remove"; 105 + }) { 106 + startTransition(async () => { 107 + toast.promise( 108 + async () => { 109 + await api.monitor.updateMonitorsTag.mutate({ 110 + ids: rows.map((row) => row.getValue("id")), 111 + ...props, 112 + }); 113 + router.refresh(); 114 + }, 115 + { 116 + loading: 117 + props.action === "add" 118 + ? "Adding tag to monitor(s)..." 119 + : "Removing tag from monitor(s)...", 120 + success: 121 + props.action === "add" 122 + ? "Tag added to monitor(s)!" 123 + : "Tag removed from monitor(s)", 124 + error: "Something went wrong!", 125 + finally: () => {}, 126 + }, 127 + ); 128 + }); 129 + } 130 + 131 + function handleDeletes() { 132 + startTransition(async () => { 133 + try { 134 + await api.monitor.deleteMonitors.mutate({ 135 + ids: rows.map((row) => row.getValue("id")), 136 + }); 137 + setAlertOpen(false); 138 + table.toggleAllRowsSelected(false); 139 + router.refresh(); 140 + } catch (error) { 141 + console.error(error); 142 + toastAction("error"); 143 + } 144 + }); 145 + } 146 + 147 + // TODO: can we make it smarter! Its ugly as hell 148 + 149 + const statusValue = rows.every((row) => row.getValue("active") === true) 150 + ? "true" 151 + : rows.every((row) => row.getValue("active") === false) 152 + ? "false" 153 + : undefined; 154 + 155 + const visibilityValue = rows.every((row) => row.getValue("public") === true) 156 + ? "true" 157 + : rows.every((row) => row.getValue("public") === false) 158 + ? "false" 159 + : undefined; 160 + 161 + const everyTagValue = 162 + tags?.filter((tag) => { 163 + return rows.every((row) => { 164 + const _tags = row.getValue("tags"); 165 + if (Array.isArray(_tags)) { 166 + return _tags.map(({ id }) => id)?.includes(tag.id); 167 + } 168 + return false; 169 + }); 170 + }) || []; 171 + 172 + const someTagValue = 173 + tags?.filter((tag) => { 174 + return rows.some((row) => { 175 + const _tags = row.getValue("tags"); 176 + if (Array.isArray(_tags)) { 177 + return _tags.map(({ id }) => id)?.includes(tag.id); 178 + } 179 + return false; 180 + }); 181 + }) || []; 182 + 183 + return ( 184 + <Portal.Root> 185 + <div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit px-4"> 186 + <div className="flex flex-wrap items-center gap-2 rounded-md border bg-background px-4 py-2 shadow"> 187 + <TooltipProvider> 188 + <Tooltip> 189 + <TooltipTrigger asChild> 190 + <Button 191 + variant="ghost" 192 + onClick={() => table.toggleAllRowsSelected(false)} 193 + className="border border-dashed" 194 + > 195 + <span className="whitespace-nowrap"> 196 + {rows.length} selected 197 + </span> 198 + <X className="ml-1.5 size-4 shrink-0" /> 199 + </Button> 200 + </TooltipTrigger> 201 + <TooltipContent className="flex items-center"> 202 + <p className="mr-2">Clear selection</p> 203 + <Kbd abbrTitle="Escape" variant="outline"> 204 + Esc 205 + </Kbd> 206 + </TooltipContent> 207 + </Tooltip> 208 + </TooltipProvider> 209 + <AlertDialog 210 + open={alertOpen} 211 + onOpenChange={(value) => setAlertOpen(value)} 212 + > 213 + <AlertDialogTrigger asChild> 214 + <Button variant="destructive">Delete</Button> 215 + </AlertDialogTrigger> 216 + <AlertDialogContent> 217 + <AlertDialogHeader> 218 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 219 + <AlertDialogDescription> 220 + This action cannot be undone. This will permanently delete the 221 + selected monitor(s). 222 + </AlertDialogDescription> 223 + </AlertDialogHeader> 224 + <AlertDialogFooter> 225 + <AlertDialogCancel>Cancel</AlertDialogCancel> 226 + <AlertDialogAction 227 + onClick={() => { 228 + setMethod("delete"); 229 + handleDeletes(); 230 + }} 231 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 232 + > 233 + {isPending && method === "delete" ? ( 234 + <LoadingAnimation /> 235 + ) : ( 236 + "Delete" 237 + )} 238 + </AlertDialogAction> 239 + </AlertDialogFooter> 240 + </AlertDialogContent> 241 + </AlertDialog> 242 + <Select 243 + disabled={isPending && method === "active"} 244 + value={statusValue} 245 + onValueChange={(value) => { 246 + setMethod("active"); 247 + handleUpdates({ active: value === "true" }); 248 + }} 249 + > 250 + <SelectTrigger className="h-9 max-w-fit"> 251 + <SelectValue placeholder="Status" /> 252 + </SelectTrigger> 253 + <SelectContent> 254 + <SelectGroup> 255 + <SelectLabel>Status</SelectLabel> 256 + <SelectItem value="true">Active</SelectItem> 257 + <SelectItem value="false">Inactive</SelectItem> 258 + </SelectGroup> 259 + </SelectContent> 260 + </Select> 261 + <Popover> 262 + <PopoverTrigger disabled={isPending && method === "tag"} asChild> 263 + <Button variant="outline" className="flex items-center gap-2"> 264 + <span>Tags</span> 265 + {everyTagValue.length ? ( 266 + <div className="relative flex overflow-hidden"> 267 + {everyTagValue.map((tag) => ( 268 + <div 269 + key={tag.id} 270 + style={{ backgroundColor: tag.color }} 271 + className="h-2.5 w-2.5 rounded-full ring-2 ring-background" 272 + /> 273 + ))} 274 + </div> 275 + ) : null} 276 + </Button> 277 + </PopoverTrigger> 278 + <PopoverContent className="w-[200px] p-0" align="start"> 279 + <Command> 280 + <CommandInput placeholder={"Tags"} /> 281 + <CommandList> 282 + <CommandEmpty>No results found.</CommandEmpty> 283 + <CommandGroup> 284 + {tags?.map((tag) => { 285 + const isSelected = everyTagValue 286 + .map((tag) => tag.id) 287 + ?.includes(tag.id); 288 + const isIndeterminated = !isSelected 289 + ? someTagValue.map((tag) => tag.id)?.includes(tag.id) 290 + : false; 291 + return ( 292 + <CommandItem 293 + key={String(tag.name)} 294 + onSelect={() => { 295 + setMethod("tag"); 296 + handleTagUpdates({ 297 + tagId: tag.id, 298 + action: 299 + !isSelected || isIndeterminated 300 + ? "add" 301 + : "remove", 302 + }); 303 + }} 304 + > 305 + <div 306 + className={cn( 307 + "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", 308 + { 309 + "bg-primary text-primary-foreground": 310 + isSelected, 311 + "text-muted-foreground": isIndeterminated, 312 + "opacity-50 [&_svg]:invisible": 313 + !isSelected && !isIndeterminated, 314 + }, 315 + )} 316 + > 317 + <Check className={cn("h-4 w-4")} /> 318 + </div> 319 + <div className="flex w-full items-center justify-between"> 320 + <span>{tag.name}</span> 321 + <div 322 + key={tag.id} 323 + style={{ backgroundColor: tag.color }} 324 + className="h-2.5 w-2.5 rounded-full" 325 + /> 326 + </div> 327 + </CommandItem> 328 + ); 329 + })} 330 + </CommandGroup> 331 + </CommandList> 332 + </Command> 333 + </PopoverContent> 334 + </Popover> 335 + <Select 336 + disabled={isPending && method === "public"} 337 + value={visibilityValue} 338 + onValueChange={(value) => { 339 + setMethod("public"); 340 + handleUpdates({ public: value === "true" }); 341 + }} 342 + > 343 + <SelectTrigger className="h-9 max-w-fit"> 344 + <SelectValue placeholder="Visibility" /> 345 + </SelectTrigger> 346 + <SelectContent> 347 + <SelectGroup> 348 + <SelectLabel>Visibility</SelectLabel> 349 + <SelectItem value="true">Public</SelectItem> 350 + <SelectItem value="false">Private</SelectItem> 351 + </SelectGroup> 352 + </SelectContent> 353 + </Select> 354 + </div> 355 + </div> 356 + </Portal.Root> 357 + ); 358 + }
+125
apps/web/src/components/data-table/monitor/data-table-pagination.tsx
··· 1 + "use client"; 2 + 3 + import type { Table } from "@tanstack/react-table"; 4 + import { 5 + ChevronLeft, 6 + ChevronRight, 7 + ChevronsLeft, 8 + ChevronsRight, 9 + } from "lucide-react"; 10 + import { useRouter } from "next/navigation"; 11 + 12 + import { 13 + Button, 14 + Select, 15 + SelectContent, 16 + SelectItem, 17 + SelectTrigger, 18 + SelectValue, 19 + } from "@openstatus/ui"; 20 + 21 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 22 + 23 + // REMINDER: pageIndex pagination issue - jumping back to 0 after change 24 + 25 + interface DataTablePaginationProps<TData> { 26 + table: Table<TData>; 27 + } 28 + 29 + export function DataTablePagination<TData>({ 30 + table, 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 + 42 + return ( 43 + <div className="flex flex-wrap-reverse items-center justify-between gap-4 px-2"> 44 + <div> 45 + <p className="text-muted-foreground text-sm"> 46 + {table.getFilteredSelectedRowModel().rows.length} of{" "} 47 + {table.getFilteredRowModel().rows.length} row(s) selected. 48 + </p> 49 + </div> 50 + <div className="flex items-center space-x-6 lg:space-x-8"> 51 + <div className="flex items-center space-x-2"> 52 + <p className="font-medium text-sm">Rows per page</p> 53 + <Select 54 + value={`${table.getState().pagination.pageSize}`} 55 + onValueChange={(value) => { 56 + table.setPageSize(Number(value)); 57 + updatePageSearchParams({ pageSize: value }); 58 + }} 59 + > 60 + <SelectTrigger className="h-8 w-[70px]"> 61 + <SelectValue placeholder={table.getState().pagination.pageSize} /> 62 + </SelectTrigger> 63 + <SelectContent side="top"> 64 + {[10, 20, 30, 40, 50].map((pageSize) => ( 65 + <SelectItem key={pageSize} value={`${pageSize}`}> 66 + {pageSize} 67 + </SelectItem> 68 + ))} 69 + </SelectContent> 70 + </Select> 71 + </div> 72 + <div className="flex w-[100px] items-center justify-center font-medium text-sm"> 73 + Page {table.getState().pagination.pageIndex + 1} of{" "} 74 + {table.getPageCount()} 75 + </div> 76 + <div className="flex items-center space-x-2"> 77 + <Button 78 + variant="outline" 79 + className="hidden h-8 w-8 p-0 lg:flex" 80 + onClick={() => { 81 + table.setPageIndex(0); 82 + }} 83 + disabled={!table.getCanPreviousPage()} 84 + > 85 + <span className="sr-only">Go to first page</span> 86 + <ChevronsLeft className="h-4 w-4" /> 87 + </Button> 88 + <Button 89 + variant="outline" 90 + className="h-8 w-8 p-0" 91 + onClick={() => { 92 + table.previousPage(); 93 + }} 94 + disabled={!table.getCanPreviousPage()} 95 + > 96 + <span className="sr-only">Go to previous page</span> 97 + <ChevronLeft className="h-4 w-4" /> 98 + </Button> 99 + <Button 100 + variant="outline" 101 + className="h-8 w-8 p-0" 102 + onClick={() => { 103 + table.nextPage(); 104 + }} 105 + disabled={!table.getCanNextPage()} 106 + > 107 + <span className="sr-only">Go to next page</span> 108 + <ChevronRight className="h-4 w-4" /> 109 + </Button> 110 + <Button 111 + variant="outline" 112 + className="hidden h-8 w-8 p-0 lg:flex" 113 + onClick={() => { 114 + table.setPageIndex(table.getPageCount() - 1); 115 + }} 116 + disabled={!table.getCanNextPage()} 117 + > 118 + <span className="sr-only">Go to last page</span> 119 + <ChevronsRight className="h-4 w-4" /> 120 + </Button> 121 + </div> 122 + </div> 123 + </div> 124 + ); 125 + }
+3 -22
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 59 59 }); 60 60 } 61 61 62 - async function onToggleActive() { 63 - startTransition(async () => { 64 - try { 65 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 66 - const { jobType, ...rest } = monitor; 67 - if (!monitor.id) return; 68 - await api.monitor.toggleMonitorActive.mutate({ 69 - id: monitor.id, 70 - }); 71 - toastAction("success"); 72 - router.refresh(); 73 - } catch { 74 - toastAction("error"); 75 - } 76 - }); 77 - } 78 - 62 + // FIXME: the test doenst take the assertions into account! 63 + // FIXME: improve (similar to the one in the edit form - also include toast.promise + better error message!) 79 64 async function onTest() { 80 65 startTransition(async () => { 81 66 const { url, body, method, headers } = monitor; ··· 120 105 <Link href={`./monitors/${monitor.id}/overview`}> 121 106 <DropdownMenuItem>Details</DropdownMenuItem> 122 107 </Link> 123 - <DropdownMenuSeparator /> 124 - <DropdownMenuItem onClick={onTest}>Test endpoint</DropdownMenuItem> 125 - <DropdownMenuItem onClick={onToggleActive}> 126 - {monitor.active ? "Pause" : "Resume"} monitor 127 - </DropdownMenuItem> 108 + <DropdownMenuItem onClick={onTest}>Test</DropdownMenuItem> 128 109 <DropdownMenuSeparator /> 129 110 <AlertDialogTrigger asChild> 130 111 <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background">
+6
apps/web/src/components/data-table/monitor/data-table.tsx
··· 27 27 TableRow, 28 28 } from "@openstatus/ui"; 29 29 30 + import { DataTableFloatingActions } from "./data-table-floating-actions"; 31 + import { DataTablePagination } from "./data-table-pagination"; 30 32 import { DataTableToolbar } from "./data-table-toolbar"; 31 33 32 34 interface DataTableProps<TData, TValue> { ··· 47 49 const [columnVisibility, setColumnVisibility] = 48 50 React.useState<VisibilityState>({ 49 51 public: false, // default is true 52 + id: false, // we hide the id column 50 53 }); 51 54 52 55 const table = useReactTable({ ··· 104 107 <TableRow key={headerGroup.id} className="hover:bg-transparent"> 105 108 {headerGroup.headers.map((header) => { 106 109 return ( 110 + // FIXME: className="[&:has(svg)]:w-4" takes the svg of the button > checkbox into account 107 111 <TableHead key={header.id}> 108 112 {header.isPlaceholder 109 113 ? null ··· 147 151 </TableBody> 148 152 </Table> 149 153 </div> 154 + <DataTablePagination table={table} /> 155 + <DataTableFloatingActions table={table} tags={tags} /> 150 156 </div> 151 157 ); 152 158 }
+2 -2
apps/web/src/components/forms/shared/save-button.tsx
··· 40 40 form={form} 41 41 > 42 42 {!isPending ? ( 43 - <span className="flex gap-2"> 43 + <span className="flex items-center gap-2"> 44 44 Confirm 45 45 <Kbd> 46 - <span>⌘</span> 46 + <span className="mr-0.5">⌘</span> 47 47 <span>S</span> 48 48 </Kbd> 49 49 </span>
+54 -6
apps/web/src/components/kbd.tsx
··· 1 - export function Kbd({ children }: { children: React.ReactNode }) { 2 - return ( 3 - <kbd className="pointer-events-none flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium font-mono font-mono text-[10px] text-foreground opacity-100"> 4 - {children} 5 - </kbd> 6 - ); 1 + import { type VariantProps, cva } from "class-variance-authority"; 2 + // Copy Pasta from: https://github.com/sadmann7/shadcn-table/blob/main/src/components/kbd.tsx#L54 3 + import * as React from "react"; 4 + 5 + import { cn } from "@/lib/utils"; 6 + 7 + const kbdVariants = cva( 8 + "select-none rounded border px-1.5 py-px font-mono text-[0.7rem] font-normal shadow-sm disabled:opacity-50", 9 + { 10 + variants: { 11 + variant: { 12 + default: "bg-accent text-accent-foreground", 13 + outline: "bg-background text-foreground", 14 + }, 15 + }, 16 + defaultVariants: { 17 + variant: "default", 18 + }, 19 + }, 20 + ); 21 + 22 + export interface KbdProps 23 + extends React.ComponentPropsWithoutRef<"kbd">, 24 + VariantProps<typeof kbdVariants> { 25 + /** 26 + * The title of the `abbr` element inside the `kbd` element. 27 + * @default undefined 28 + * @type string | undefined 29 + * @example title="Command" 30 + */ 31 + abbrTitle?: string; 7 32 } 33 + 34 + const Kbd = React.forwardRef<HTMLUnknownElement, KbdProps>( 35 + ({ abbrTitle, children, className, variant, ...props }, ref) => { 36 + return ( 37 + <kbd 38 + className={cn(kbdVariants({ variant, className }))} 39 + ref={ref} 40 + {...props} 41 + > 42 + {abbrTitle ? ( 43 + <abbr title={abbrTitle} className="no-underline"> 44 + {children} 45 + </abbr> 46 + ) : ( 47 + children 48 + )} 49 + </kbd> 50 + ); 51 + }, 52 + ); 53 + Kbd.displayName = "Kbd"; 54 + 55 + export { Kbd };
-8
apps/web/src/components/layout/app-page-layout.tsx
··· 1 1 import { Shell } from "@/components/dashboard/shell"; 2 2 import { cn } from "@/lib/utils"; 3 - import { HelpCallout } from "../dashboard/help-callout"; 4 3 5 4 export default function AppPageLayout({ 6 5 children, 7 6 className, 8 - withHelpCallout = false, 9 7 }: { 10 8 children: React.ReactNode; 11 9 className?: string; 12 - withHelpCallout?: boolean; 13 10 }) { 14 11 return ( 15 12 <Shell className="relative flex flex-1 flex-col overflow-x-hidden"> ··· 18 15 > 19 16 {children} 20 17 </div> 21 - {withHelpCallout ? ( 22 - <div className="mt-4"> 23 - <HelpCallout /> 24 - </div> 25 - ) : null} 26 18 </Shell> 27 19 ); 28 20 }
+3 -3
apps/web/src/components/monitor/status-dot.tsx
··· 17 17 if (maintenance) { 18 18 return ( 19 19 <span className="relative flex h-2 w-2"> 20 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500/80 opacity-75 duration-1000" /> 20 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500/80 opacity-75 duration-[2000ms]" /> 21 21 <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" /> 22 22 </span> 23 23 ); ··· 25 25 if (status === "error") { 26 26 return ( 27 27 <span className="relative flex h-2 w-2"> 28 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-1000" /> 28 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-[2000ms]" /> 29 29 <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 30 30 </span> 31 31 ); ··· 33 33 34 34 return ( 35 35 <span className="relative flex h-2 w-2"> 36 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-1000" /> 36 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-[2000ms]" /> 37 37 <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> 38 38 </span> 39 39 );
+1 -1
apps/web/src/components/support/bubble.tsx
··· 29 29 setOpen(value); 30 30 }} 31 31 > 32 - <PopoverTrigger className="rounded-full border p-2 shadow"> 32 + <PopoverTrigger className="rounded-full border p-2 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"> 33 33 <MessageCircle className="h-6 w-6" /> 34 34 </PopoverTrigger> 35 35 <PopoverContent
+125
packages/api/src/router/monitor.ts
··· 405 405 } 406 406 }), 407 407 408 + updateMonitors: protectedProcedure 409 + .input( 410 + insertMonitorSchema 411 + .pick({ public: true, active: true }) 412 + .partial() // batched updates 413 + .extend({ ids: z.number().array() }), // array of monitor ids to update 414 + ) 415 + .mutation(async (opts) => { 416 + const _monitors = await opts.ctx.db 417 + .update(monitor) 418 + .set(opts.input) 419 + .where( 420 + and( 421 + inArray(monitor.id, opts.input.ids), 422 + eq(monitor.workspaceId, opts.ctx.workspace.id), 423 + isNull(monitor.deletedAt), 424 + ), 425 + ); 426 + }), 427 + 428 + updateMonitorsTag: protectedProcedure 429 + .input( 430 + z.object({ 431 + ids: z.number().array(), 432 + tagId: z.number(), 433 + action: z.enum(["add", "remove"]), 434 + }), 435 + ) 436 + .mutation(async (opts) => { 437 + const _monitorTag = await opts.ctx.db.query.monitorTag.findFirst({ 438 + where: and( 439 + eq(monitorTag.workspaceId, opts.ctx.workspace.id), 440 + eq(monitorTag.id, opts.input.tagId), 441 + ), 442 + }); 443 + 444 + const _monitors = await opts.ctx.db.query.monitor.findMany({ 445 + where: and( 446 + eq(monitor.workspaceId, opts.ctx.workspace.id), 447 + inArray(monitor.id, opts.input.ids), 448 + ), 449 + }); 450 + 451 + if (!_monitorTag || _monitors.length !== opts.input.ids.length) { 452 + throw new TRPCError({ 453 + code: "BAD_REQUEST", 454 + message: "Invalid tag", 455 + }); 456 + } 457 + 458 + if (opts.input.action === "add") { 459 + await opts.ctx.db 460 + .insert(monitorTagsToMonitors) 461 + .values( 462 + opts.input.ids.map((id) => ({ 463 + monitorId: id, 464 + monitorTagId: opts.input.tagId, 465 + })), 466 + ) 467 + .onConflictDoNothing() 468 + .run(); 469 + } 470 + 471 + if (opts.input.action === "remove") { 472 + await opts.ctx.db 473 + .delete(monitorTagsToMonitors) 474 + .where( 475 + and( 476 + inArray(monitorTagsToMonitors.monitorId, opts.input.ids), 477 + eq(monitorTagsToMonitors.monitorTagId, opts.input.tagId), 478 + ), 479 + ) 480 + .run(); 481 + } 482 + }), 483 + 408 484 delete: protectedProcedure 409 485 .input(z.object({ id: z.number() })) 410 486 .mutation(async (opts) => { ··· 439 515 await tx 440 516 .delete(notificationsToMonitors) 441 517 .where(eq(notificationsToMonitors.monitorId, monitorToDelete.id)); 518 + await tx 519 + .delete(maintenancesToMonitors) 520 + .where(eq(maintenancesToMonitors.monitorId, monitorToDelete.id)); 521 + }); 522 + }), 523 + 524 + deleteMonitors: protectedProcedure 525 + .input(z.object({ ids: z.number().array() })) 526 + .mutation(async (opts) => { 527 + const _monitors = await opts.ctx.db 528 + .select() 529 + .from(monitor) 530 + .where( 531 + and( 532 + inArray(monitor.id, opts.input.ids), 533 + eq(monitor.workspaceId, opts.ctx.workspace.id), 534 + ), 535 + ) 536 + .all(); 537 + 538 + if (_monitors.length !== opts.input.ids.length) { 539 + throw new TRPCError({ 540 + code: "NOT_FOUND", 541 + message: "Monitor not found.", 542 + }); 543 + } 544 + 545 + await opts.ctx.db 546 + .update(monitor) 547 + .set({ deletedAt: new Date(), active: false }) 548 + .where(inArray(monitor.id, opts.input.ids)) 549 + .run(); 550 + 551 + await opts.ctx.db.transaction(async (tx) => { 552 + await tx 553 + .delete(monitorsToPages) 554 + .where(inArray(monitorsToPages.monitorId, opts.input.ids)); 555 + await tx 556 + .delete(monitorTagsToMonitors) 557 + .where(inArray(monitorTagsToMonitors.monitorId, opts.input.ids)); 558 + await tx 559 + .delete(monitorsToStatusReport) 560 + .where(inArray(monitorsToStatusReport.monitorId, opts.input.ids)); 561 + await tx 562 + .delete(notificationsToMonitors) 563 + .where(inArray(notificationsToMonitors.monitorId, opts.input.ids)); 564 + await tx 565 + .delete(maintenancesToMonitors) 566 + .where(inArray(maintenancesToMonitors.monitorId, opts.input.ids)); 442 567 }); 443 568 }), 444 569
+23
pnpm-lock.yaml
··· 305 305 '@openstatus/utils': 306 306 specifier: workspace:* 307 307 version: link:../../packages/utils 308 + '@radix-ui/react-portal': 309 + specifier: ^1.0.4 310 + version: 1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 308 311 '@sentry/integrations': 309 312 specifier: 7.116.0 310 313 version: 7.116.0 ··· 10676 10679 '@types/react': 18.2.64 10677 10680 '@types/react-dom': 18.2.21 10678 10681 10682 + '@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': 10683 + dependencies: 10684 + '@babel/runtime': 7.23.2 10685 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 10686 + react: 18.2.0 10687 + react-dom: 18.2.0(react@18.2.0) 10688 + optionalDependencies: 10689 + '@types/react': 18.2.64 10690 + '@types/react-dom': 18.2.21 10691 + 10679 10692 '@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.3.1(react@18.2.0))(react@18.2.0)': 10680 10693 dependencies: 10681 10694 '@babel/runtime': 7.23.2 ··· 10726 10739 '@radix-ui/react-slot': 1.0.0(react@18.2.0) 10727 10740 react: 18.2.0 10728 10741 react-dom: 18.3.1(react@18.2.0) 10742 + 10743 + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': 10744 + dependencies: 10745 + '@babel/runtime': 7.23.2 10746 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.64)(react@18.2.0) 10747 + react: 18.2.0 10748 + react-dom: 18.2.0(react@18.2.0) 10749 + optionalDependencies: 10750 + '@types/react': 18.2.64 10751 + '@types/react-dom': 18.2.21 10729 10752 10730 10753 '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.3.1(react@18.2.0))(react@18.2.0)': 10731 10754 dependencies: