Openstatus www.openstatus.dev
6
fork

Configure Feed

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

feat: incident screenshot (#747)

* feat: incident screenshot

* fix: overflow-hidden

authored by

Maximilian Kaske and committed by
GitHub
5a2f14c3 59162957

+235 -11
+4
apps/web/next.config.js
··· 27 27 protocol: "https", 28 28 hostname: "**.public.blob.vercel-storage.com", 29 29 }, 30 + { 31 + protocol: "https", 32 + hostname: "screenshot.openstat.us", 33 + }, 30 34 ], 31 35 }, 32 36 };
+7 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/[id]/layout.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 + import { Header } from "@/components/dashboard/header"; 3 4 import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 4 5 import { api } from "@/trpc/server"; 5 6 ··· 12 13 }) { 13 14 const id = params.id; 14 15 15 - const monitor = await api.incident.getIncidentById.query({ 16 + const incidents = await api.incident.getIncidentsByWorkspace.query(); 17 + const incident = await api.incident.getIncidentById.query({ 16 18 id: Number(id), 17 19 }); 18 20 19 - if (!monitor) { 21 + if (!incident) { 20 22 return notFound(); 21 23 } 22 24 25 + const incidentIndex = incidents.findIndex((item) => item.id === incident.id); 26 + 23 27 return ( 24 28 <AppPageWithSidebarLayout id="incidents"> 29 + <Header title={`Incident #${incidentIndex + 1}`} /> 25 30 {children} 26 31 </AppPageWithSidebarLayout> 27 32 );
+38
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/[id]/overview/_components/event.tsx
··· 1 + import * as React from "react"; 2 + import { format } from "date-fns"; 3 + 4 + import type { ValidIcon } from "@/components/icons"; 5 + import { Icons } from "@/components/icons"; 6 + 7 + export function Event({ 8 + label, 9 + date, 10 + icon, 11 + children, 12 + }: { 13 + label: string; 14 + date: Date; 15 + icon: ValidIcon; 16 + children?: React.ReactNode; 17 + }) { 18 + const Icon = Icons[icon]; 19 + return ( 20 + <div className="group relative -m-2 flex gap-4 border border-transparent p-2"> 21 + <div className="relative"> 22 + <div className="bg-background rounded-full border p-2"> 23 + <Icon className="h-4 w-4" /> 24 + </div> 25 + <div className="bg-muted absolute inset-x-0 mx-auto h-full w-[2px]" /> 26 + </div> 27 + <div className="mt-1 flex flex-1 flex-col gap-1"> 28 + <div className="flex items-center justify-between gap-4"> 29 + <p className="text-sm font-semibold">{label}</p> 30 + <p className="text-muted-foreground mt-px text-right text-[10px]"> 31 + <code>{format(new Date(date), "LLL dd, y HH:mm:ss")}</code> 32 + </p> 33 + </div> 34 + {children} 35 + </div> 36 + </div> 37 + ); 38 + }
+114
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/[id]/overview/page.tsx
··· 1 + import Image from "next/image"; 2 + import Link from "next/link"; 3 + import { formatDistanceStrict } from "date-fns"; 4 + import { ArrowUpRight } from "lucide-react"; 5 + 6 + import { api } from "@/trpc/server"; 7 + import { Event } from "./_components/event"; 8 + 9 + /** 10 + * MetricCards (Like: Duration, Monitor Name, Autoresolved,...) 11 + * 12 + * Start Date + (can we include the response details?) 13 + * Screenshot 14 + * Acknowledged 15 + * Resolved 16 + * 17 + */ 18 + 19 + export default async function IncidentPage({ 20 + params, 21 + }: { 22 + params: { workspaceSlug: string; id: string }; 23 + }) { 24 + const incident = await api.incident.getIncidentById.query({ 25 + id: Number(params.id), 26 + }); 27 + 28 + const duration = formatDistanceStrict( 29 + new Date(incident.startedAt), 30 + incident?.resolvedAt ? new Date(incident.resolvedAt) : new Date(), 31 + ); 32 + 33 + return ( 34 + <div className="grid gap-6"> 35 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 md:gap-6"> 36 + <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 37 + <p className="text-muted-foreground text-sm font-light uppercase"> 38 + Monitor 39 + </p> 40 + <div className="flex flex-row items-end gap-2"> 41 + <p className="text-xl font-semibold">{incident.monitorName}</p> 42 + <Link 43 + href={`/app/${params.workspaceSlug}/monitors/${incident.monitorId}/overview`} 44 + className="text-muted-foreground hover:text-foreground" 45 + > 46 + <ArrowUpRight /> 47 + </Link> 48 + </div> 49 + </div> 50 + <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 51 + <p className="text-muted-foreground text-sm font-light uppercase"> 52 + Duration 53 + </p> 54 + <p className="text-xl font-semibold">{duration}</p> 55 + </div> 56 + <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 57 + <p className="text-muted-foreground text-sm font-light uppercase"> 58 + Auto-resolved 59 + </p> 60 + <p className="font-mono text-xl font-semibold"> 61 + {incident.autoResolved ? "true" : "false"} 62 + </p> 63 + </div> 64 + </div> 65 + <div className="max-w-xl"> 66 + <Event label="Started" icon="alert-triangle" date={incident.startedAt}> 67 + {incident.incidentScreenshotUrl ? ( 68 + <div className="border-border bg-background relative h-64 w-full overflow-hidden rounded-xl border"> 69 + <a 70 + href={incident.incidentScreenshotUrl} 71 + target="_blank" 72 + rel="noreferrer" 73 + > 74 + <Image 75 + src={incident.incidentScreenshotUrl} 76 + fill={true} 77 + alt="incident screenshot" 78 + className="object-contain" 79 + /> 80 + </a> 81 + </div> 82 + ) : null} 83 + </Event> 84 + {incident?.acknowledgedAt ? ( 85 + <Event 86 + label="Acknowledged" 87 + icon="eye" 88 + date={incident.acknowledgedAt} 89 + /> 90 + ) : null} 91 + {incident?.resolvedAt ? ( 92 + <Event label="Resolved" icon="check" date={incident.resolvedAt}> 93 + {incident.recoveryScreenshotUrl ? ( 94 + <div className="border-border bg-background relative h-64 w-full overflow-hidden rounded-xl border"> 95 + <a 96 + href={incident.recoveryScreenshotUrl} 97 + target="_blank" 98 + rel="noreferrer" 99 + > 100 + <Image 101 + src={incident.recoveryScreenshotUrl} 102 + fill={true} 103 + alt="recovery screenshot" 104 + className="object-contain" 105 + /> 106 + </a> 107 + </div> 108 + ) : null} 109 + </Event> 110 + ) : null} 111 + </div> 112 + </div> 113 + ); 114 + }
+8 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/[id]/page.tsx
··· 1 - export default async function IncidentPage() { 2 - return <></>; 1 + import { redirect } from "next/navigation"; 2 + 3 + export default function Page({ 4 + params, 5 + }: { 6 + params: { workspaceSlug: string; id: string }; 7 + }) { 8 + return redirect(`./${params.id}/overview`); 3 9 }
+33 -4
apps/web/src/components/data-table/incident/columns.tsx
··· 1 1 "use client"; 2 2 3 + import Image from "next/image"; 3 4 import Link from "next/link"; 4 5 import type { ColumnDef } from "@tanstack/react-table"; 5 6 import { formatDistanceStrict } from "date-fns"; ··· 30 31 accessorKey: "startedAt", 31 32 header: "Started At", 32 33 cell: ({ row }) => { 33 - const { startedAt } = row.original; 34 + const { startedAt, incidentScreenshotUrl } = row.original; 34 35 const date = startedAt ? formatDateTime(startedAt) : "-"; 35 36 return ( 36 - <div className="flex"> 37 + <div className="flex gap-2"> 37 38 <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 38 39 {date} 39 40 </span> 41 + {incidentScreenshotUrl ? ( 42 + <a 43 + href={incidentScreenshotUrl} 44 + target="_blank" 45 + rel="noreferrer" 46 + className="relative relative h-5 w-5 overflow-hidden rounded border" 47 + > 48 + <Image 49 + src={incidentScreenshotUrl} 50 + alt="incident screenshot" 51 + fill={true} 52 + /> 53 + </a> 54 + ) : null} 40 55 </div> 41 56 ); 42 57 }, ··· 60 75 accessorKey: "resolvedAt", 61 76 header: "Resolved At", 62 77 cell: ({ row }) => { 63 - const { resolvedAt } = row.original; 78 + const { resolvedAt, recoveryScreenshotUrl } = row.original; 64 79 const date = resolvedAt ? formatDateTime(resolvedAt) : "-"; 65 80 return ( 66 - <div className="flex"> 81 + <div className="flex gap-2"> 67 82 <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 68 83 {date} 69 84 </span> 85 + {recoveryScreenshotUrl ? ( 86 + <a 87 + href={recoveryScreenshotUrl} 88 + target="_blank" 89 + rel="noreferrer" 90 + className="relative h-5 w-5 overflow-hidden rounded border" 91 + > 92 + <Image 93 + src={recoveryScreenshotUrl} 94 + alt="recovery screenshot" 95 + fill={true} 96 + /> 97 + </a> 98 + ) : null} 70 99 </div> 71 100 ); 72 101 },
+5
apps/web/src/components/data-table/incident/data-table-row-actions.tsx
··· 1 1 "use client"; 2 2 3 3 import * as React from "react"; 4 + import Link from "next/link"; 4 5 import { useRouter } from "next/navigation"; 5 6 import type { Row } from "@tanstack/react-table"; 6 7 import { MoreHorizontal } from "lucide-react"; ··· 107 108 > 108 109 Resolved 109 110 </DropdownMenuItem> 111 + <DropdownMenuSeparator /> 112 + <Link href={`./incidents/${incident.id}/overview`}> 113 + <DropdownMenuItem>Details</DropdownMenuItem> 114 + </Link> 110 115 <DropdownMenuSeparator /> 111 116 <AlertDialogTrigger asChild> 112 117 <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background">
+4
apps/web/src/components/icons.tsx
··· 11 11 Cog, 12 12 Copy, 13 13 CreditCard, 14 + Eye, 15 + FileClock, 14 16 Fingerprint, 15 17 Globe2, 16 18 Hourglass, ··· 80 82 image: Image, 81 83 bell: Bell, 82 84 zap: Zap, 85 + eye: Eye, 83 86 users: Users, 84 87 key: KeyRound, 85 88 "credit-card": CreditCard, 86 89 "alert-triangle": AlertTriangle, 90 + "file-clock": FileClock, 87 91 megaphone: Megaphone, 88 92 webhook: Webhook, 89 93 minus: Minus,
+1 -1
apps/web/src/components/layout/app-page-with-sidebar-layout.tsx
··· 24 24 <Shell className="hidden max-h-[calc(100vh-8rem)] max-w-min shrink-0 lg:sticky lg:top-28 lg:block"> 25 25 <AppSidebar page={page} /> 26 26 </Shell> 27 - <Shell className="relative flex-1 overflow-x-hidden"> 27 + <Shell className="relative flex-1 overflow-hidden"> 28 28 <div 29 29 className={cn( 30 30 "flex h-full flex-1 flex-col gap-6 md:gap-8",
+11
apps/web/src/config/pages.ts
··· 96 96 }, 97 97 ]; 98 98 99 + const incidentPagesConfig: Page[] = [ 100 + { 101 + title: "Overview", 102 + description: "Timeline with all the actions.", 103 + href: "/incidents/[id]/overview", 104 + icon: "file-clock", 105 + segment: "overview", 106 + }, 107 + ]; 108 + 99 109 export const statusReportsPagesConfig: Page[] = [ 100 110 { 101 111 title: "Overview", ··· 140 150 href: "/incidents", 141 151 icon: "siren", 142 152 segment: "incidents", 153 + children: incidentPagesConfig, 143 154 }, 144 155 { 145 156 title: "Status Pages",
+10 -2
packages/api/src/router/incident.ts
··· 30 30 .input(z.object({ id: z.number() })) 31 31 .output(selectIncidentSchema) 32 32 .query(async (opts) => { 33 - const currentIncident = await opts.ctx.db 33 + const result = await opts.ctx.db 34 34 .select() 35 35 .from(schema.incidentTable) 36 36 .where( ··· 38 38 eq(schema.incidentTable.id, opts.input.id), 39 39 eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), 40 40 ), 41 + ) 42 + .leftJoin( 43 + schema.monitor, 44 + eq(schema.incidentTable.monitorId, schema.monitor.id), 41 45 ) 42 46 .get(); 43 - return selectIncidentSchema.parse(currentIncident); 47 + 48 + return selectIncidentSchema.parse({ 49 + ...result?.incident, 50 + monitorName: result?.monitor?.name, 51 + }); 44 52 }), 45 53 46 54 acknowledgeIncident: protectedProcedure