Openstatus www.openstatus.dev

feat: improve notifications (#910)

* chore: data table badges component for notification monitors

* fix: sorting in monitor data table

* feat: add link to notification name

* refactor: move icon and link to status page slug

authored by

Maximilian Kaske and committed by
GitHub
77b0c629 e1c60be3

+153 -93
+41
apps/web/src/components/data-table/data-table-badges.tsx
··· 1 + import { 2 + Badge, 3 + Tooltip, 4 + TooltipContent, 5 + TooltipProvider, 6 + TooltipTrigger, 7 + } from "@openstatus/ui"; 8 + 9 + export function DataTableBadges({ names }: { names: string[] }) { 10 + const [first, second, ...rest] = names; 11 + 12 + if (names.length === 0) return null; 13 + 14 + return ( 15 + <div className="flex items-center gap-2"> 16 + <span className="flex max-w-[150px] gap-2 truncate font-medium lg:max-w-[250px] sm:max-w-[200px] xl:max-w-[350px]"> 17 + <Badge variant="outline">{first}</Badge> 18 + {second ? <Badge variant="outline">{second}</Badge> : null} 19 + </span> 20 + {rest.length > 0 ? ( 21 + <TooltipProvider> 22 + <Tooltip delayDuration={200}> 23 + <TooltipTrigger> 24 + <Badge variant="secondary" className="border"> 25 + +{rest.length} 26 + </Badge> 27 + </TooltipTrigger> 28 + <TooltipContent side="top" className="flex gap-2"> 29 + {rest.map((name, i) => ( 30 + // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 31 + <Badge key={i} variant="outline"> 32 + {name} 33 + </Badge> 34 + ))} 35 + </TooltipContent> 36 + </Tooltip> 37 + </TooltipProvider> 38 + ) : null} 39 + </div> 40 + ); 41 + }
+10 -5
apps/web/src/components/data-table/monitor/columns.tsx
··· 213 213 sortingFn: (rowA, rowB, columnId) => { 214 214 const valueA = rowA.getValue(columnId) as number | undefined; 215 215 const valueB = rowB.getValue(columnId) as number | undefined; 216 - if (!valueA || !valueB) return 0; 216 + if (!valueB) return valueA || 1; 217 + if (!valueA) return -valueB; 217 218 return valueA - valueB; 218 219 }, 219 220 }, ··· 231 232 sortingFn: (rowA, rowB, columnId) => { 232 233 const valueA = rowA.getValue(columnId) as number | undefined; 233 234 const valueB = rowB.getValue(columnId) as number | undefined; 234 - if (!valueA || !valueB) return 0; 235 + if (!valueB) return valueA || 1; 236 + if (!valueA) return -valueB; 235 237 return valueA - valueB; 236 238 }, 237 239 }, ··· 249 251 sortingFn: (rowA, rowB, columnId) => { 250 252 const valueA = rowA.getValue(columnId) as number | undefined; 251 253 const valueB = rowB.getValue(columnId) as number | undefined; 252 - if (!valueA || !valueB) return 0; 254 + if (!valueB) return valueA || 1; 255 + if (!valueA) return -valueB; 253 256 return valueA - valueB; 254 257 }, 255 258 }, ··· 267 270 sortingFn: (rowA, rowB, columnId) => { 268 271 const valueA = rowA.getValue(columnId) as number | undefined; 269 272 const valueB = rowB.getValue(columnId) as number | undefined; 270 - if (!valueA || !valueB) return 0; 273 + if (!valueB) return valueA || 1; 274 + if (!valueA) return -valueB; 271 275 return valueA - valueB; 272 276 }, 273 277 }, ··· 285 289 sortingFn: (rowA, rowB, columnId) => { 286 290 const valueA = rowA.getValue(columnId) as number | undefined; 287 291 const valueB = rowB.getValue(columnId) as number | undefined; 288 - if (!valueA || !valueB) return 0; 292 + if (!valueB) return valueA || 1; 293 + if (!valueA) return -valueB; 289 294 return valueA - valueB; 290 295 }, 291 296 },
+3 -2
apps/web/src/components/data-table/monitor/data-table-toolbar.tsx
··· 71 71 </Button> 72 72 )} 73 73 </div> 74 - <div className="flex h-8 items-center self-end rounded-lg border border-dashed bg-muted/50 px-3"> 74 + <div className="flex items-center self-end rounded-lg border border-dashed bg-muted/50 px-3 py-2"> 75 75 <p className="text-muted-foreground text-xs"> 76 - Quantiles and Uptime are aggregated data from the last 24h. 76 + Quantiles and Uptime are aggregated data from the{" "} 77 + <span className="text-foreground">last 24h</span>. 77 78 </p> 78 79 </div> 79 80 </div>
+37 -6
apps/web/src/components/data-table/notification/columns.tsx
··· 2 2 3 3 import type { ColumnDef } from "@tanstack/react-table"; 4 4 5 - import type { Notification } from "@openstatus/db/src/schema"; 5 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 6 6 import { Badge } from "@openstatus/ui"; 7 7 8 8 import { DataTableRowActions } from "./data-table-row-actions"; 9 + import { z } from "zod"; 10 + import { DataTableBadges } from "../data-table-badges"; 11 + import Link from "next/link"; 9 12 10 13 // TODO: use the getProviderMetaData function from the notification form to access the data 11 14 12 - export const columns: ColumnDef<Notification>[] = [ 15 + export const columns: ColumnDef< 16 + Notification & { monitor: { monitor: Monitor }[] } 17 + >[] = [ 13 18 { 14 19 accessorKey: "name", 15 20 header: "Name", 21 + cell: ({ row }) => { 22 + const { name } = row.original; 23 + return ( 24 + <div className="flex gap-2"> 25 + <Link 26 + href={`./notifications/${row.original.id}/edit`} 27 + className="group flex max-w-full items-center gap-2" 28 + prefetch={false} 29 + > 30 + <span className="truncate group-hover:underline">{name}</span> 31 + </Link> 32 + </div> 33 + ); 34 + }, 16 35 }, 17 36 { 18 37 accessorKey: "provider", ··· 25 44 ); 26 45 }, 27 46 }, 28 - // { 29 - // accessorKey: "data", 30 - // header: "Data", 31 - // }, 47 + { 48 + accessorKey: "monitor", 49 + header: "Monitors", 50 + cell: ({ row }) => { 51 + const monitor = row.getValue("monitor"); 52 + const monitors = z 53 + .object({ monitor: z.object({ name: z.string() }) }) 54 + .array() 55 + .parse(monitor); 56 + return ( 57 + <DataTableBadges 58 + names={monitors.map((monitor) => monitor.monitor.name)} 59 + /> 60 + ); 61 + }, 62 + }, 32 63 { 33 64 id: "actions", 34 65 cell: ({ row }) => {
+36 -62
apps/web/src/components/data-table/status-page/columns.tsx
··· 7 7 8 8 import type { Maintenance, Page } from "@openstatus/db/src/schema"; 9 9 import { 10 - Badge, 11 10 Tooltip, 12 11 TooltipContent, 13 12 TooltipProvider, ··· 16 15 17 16 import { ArrowUpRight, Check } from "lucide-react"; 18 17 import { DataTableRowActions } from "./data-table-row-actions"; 18 + import { DataTableBadges } from "../data-table-badges"; 19 19 20 20 export const columns: ColumnDef< 21 21 Page & { ··· 28 28 header: "Title", 29 29 cell: ({ row }) => { 30 30 return ( 31 - <div className="flex items-center gap-1"> 32 - <Link 33 - href={`./status-pages/${row.original.id}/edit`} 34 - className="group flex items-center gap-2" 35 - > 36 - <span className="max-w-[125px] truncate group-hover:underline"> 37 - {row.getValue("title")} 38 - </span> 39 - </Link> 40 - <TooltipProvider> 41 - <Tooltip delayDuration={100}> 42 - <TooltipTrigger> 43 - <a 44 - href={ 45 - process.env.NODE_ENV === "production" 46 - ? `https://${row.original.slug}.openstatus.dev` 47 - : `/status-page/${row.original.slug}` 48 - } 49 - target="_blank" 50 - rel="noreferrer" 51 - className="text-muted-foreground hover:text-foreground" 52 - > 53 - <ArrowUpRight className="h-4 w-4 flex-shrink-0" /> 54 - </a> 55 - </TooltipTrigger> 56 - <TooltipContent>Visit page</TooltipContent> 57 - </Tooltip> 58 - </TooltipProvider> 59 - </div> 31 + <Link 32 + href={`./status-pages/${row.original.id}/edit`} 33 + className="group flex items-center gap-2" 34 + > 35 + <span className="max-w-[125px] truncate group-hover:underline"> 36 + {row.getValue("title")} 37 + </span> 38 + </Link> 60 39 ); 61 40 }, 62 41 }, ··· 64 43 accessorKey: "slug", 65 44 header: "Slug", 66 45 cell: ({ row }) => { 67 - return <span className="font-mono">{row.getValue("slug")}</span>; 46 + return ( 47 + <TooltipProvider> 48 + <Tooltip delayDuration={100}> 49 + <TooltipTrigger> 50 + <a 51 + href={ 52 + process.env.NODE_ENV === "production" 53 + ? `https://${row.original.slug}.openstatus.dev` 54 + : `/status-page/${row.original.slug}` 55 + } 56 + target="_blank" 57 + rel="noreferrer" 58 + className="group flex items-center gap-1" 59 + > 60 + <span className="max-w-[125px] truncate font-mono group-hover:underline"> 61 + {row.getValue("slug")} 62 + </span> 63 + <ArrowUpRight className="h-4 w-4 flex-shrink-0 text-muted-foreground group-hover:text-foreground" /> 64 + </a> 65 + </TooltipTrigger> 66 + <TooltipContent>Visit page</TooltipContent> 67 + </Tooltip> 68 + </TooltipProvider> 69 + ); 68 70 }, 69 71 }, 70 72 { ··· 76 78 .object({ monitor: z.object({ name: z.string() }) }) 77 79 .array() 78 80 .parse(monitorsToPages); 79 - const firstMonitors = monitors.splice(0, 2); 80 - const lastMonitors = monitors; 81 81 return ( 82 - <div className="flex items-center gap-2"> 83 - <span className="flex max-w-[150px] gap-2 truncate font-medium lg:max-w-[250px] sm:max-w-[200px] xl:max-w-[350px]"> 84 - {firstMonitors.map(({ monitor: { name } }, i) => ( 85 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 86 - <Badge key={i} variant="outline"> 87 - {name} 88 - </Badge> 89 - ))} 90 - </span> 91 - {lastMonitors.length > 0 ? ( 92 - <TooltipProvider> 93 - <Tooltip delayDuration={200}> 94 - <TooltipTrigger> 95 - <Badge variant="secondary" className="border"> 96 - +{lastMonitors.length} 97 - </Badge> 98 - </TooltipTrigger> 99 - <TooltipContent side="top" className="flex gap-2"> 100 - {lastMonitors.map(({ monitor: { name } }, i) => ( 101 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 102 - <Badge key={i} variant="outline"> 103 - {name} 104 - </Badge> 105 - ))} 106 - </TooltipContent> 107 - </Tooltip> 108 - </TooltipProvider> 109 - ) : null} 110 - </div> 82 + <DataTableBadges 83 + names={monitors.map((monitor) => monitor.monitor.name)} 84 + /> 111 85 ); 112 86 }, 113 87 },
+25 -14
packages/api/src/router/notification.ts
··· 6 6 NotificationDataSchema, 7 7 insertNotificationSchema, 8 8 notification, 9 + selectMonitorSchema, 9 10 selectNotificationSchema, 10 11 } from "@openstatus/db/src/schema"; 11 12 import { getLimit } from "@openstatus/plans"; ··· 23 24 24 25 const notificationLimit = getLimit( 25 26 opts.ctx.workspace.plan, 26 - "notification-channels" 27 + "notification-channels", 27 28 ); 28 29 29 30 const notificationNumber = ( ··· 85 86 .where( 86 87 and( 87 88 eq(notification.id, opts.input.id), 88 - eq(notification.workspaceId, opts.ctx.workspace.id) 89 - ) 89 + eq(notification.workspaceId, opts.ctx.workspace.id), 90 + ), 90 91 ) 91 92 .returning() 92 93 .get(); ··· 100 101 .where( 101 102 and( 102 103 eq(notification.id, opts.input.id), 103 - eq(notification.id, opts.input.id) 104 - ) 104 + eq(notification.id, opts.input.id), 105 + ), 105 106 ) 106 107 .run(); 107 108 }), ··· 116 117 and( 117 118 eq(notification.id, opts.input.id), 118 119 eq(notification.id, opts.input.id), 119 - eq(notification.workspaceId, opts.ctx.workspace.id) 120 - ) 120 + eq(notification.workspaceId, opts.ctx.workspace.id), 121 + ), 121 122 ) 122 123 .get(); 123 124 ··· 125 126 }), 126 127 127 128 getNotificationsByWorkspace: protectedProcedure.query(async (opts) => { 128 - const notifications = await opts.ctx.db 129 - .select() 130 - .from(notification) 131 - .where(eq(notification.workspaceId, opts.ctx.workspace.id)) 132 - .all(); 129 + const notifications = await opts.ctx.db.query.notification.findMany({ 130 + where: and(eq(notification.workspaceId, opts.ctx.workspace.id)), 131 + with: { 132 + // FIXME: first should be plurals! 133 + monitor: { with: { monitor: true } }, 134 + }, 135 + }); 136 + 137 + const schema = selectNotificationSchema.extend({ 138 + monitor: z.array( 139 + z.object({ 140 + monitor: selectMonitorSchema, 141 + }), 142 + ), 143 + }); 133 144 134 - return z.array(selectNotificationSchema).parse(notifications); 145 + return z.array(schema).parse(notifications); 135 146 }), 136 147 137 148 isNotificationLimitReached: protectedProcedure.query(async (opts) => { 138 149 const notificationLimit = getLimit( 139 150 opts.ctx.workspace.plan, 140 - "notification-channels" 151 + "notification-channels", 141 152 ); 142 153 const notificationNumbers = ( 143 154 await opts.ctx.db.query.notification.findMany({
+1 -4
packages/db/src/schema/shared.ts
··· 1 1 import { z } from "zod"; 2 2 3 3 import { selectIncidentSchema } from "./incidents/validation"; 4 - import { 5 - maintenancesToMonitors, 6 - selectMaintenanceSchema, 7 - } from "./maintenances"; 4 + import { selectMaintenanceSchema } from "./maintenances"; 8 5 import { selectMonitorSchema } from "./monitors"; 9 6 import { selectPageSchema } from "./pages"; 10 7 import {