Openstatus www.openstatus.dev

feat: maintenances (#868)

* feat: init maintenances

* fix: dark mode, public/status api, widget color

* chore: changelog

* fix: og

* chore: clean up comments

* chore: remove monitors in maintenance

* 🤣 subquery

* fix: cron query

Co-authored-by: Thibault Le Ouay <thibaultleouay@users.noreply.github.com>

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>
Co-authored-by: Thibault Le Ouay <thibaultleouay@users.noreply.github.com>

authored by

Maximilian Kaske
Thibault Le Ouay
Thibault Le Ouay
and committed by
GitHub
a3c6beec 056c68c7

+3571 -121
+1 -1
apps/docs/packages/status-widget.mdx
··· 120 120 }, 121 121 under_maintenance: { 122 122 label: "Under Maintenance", 123 - color: "bg-gray-500", 123 + color: "bg-blue-500", 124 124 }, 125 125 } as const; 126 126
+45 -17
apps/server/src/public/status.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { endTime, setMetric, startTime } from "hono/timing"; 3 3 4 - import { and, db, eq, inArray, isNull } from "@openstatus/db"; 4 + import { and, db, eq, gte, inArray, isNull, lte } from "@openstatus/db"; 5 5 import { 6 6 incidentTable, 7 + maintenance, 7 8 monitor, 8 9 monitorsToPages, 9 10 monitorsToStatusReport, ··· 44 45 return c.json({ status: Status.Unknown }); 45 46 } 46 47 47 - const { pageStatusReportData, monitorStatusReportData, ongoingIncidents } = 48 - await getStatusPageData(currentPage.id); 48 + const { 49 + pageStatusReportData, 50 + monitorStatusReportData, 51 + ongoingIncidents, 52 + maintenanceData, 53 + } = await getStatusPageData(currentPage.id); 49 54 endTime(c, "database"); 55 + 56 + console.log(maintenanceData); 50 57 51 58 const statusReports = [ 52 59 ...pageStatusReportData, ··· 55 62 return item.status_report; 56 63 }); 57 64 58 - const tracker = new Tracker({ incidents: ongoingIncidents, statusReports }); 65 + const tracker = new Tracker({ 66 + incidents: ongoingIncidents, 67 + statusReports, 68 + maintenances: maintenanceData, 69 + }); 59 70 60 71 const status = tracker.currentStatus; 61 72 await redis.set(slug, status, { ex: 60 }); // 1m cache ··· 73 84 and( 74 85 eq(monitorsToPages.monitorId, monitor.id), 75 86 eq(monitor.active, true), 76 - eq(monitorsToPages.pageId, pageId), 77 - ), 87 + eq(monitorsToPages.pageId, pageId) 88 + ) 78 89 ) 79 90 80 91 .all(); ··· 94 105 .from(monitorsToStatusReport) 95 106 .innerJoin( 96 107 statusReport, 97 - eq(monitorsToStatusReport.statusReportId, statusReport.id), 108 + eq(monitorsToStatusReport.statusReportId, statusReport.id) 98 109 ) 99 110 .where(inArray(monitorsToStatusReport.monitorId, monitorIds)) 100 111 .all(); ··· 106 117 statusReport, 107 118 and( 108 119 eq(pagesToStatusReports.statusReportId, statusReport.id), 109 - eq(pagesToStatusReports.pageId, pageId), 110 - ), 120 + eq(pagesToStatusReports.pageId, pageId) 121 + ) 111 122 ) 112 123 .all(); 113 124 ··· 117 128 .where( 118 129 and( 119 130 isNull(incidentTable.resolvedAt), 120 - inArray(incidentTable.monitorId, monitorIds), 121 - ), 131 + inArray(incidentTable.monitorId, monitorIds) 132 + ) 122 133 ) 123 134 .all(); 124 135 125 - const [monitorStatusReportData, pageStatusReportData, ongoingIncidents] = 126 - await Promise.all([ 127 - monitorStatusReportQuery, 128 - pageStatusReportDataQuery, 129 - ongoingIncidentsQuery, 130 - ]); 136 + const ongoingMaintenancesQuery = db 137 + .select() 138 + .from(maintenance) 139 + .where( 140 + and( 141 + eq(maintenance.pageId, pageId), 142 + lte(maintenance.from, new Date()), 143 + gte(maintenance.to, new Date()) 144 + ) 145 + ); 146 + 147 + const [ 148 + monitorStatusReportData, 149 + pageStatusReportData, 150 + ongoingIncidents, 151 + maintenanceData, 152 + ] = await Promise.all([ 153 + monitorStatusReportQuery, 154 + pageStatusReportDataQuery, 155 + ongoingIncidentsQuery, 156 + ongoingMaintenancesQuery, 157 + ]); 131 158 132 159 return { 133 160 // monitorData, 134 161 pageStatusReportData, 135 162 monitorStatusReportData, 163 + maintenanceData, 136 164 ongoingIncidents, 137 165 }; 138 166 }
apps/web/public/assets/changelog/maintenance-status.png

This is a binary file and will not be displayed.

+29 -4
apps/web/src/app/api/checker/cron/_cron.ts
··· 3 3 import type { NextRequest } from "next/server"; 4 4 import { z } from "zod"; 5 5 6 - import { and, db, eq } from "@openstatus/db"; 6 + import { and, db, eq, gte, lte, notInArray } from "@openstatus/db"; 7 7 import type { MonitorStatus } from "@openstatus/db/src/schema"; 8 8 import { 9 + maintenance, 10 + maintenancesToMonitors, 9 11 monitor, 10 12 monitorStatusTable, 11 13 selectMonitorSchema, ··· 43 45 const parent = client.queuePath( 44 46 env.GCP_PROJECT_ID, 45 47 env.GCP_LOCATION, 46 - periodicity, 48 + periodicity 47 49 ); 48 50 49 51 const timestamp = Date.now(); 50 52 53 + const currentMaintenance = db 54 + .select({ id: maintenance.id }) 55 + .from(maintenance) 56 + .where( 57 + and(lte(maintenance.from, new Date()), gte(maintenance.to, new Date())) 58 + ) 59 + .as("currentMaintenance"); 60 + 61 + const currentMaintenanceMonitors = db 62 + .select({ id: maintenancesToMonitors.monitorId }) 63 + .from(maintenancesToMonitors) 64 + .innerJoin( 65 + currentMaintenance, 66 + eq(maintenancesToMonitors.maintenanceId, currentMaintenance.id) 67 + ); 68 + 51 69 const result = await db 52 70 .select() 53 71 .from(monitor) 54 - .where(and(eq(monitor.periodicity, periodicity), eq(monitor.active, true))) 72 + .where( 73 + and( 74 + eq(monitor.periodicity, "10m"), 75 + eq(monitor.active, true), 76 + notInArray(monitor.id, currentMaintenanceMonitors) 77 + ) 78 + ) 55 79 .all(); 80 + 56 81 console.log(`Start cron for ${periodicity}`); 57 82 58 83 const monitors = z.array(selectMonitorSchema).parse(result); ··· 102 127 const failed = allRequests.filter((r) => r.status === "rejected").length; 103 128 104 129 console.log( 105 - `End cron for ${periodicity} with ${allResult.length} jobs with ${success} success and ${failed} failed`, 130 + `End cron for ${periodicity} with ${allResult.length} jobs with ${success} success and ${failed} failed` 106 131 ); 107 132 }; 108 133 // timestamp needs to be in ms
+22
apps/web/src/app/api/og/_components/status-check.tsx
··· 9 9 // FIXME: move icons into @openstatus/tracker lib 10 10 function getVariant() { 11 11 switch (details.variant) { 12 + case "maintenance": 13 + return Hammer; 12 14 case "down": 13 15 return Minus; 14 16 case "degraded": ··· 31 33 {details.long} 32 34 </p> 33 35 </div> 36 + ); 37 + } 38 + 39 + function Hammer() { 40 + return ( 41 + <svg 42 + xmlns="http://www.w3.org/2000/svg" 43 + width="40" 44 + height="40" 45 + viewBox="0 0 24 24" 46 + fill="none" 47 + stroke="currentColor" 48 + stroke-width="2" 49 + stroke-linecap="round" 50 + stroke-linejoin="round" 51 + > 52 + <path d="m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9" /> 53 + <path d="m18 15 4-4" /> 54 + <path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5" /> 55 + </svg> 34 56 ); 35 57 } 36 58
+8 -5
apps/web/src/app/api/og/page/route.tsx
··· 12 12 13 13 export async function GET(req: Request) { 14 14 const [interRegularData, interLightData, calSemiBoldData] = await Promise.all( 15 - [interRegular, interLight, calSemiBold], 15 + [interRegular, interLight, calSemiBold] 16 16 ); 17 17 18 18 const { searchParams } = new URL(req.url); ··· 30 30 const tracker = new Tracker({ 31 31 incidents: passwordProtected ? undefined : page?.incidents, 32 32 statusReports: passwordProtected ? undefined : page?.statusReports, 33 + maintenances: passwordProtected ? undefined : page?.maintenances, 33 34 }); 34 35 35 36 return new ImageResponse( 36 - <BasicLayout title={title} description={description} tw="py-24 px-24"> 37 - <StatusCheck tracker={tracker} /> 38 - </BasicLayout>, 37 + ( 38 + <BasicLayout title={title} description={description} tw="py-24 px-24"> 39 + <StatusCheck tracker={tracker} /> 40 + </BasicLayout> 41 + ), 39 42 { 40 43 ...SIZE, 41 44 fonts: [ ··· 58 61 weight: 600, 59 62 }, 60 63 ], 61 - }, 64 + } 62 65 ); 63 66 }
+6 -14
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/page.tsx
··· 2 2 3 3 import { PathCard } from "./_components/path-card"; 4 4 import { api } from "@/trpc/server"; 5 - import { Suspense } from "react"; 6 - import { Skeleton } from "@openstatus/ui/src/components/skeleton"; 7 - import Loading from "../loading"; 8 - import { auth } from "@/lib/auth"; 9 5 import { SessionTable } from "./_components/session-table"; 10 6 import { z } from "zod"; 11 7 ··· 18 14 }: { 19 15 searchParams: { [key: string]: string | string[] | undefined }; 20 16 }) { 21 - const session = await auth(); 22 - if (!session?.user) { 23 - return <Loading />; 24 - } 17 + const search = searchParamsSchema.safeParse(searchParams); 25 18 26 - const data = searchParamsSchema.parse(searchParams); 27 19 const applications = await api.workspace.getApplicationWorkspaces.query(); 28 - if (applications.length === 0 || !applications[0].dsn) { 29 - return null; 30 - } 20 + const dsn = applications?.[0]?.dsn; 21 + 22 + if (!search.success || !dsn) return null; 31 23 32 24 return ( 33 25 <> 34 - <PathCard dsn={applications[0].dsn} path={data.path} /> 26 + <PathCard dsn={dsn} path={search.data.path} /> 35 27 <div> 36 - <SessionTable dsn={applications[0].dsn} path={data.path} /> 28 + <SessionTable dsn={dsn} path={search.data.path} /> 37 29 </div> 38 30 </> 39 31 );
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx
··· 14 14 15 15 export default async function RUMPage() { 16 16 const applications = await api.workspace.getApplicationWorkspaces.query(); 17 - 18 17 const session = await auth(); 18 + 19 19 if (!session?.user) return null; 20 20 21 21 const accessRequested = await redis.sismember(
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/layout.tsx
··· 12 12 return ( 13 13 <AppPageLayout withHelpCallout> 14 14 <Header 15 - title="Pages" 15 + title="Status Pages" 16 16 description="Overview of all your pages." 17 17 actions={ 18 18 <ButtonWithDisableTooltip
+41
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/(overview)/page.tsx
··· 1 + import { EmptyState } from "@/components/dashboard/empty-state"; 2 + import { columns } from "@/components/data-table/maintenance/columns"; 3 + import { DataTable } from "@/components/data-table/maintenance/data-table"; 4 + import { api } from "@/trpc/server"; 5 + import { Button } from "@openstatus/ui"; 6 + import Link from "next/link"; 7 + 8 + export default async function MaintenancePage({ 9 + params, 10 + }: { 11 + params: { workspaceSlug: string; id: string }; 12 + }) { 13 + const maintenances = await api.maintenance.getByPage.query({ 14 + id: Number(params.id), 15 + }); 16 + 17 + if (maintenances?.length === 0) 18 + return ( 19 + <EmptyState 20 + icon="hammer" 21 + title="No maintenances" 22 + description="Add a maintenance to your status page." 23 + action={ 24 + <Button asChild> 25 + <Link href="./maintenances/new">Create a maintenance</Link> 26 + </Button> 27 + } 28 + /> 29 + ); 30 + 31 + return ( 32 + <div className="flex flex-col gap-4"> 33 + <div> 34 + <Button size="sm" asChild> 35 + <Link href="./maintenances/new">Create a maintenance</Link> 36 + </Button> 37 + </div> 38 + <DataTable data={maintenances} columns={columns} /> 39 + </div> 40 + ); 41 + }
+24
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/edit/page.tsx
··· 1 + import { MaintenanceForm } from "@/components/forms/maintenance/form"; 2 + import { api } from "@/trpc/server"; 3 + 4 + export default async function MaintenancePage({ 5 + params, 6 + }: { 7 + params: { workspaceSlug: string; id: string; maintenanceId: string }; 8 + }) { 9 + const monitors = await api.monitor.getMonitorsByPageId.query({ 10 + id: Number(params.id), 11 + }); 12 + const maintenance = await api.maintenance.getById.query({ 13 + id: Number(params.maintenanceId), 14 + }); 15 + 16 + return ( 17 + <MaintenanceForm 18 + defaultValues={maintenance} 19 + pageId={Number(params.id)} 20 + defaultSection="connect" 21 + monitors={monitors} 22 + /> 23 + ); 24 + }
+21
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/new/page.tsx
··· 1 + import { MaintenanceForm } from "@/components/forms/maintenance/form"; 2 + import { api } from "@/trpc/server"; 3 + 4 + export default async function MaintenancePage({ 5 + params, 6 + }: { 7 + params: { workspaceSlug: string; id: string }; 8 + }) { 9 + const monitors = await api.monitor.getMonitorsByPageId.query({ 10 + id: Number(params.id), 11 + }); 12 + 13 + return ( 14 + <MaintenanceForm 15 + nextUrl="./" // back to the overview page 16 + defaultSection="connect" 17 + pageId={Number(params.id)} 18 + monitors={monitors} 19 + /> 20 + ); 21 + }
+1
apps/web/src/app/status-page/[domain]/_components/header.tsx
··· 46 46 <TabsContainer className="-mb-[14px] hidden sm:block"> 47 47 {navigation.map(({ label, href, disabled, segment }) => { 48 48 const active = segment === selectedSegment; 49 + if (disabled) return null; 49 50 return ( 50 51 <TabsLink key={segment} {...{ active, href, label, disabled }}> 51 52 {label}
+1
apps/web/src/app/status-page/[domain]/_components/menu.tsx
··· 53 53 <ul className="grid gap-1"> 54 54 {navigation.map(({ href, label, segment, disabled }) => { 55 55 const active = segment === selectedSegment; 56 + if (disabled) return null; 56 57 return ( 57 58 <li key={href} className="w-full"> 58 59 <AppLink {...{ href, label, active, disabled }} />
+2 -1
apps/web/src/app/status-page/[domain]/incidents/[id]/page.tsx
··· 21 21 if (!report) return notFound(); 22 22 23 23 const affectedMonitors = report.monitorsToStatusReports.map( 24 - ({ monitor }) => monitor, 24 + ({ monitor }) => monitor 25 25 ); 26 26 27 27 return ( ··· 32 32 <StatusReportDescription 33 33 report={report} 34 34 monitors={affectedMonitors} 35 + className="mt-2" 35 36 /> 36 37 } 37 38 actions={<CopyLinkButton />}
+6
apps/web/src/app/status-page/[domain]/layout.tsx
··· 33 33 href: setPrefixUrl("/", params), 34 34 }, 35 35 { 36 + label: "Maintenances", 37 + segment: "maintenances", 38 + href: setPrefixUrl("/maintenances", params), 39 + disabled: page.maintenances.length === 0, 40 + }, 41 + { 36 42 label: "Incidents", 37 43 segment: "incidents", 38 44 href: setPrefixUrl("/incidents", params),
+31
apps/web/src/app/status-page/[domain]/maintenances/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { MaintenanceList } from "@/components/status-page/maintenance-list"; 5 + import { api } from "@/trpc/server"; 6 + 7 + type Props = { 8 + params: { domain: string }; 9 + searchParams: { [key: string]: string | string[] | undefined }; 10 + }; 11 + 12 + export default async function Page({ params }: Props) { 13 + if (!params.domain) return notFound(); 14 + 15 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 16 + if (!page) return notFound(); 17 + 18 + return ( 19 + <div className="grid gap-8"> 20 + <Header 21 + title={page.title} 22 + description={page.description} 23 + className="text-left" 24 + /> 25 + <MaintenanceList 26 + maintenances={page.maintenances} 27 + monitors={page.monitors} 28 + /> 29 + </div> 30 + ); 31 + }
+19 -2
apps/web/src/app/status-page/[domain]/page.tsx
··· 8 8 import { StatusCheck } from "@/components/status-page/status-check"; 9 9 import { StatusReportList } from "@/components/status-page/status-report-list"; 10 10 import { api } from "@/trpc/server"; 11 + import { MaintenanceBanner } from "@/components/status-page/maintenance-banner"; 11 12 12 13 type Props = { 13 14 params: { domain: string }; ··· 21 22 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 22 23 if (!page) return notFound(); 23 24 25 + const currentMaintenances = page.maintenances.filter( 26 + (maintenance) => 27 + maintenance.to.getTime() > Date.now() && 28 + maintenance.from.getTime() < Date.now() 29 + ); 30 + 24 31 return ( 25 32 <div className="mx-auto flex w-full flex-col gap-8"> 26 33 <Header ··· 31 38 <StatusCheck 32 39 statusReports={page.statusReports} 33 40 incidents={page.incidents} 41 + maintenances={page.maintenances} 34 42 /> 43 + {currentMaintenances.length ? ( 44 + <div className="grid w-full gap-3"> 45 + {currentMaintenances.map((maintenance) => ( 46 + <MaintenanceBanner key={maintenance.id} {...maintenance} /> 47 + ))} 48 + </div> 49 + ) : null} 35 50 <MonitorList 36 51 monitors={page.monitors} 37 52 statusReports={page.statusReports} 38 53 incidents={page.incidents} 54 + maintenances={page.maintenances} 39 55 /> 40 56 <Separator /> 41 57 <div className="grid gap-6"> 42 58 <div> 43 - <h2 className="font-semibold text-xl">Latest Incidents</h2> 59 + <h2 className="font-semibold text-xl">Last updates</h2> 44 60 <p className="text-muted-foreground text-sm"> 45 - Incidents of the last 7 days or that have not been resolved yet. 61 + Reports of the last 7 days or incidents that have not been resolved 62 + yet. 46 63 </p> 47 64 </div> 48 65 <StatusReportList
+36
apps/web/src/components/data-table/maintenance/columns.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + 5 + import type { Maintenance } from "@openstatus/db/src/schema"; 6 + 7 + import { DataTableRowActions } from "./data-table-row-actions"; 8 + 9 + export const columns: ColumnDef<Maintenance>[] = [ 10 + { 11 + accessorKey: "title", 12 + header: "Title", 13 + }, 14 + { 15 + accessorKey: "message", 16 + header: "Message", 17 + cell: ({ row }) => { 18 + return ( 19 + <p className="flex max-w-[125px] lg:max-w-[250px] xl:max-w-[350px]"> 20 + <span className="truncate">{row.getValue("message")}</span> 21 + </p> 22 + ); 23 + }, 24 + }, 25 + // missing: from, to 26 + { 27 + id: "actions", 28 + cell: ({ row }) => { 29 + return ( 30 + <div className="text-right"> 31 + <DataTableRowActions row={row} /> 32 + </div> 33 + ); 34 + }, 35 + }, 36 + ];
+104
apps/web/src/components/data-table/maintenance/data-table-row-actions.tsx
··· 1 + "use client"; 2 + 3 + import type { Row } from "@tanstack/react-table"; 4 + import { MoreHorizontal } from "lucide-react"; 5 + import Link from "next/link"; 6 + import { useRouter } from "next/navigation"; 7 + import * as React from "react"; 8 + 9 + import { selectMaintenanceSchema } from "@openstatus/db/src/schema"; 10 + import { 11 + AlertDialog, 12 + AlertDialogAction, 13 + AlertDialogCancel, 14 + AlertDialogContent, 15 + AlertDialogDescription, 16 + AlertDialogFooter, 17 + AlertDialogHeader, 18 + AlertDialogTitle, 19 + AlertDialogTrigger, 20 + Button, 21 + DropdownMenu, 22 + DropdownMenuContent, 23 + DropdownMenuItem, 24 + DropdownMenuTrigger, 25 + } from "@openstatus/ui"; 26 + 27 + import { LoadingAnimation } from "@/components/loading-animation"; 28 + import { toastAction } from "@/lib/toast"; 29 + import { api } from "@/trpc/client"; 30 + 31 + interface DataTableRowActionsProps<TData> { 32 + row: Row<TData>; 33 + } 34 + 35 + export function DataTableRowActions<TData>({ 36 + row, 37 + }: DataTableRowActionsProps<TData>) { 38 + const maintenance = selectMaintenanceSchema.parse(row.original); 39 + const router = useRouter(); 40 + const [alertOpen, setAlertOpen] = React.useState(false); 41 + const [isPending, startTransition] = React.useTransition(); 42 + 43 + async function onDelete() { 44 + startTransition(async () => { 45 + try { 46 + if (!maintenance.id) return; 47 + await api.maintenance.delete.mutate({ id: maintenance.id }); 48 + toastAction("deleted"); 49 + router.refresh(); 50 + setAlertOpen(false); 51 + } catch { 52 + toastAction("error"); 53 + } 54 + }); 55 + } 56 + 57 + return ( 58 + <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 59 + <DropdownMenu> 60 + <DropdownMenuTrigger asChild> 61 + <Button 62 + variant="ghost" 63 + className="h-8 w-8 p-0 data-[state=open]:bg-accent" 64 + > 65 + <span className="sr-only">Open menu</span> 66 + <MoreHorizontal className="h-4 w-4" /> 67 + </Button> 68 + </DropdownMenuTrigger> 69 + <DropdownMenuContent align="end"> 70 + <Link href={`./maintenances/${maintenance.id}/edit`}> 71 + <DropdownMenuItem>Edit</DropdownMenuItem> 72 + </Link> 73 + <AlertDialogTrigger asChild> 74 + <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 75 + Delete 76 + </DropdownMenuItem> 77 + </AlertDialogTrigger> 78 + </DropdownMenuContent> 79 + </DropdownMenu> 80 + <AlertDialogContent> 81 + <AlertDialogHeader> 82 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 83 + <AlertDialogDescription> 84 + This action cannot be undone. This will permanently delete the 85 + monitor. 86 + </AlertDialogDescription> 87 + </AlertDialogHeader> 88 + <AlertDialogFooter> 89 + <AlertDialogCancel>Cancel</AlertDialogCancel> 90 + <AlertDialogAction 91 + onClick={(e) => { 92 + e.preventDefault(); 93 + onDelete(); 94 + }} 95 + disabled={isPending} 96 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 97 + > 98 + {!isPending ? "Delete" : <LoadingAnimation />} 99 + </AlertDialogAction> 100 + </AlertDialogFooter> 101 + </AlertDialogContent> 102 + </AlertDialog> 103 + ); 104 + }
+81
apps/web/src/components/data-table/maintenance/data-table.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + import { 5 + flexRender, 6 + getCoreRowModel, 7 + useReactTable, 8 + } from "@tanstack/react-table"; 9 + import * as React from "react"; 10 + 11 + import { 12 + Table, 13 + TableBody, 14 + TableCell, 15 + TableHead, 16 + TableHeader, 17 + TableRow, 18 + } from "@openstatus/ui"; 19 + 20 + interface DataTableProps<TData, TValue> { 21 + columns: ColumnDef<TData, TValue>[]; 22 + data: TData[]; 23 + } 24 + 25 + export function DataTable<TData, TValue>({ 26 + columns, 27 + data, 28 + }: DataTableProps<TData, TValue>) { 29 + const table = useReactTable({ 30 + data, 31 + columns, 32 + getCoreRowModel: getCoreRowModel(), 33 + }); 34 + 35 + return ( 36 + <div className="rounded-md border"> 37 + <Table> 38 + <TableHeader className="bg-muted/50"> 39 + {table.getHeaderGroups().map((headerGroup) => ( 40 + <TableRow key={headerGroup.id} className="hover:bg-transparent"> 41 + {headerGroup.headers.map((header) => { 42 + return ( 43 + <TableHead key={header.id}> 44 + {header.isPlaceholder 45 + ? null 46 + : flexRender( 47 + header.column.columnDef.header, 48 + header.getContext(), 49 + )} 50 + </TableHead> 51 + ); 52 + })} 53 + </TableRow> 54 + ))} 55 + </TableHeader> 56 + <TableBody> 57 + {table.getRowModel().rows?.length ? ( 58 + table.getRowModel().rows.map((row) => ( 59 + <TableRow 60 + key={row.id} 61 + data-state={row.getIsSelected() && "selected"} 62 + > 63 + {row.getVisibleCells().map((cell) => ( 64 + <TableCell key={cell.id}> 65 + {flexRender(cell.column.columnDef.cell, cell.getContext())} 66 + </TableCell> 67 + ))} 68 + </TableRow> 69 + )) 70 + ) : ( 71 + <TableRow> 72 + <TableCell colSpan={columns.length} className="h-24 text-center"> 73 + No results. 74 + </TableCell> 75 + </TableRow> 76 + )} 77 + </TableBody> 78 + </Table> 79 + </div> 80 + ); 81 + }
+120
apps/web/src/components/forms/maintenance/form.tsx
··· 1 + "use client"; 2 + 3 + import { zodResolver } from "@hookform/resolvers/zod"; 4 + import { usePathname, useRouter } from "next/navigation"; 5 + import * as React from "react"; 6 + import { useTransition } from "react"; 7 + import { useForm } from "react-hook-form"; 8 + 9 + import { insertMaintenanceSchema } from "@openstatus/db/src/schema"; 10 + import type { InsertMaintenance, Monitor } from "@openstatus/db/src/schema"; 11 + import { Form } from "@openstatus/ui"; 12 + 13 + import { toastAction } from "@/lib/toast"; 14 + import { api } from "@/trpc/client"; 15 + 16 + import { 17 + Tabs, 18 + TabsContent, 19 + TabsList, 20 + TabsTrigger, 21 + } from "@/components/dashboard/tabs"; 22 + 23 + import { SaveButton } from "../shared/save-button"; 24 + import { General } from "./general"; 25 + import { SectionConnect } from "./section-connect"; 26 + 27 + interface Props { 28 + defaultSection?: string; 29 + defaultValues?: InsertMaintenance; 30 + monitors?: Monitor[]; 31 + nextUrl?: string; 32 + pageId: number; 33 + } 34 + 35 + export function MaintenanceForm({ 36 + defaultSection, 37 + defaultValues, 38 + monitors, 39 + nextUrl, 40 + pageId, 41 + }: Props) { 42 + const form = useForm<InsertMaintenance>({ 43 + resolver: zodResolver(insertMaintenanceSchema), 44 + defaultValues: { 45 + ...defaultValues, 46 + title: defaultValues?.title || "", 47 + message: defaultValues?.message || "", 48 + from: defaultValues?.from ? new Date(defaultValues.from) : new Date(), 49 + to: defaultValues?.to 50 + ? new Date(defaultValues.to) 51 + : new Date(Date.now() + 1000 * 60 * 60), 52 + pageId, 53 + }, 54 + }); 55 + const router = useRouter(); 56 + const pathname = usePathname(); 57 + const [isPending, startTransition] = useTransition(); 58 + 59 + const onSubmit = async ({ ...props }: InsertMaintenance) => { 60 + startTransition(async () => { 61 + try { 62 + if (defaultValues) { 63 + await api.maintenance.update.mutate(props); 64 + } else { 65 + await api.maintenance.create.mutate(props); 66 + } 67 + toastAction("saved"); 68 + // otherwise, the form will stay dirty - keepValues is used to keep the current values in the form 69 + form.reset({}, { keepValues: true }); 70 + if (nextUrl) { 71 + router.push(nextUrl); 72 + } 73 + router.refresh(); 74 + } catch { 75 + toastAction("error"); 76 + } 77 + }); 78 + }; 79 + 80 + function onValueChange(value: string) { 81 + // REMINDER: we are not merging the searchParams here 82 + // we are just setting the section to allow refreshing the page 83 + const params = new URLSearchParams(); 84 + params.set("section", value); 85 + router.push(`${pathname}?${params.toString()}`); 86 + } 87 + 88 + console.log(form.formState.errors); 89 + 90 + return ( 91 + <Form {...form}> 92 + <form 93 + onSubmit={async (e) => { 94 + e.preventDefault(); 95 + form.handleSubmit(onSubmit)(e); 96 + }} 97 + className="grid w-full gap-6" 98 + > 99 + <General form={form} /> 100 + <Tabs 101 + defaultValue={defaultSection} 102 + className="w-full" 103 + onValueChange={onValueChange} 104 + > 105 + <TabsList> 106 + <TabsTrigger value="connect">Connect</TabsTrigger> 107 + </TabsList> 108 + <TabsContent value="connect"> 109 + <SectionConnect form={form} monitors={monitors} /> 110 + </TabsContent> 111 + </Tabs> 112 + <SaveButton 113 + isPending={isPending} 114 + isDirty={form.formState.isDirty} 115 + onSubmit={form.handleSubmit(onSubmit)} 116 + /> 117 + </form> 118 + </Form> 119 + ); 120 + }
+110
apps/web/src/components/forms/maintenance/general.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertMaintenance } from "@openstatus/db/src/schema"; 7 + import { 8 + DateTimePickerPopover, 9 + FormControl, 10 + FormDescription, 11 + FormField, 12 + FormItem, 13 + FormLabel, 14 + FormMessage, 15 + Input, 16 + Textarea, 17 + } from "@openstatus/ui"; 18 + 19 + import { SectionHeader } from "../shared/section-header"; 20 + 21 + interface Props { 22 + form: UseFormReturn<InsertMaintenance>; 23 + } 24 + 25 + export function General({ form }: Props) { 26 + return ( 27 + <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 28 + <SectionHeader 29 + title="Maintenance Information" 30 + description="Give your users a heads up when you're doing maintenance." 31 + /> 32 + <div className="flex w-full flex-col gap-4 sm:col-span-2"> 33 + <FormField 34 + control={form.control} 35 + name="title" 36 + render={({ field }) => ( 37 + <FormItem className="w-full"> 38 + <FormLabel>Title</FormLabel> 39 + <FormControl> 40 + <Input placeholder="Database migration" {...field} /> 41 + </FormControl> 42 + <FormDescription>Displayed on the status page.</FormDescription> 43 + <FormMessage /> 44 + </FormItem> 45 + )} 46 + /> 47 + <FormField 48 + control={form.control} 49 + name="message" 50 + render={({ field }) => ( 51 + <FormItem className="col-span-full"> 52 + <FormLabel>Message</FormLabel> 53 + <FormControl> 54 + <Textarea 55 + placeholder="We're doing some maintenance. We'll be back soon!" 56 + {...field} 57 + /> 58 + </FormControl> 59 + <FormDescription>Give your users some context.</FormDescription> 60 + <FormMessage /> 61 + </FormItem> 62 + )} 63 + /> 64 + <div className="grid grid-cols-2 gap-4"> 65 + <FormField 66 + control={form.control} 67 + name="from" 68 + render={({ field }) => ( 69 + <FormItem className="flex w-full flex-col"> 70 + <FormLabel>From</FormLabel> 71 + <DateTimePickerPopover 72 + date={field.value ? new Date(field.value) : new Date()} 73 + setDate={(date) => { 74 + field.onChange(date); 75 + }} 76 + className="w-full" 77 + /> 78 + <FormMessage /> 79 + </FormItem> 80 + )} 81 + /> 82 + <FormField 83 + control={form.control} 84 + name="to" 85 + render={({ field }) => ( 86 + <FormItem className="flex w-full flex-col"> 87 + <FormLabel>To</FormLabel> 88 + <DateTimePickerPopover 89 + date={ 90 + field.value 91 + ? new Date(field.value) 92 + : new Date(Date.now() + 1000 * 60 * 60) 93 + } 94 + setDate={(date) => { 95 + field.onChange(date); 96 + }} 97 + className="w-full" 98 + /> 99 + <FormMessage /> 100 + </FormItem> 101 + )} 102 + /> 103 + <FormDescription className="sm:-mt-2 col-span-full"> 104 + The date and time when the incident took place. 105 + </FormDescription> 106 + </div> 107 + </div> 108 + </div> 109 + ); 110 + }
+90
apps/web/src/components/forms/maintenance/section-connect.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertMaintenance, Monitor } from "@openstatus/db/src/schema"; 7 + import { 8 + Alert, 9 + AlertDescription, 10 + AlertTitle, 11 + FormControl, 12 + FormDescription, 13 + FormField, 14 + FormItem, 15 + FormLabel, 16 + FormMessage, 17 + } from "@openstatus/ui"; 18 + 19 + import { CheckboxLabel } from "../shared/checkbox-label"; 20 + 21 + interface Props { 22 + form: UseFormReturn<InsertMaintenance>; 23 + monitors?: Monitor[]; 24 + } 25 + 26 + export function SectionConnect({ form, monitors }: Props) { 27 + return ( 28 + <div className="grid w-full gap-4"> 29 + <div className="flex flex-col gap-3"> 30 + <FormField 31 + control={form.control} 32 + name="monitors" 33 + render={() => ( 34 + <FormItem> 35 + <div className="mb-4"> 36 + <FormLabel>Monitors</FormLabel> 37 + <FormDescription> 38 + Select the monitors that are affected by this maintenance.{" "} 39 + <span className="font-medium"> 40 + We will not trigger any ping during that period. 41 + </span> 42 + </FormDescription> 43 + </div> 44 + <div className="grid grid-cols-1 grid-rows-1 gap-6 md:grid-cols-3 sm:grid-cols-2"> 45 + {monitors?.map((item) => ( 46 + <FormField 47 + key={item.id} 48 + control={form.control} 49 + name="monitors" 50 + render={({ field }) => { 51 + return ( 52 + <FormItem key={item.id} className="h-full w-full"> 53 + <FormControl className="w-full"> 54 + <CheckboxLabel 55 + id={String(item.id)} 56 + name="monitor" 57 + checked={field.value?.includes(item.id)} 58 + onCheckedChange={(checked) => { 59 + return checked 60 + ? field.onChange([ 61 + ...(field.value || []), 62 + item.id, 63 + ]) 64 + : field.onChange( 65 + field.value?.filter( 66 + (value) => value !== item.id 67 + ) 68 + ); 69 + }} 70 + > 71 + {item.name} 72 + </CheckboxLabel> 73 + </FormControl> 74 + </FormItem> 75 + ); 76 + }} 77 + /> 78 + ))} 79 + </div> 80 + {!monitors || monitors.length === 0 ? ( 81 + <FormDescription>Missing monitors.</FormDescription> 82 + ) : null} 83 + <FormMessage /> 84 + </FormItem> 85 + )} 86 + /> 87 + </div> 88 + </div> 89 + ); 90 + }
+2
apps/web/src/components/icons.tsx
··· 16 16 FileClock, 17 17 Fingerprint, 18 18 Globe2, 19 + Hammer, 19 20 Hourglass, 20 21 Image, 21 22 KeyRound, ··· 66 67 table: Table, 67 68 "toy-brick": ToyBrick, 68 69 cog: Cog, 70 + hammer: Hammer, 69 71 search: Search, 70 72 "search-check": SearchCheck, 71 73 fingerprint: Fingerprint,
+14 -3
apps/web/src/components/layout/app-sidebar.tsx
··· 1 1 "use client"; 2 2 3 - import { useSelectedLayoutSegment } from "next/navigation"; 3 + import { useParams, useSelectedLayoutSegment } from "next/navigation"; 4 4 5 5 import type { Page } from "@/config/pages"; 6 6 import { ProBanner } from "../billing/pro-banner"; 7 7 import { AppLink } from "./app-link"; 8 8 9 + function replacePlaceholders( 10 + template: string, 11 + values: { [key: string]: string } 12 + ): string { 13 + return template.replace(/\[([^\]]+)\]/g, (_, key) => { 14 + return values[key] || `[${key}]`; 15 + }); 16 + } 17 + 9 18 export function AppSidebar({ page }: { page?: Page }) { 19 + const params = useParams<Record<string, string>>(); 10 20 const selectedSegment = useSelectedLayoutSegment(); 11 21 12 22 if (!page) return null; ··· 18 28 {page?.title} 19 29 </p> 20 30 <ul className="grid gap-2"> 21 - {page?.children?.map(({ title, segment, icon, disabled }) => { 31 + {page?.children?.map(({ title, segment, icon, disabled, href }) => { 32 + const prefix = `/app/${params.workspaceSlug}`; 22 33 return ( 23 34 <li key={title} className="w-full"> 24 35 <AppLink 25 36 label={title} 26 - href={`./${segment}`} 37 + href={`${prefix}${replacePlaceholders(href, params)}`} 27 38 disabled={disabled} 28 39 active={segment === selectedSegment} 29 40 icon={icon}
+21
apps/web/src/components/status-page/maintenance-banner.tsx
··· 1 + import { isSameDay, format } from "date-fns"; 2 + import { Hammer } from "lucide-react"; 3 + 4 + import type { Maintenance } from "@openstatus/db/src/schema"; 5 + import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 6 + 7 + export function MaintenanceBanner(props: Maintenance) { 8 + return ( 9 + <Alert className="border-blue-500/20 bg-blue-500/10"> 10 + <Hammer className="h-4 w-4" /> 11 + <AlertTitle>{props.title}</AlertTitle> 12 + <AlertDescription>{props.message}</AlertDescription> 13 + <AlertDescription className="mt-2 font-mono"> 14 + {format(props.from, "LLL dd, y HH:mm")} -{" "} 15 + {isSameDay(props.from, props.to) 16 + ? format(props.to, "HH:mm") 17 + : format(props.to, "LLL dd, y HH:mm")} 18 + </AlertDescription> 19 + </Alert> 20 + ); 21 + }
+62
apps/web/src/components/status-page/maintenance-list.tsx
··· 1 + import { notEmpty } from "@/lib/utils"; 2 + import type { Maintenance, PublicMonitor } from "@openstatus/db/src/schema"; 3 + import { Badge } from "@openstatus/ui"; 4 + import { format, isSameDay } from "date-fns"; 5 + 6 + export function MaintenanceList({ 7 + maintenances, 8 + monitors, 9 + }: { 10 + maintenances: Maintenance[]; 11 + monitors: PublicMonitor[]; 12 + }) { 13 + if (!maintenances.length) { 14 + return <EmptyState />; 15 + } 16 + 17 + return ( 18 + <ul className="grid gap-6"> 19 + {maintenances.map((maintenance) => { 20 + const maintenanceMonitors = maintenance.monitors 21 + ?.map((id) => { 22 + return monitors.find((monitor) => monitor.id === id); 23 + }) 24 + .filter(notEmpty); 25 + return ( 26 + <li key={maintenance.id} className="grid gap-3"> 27 + <div> 28 + <h3 className="font-semibold text-xl">{maintenance.title}</h3> 29 + <div className="flex flex-wrap items-center gap-2"> 30 + <span className="font-mono text-muted-foreground text-sm"> 31 + {format(maintenance.from, "LLL dd, y HH:mm")} -{" "} 32 + {isSameDay(maintenance.from, maintenance.to) 33 + ? format(maintenance.to, "HH:mm") 34 + : format(maintenance.to, "LLL dd, y HH:mm")} 35 + </span> 36 + {maintenanceMonitors?.length ? ( 37 + <> 38 + <span className="text-muted-foreground/50 text-xs">•</span> 39 + {maintenanceMonitors.map((monitor) => ( 40 + <Badge key={monitor.id} variant="secondary"> 41 + {monitor.name} 42 + </Badge> 43 + ))} 44 + </> 45 + ) : null} 46 + </div> 47 + </div> 48 + <p>{maintenance.message}</p> 49 + </li> 50 + ); 51 + })} 52 + </ul> 53 + ); 54 + } 55 + 56 + function EmptyState() { 57 + return ( 58 + <p className="text-center font-light text-muted-foreground text-sm"> 59 + No maintenances reported. 60 + </p> 61 + ); 62 + }
+11 -3
apps/web/src/components/status-page/monitor-list.tsx
··· 2 2 3 3 import type { 4 4 Incident, 5 + Maintenance, 5 6 PublicMonitor, 6 7 selectPublicStatusReportSchemaWithRelation, 7 8 } from "@openstatus/db/src/schema"; ··· 12 13 monitors, 13 14 statusReports, 14 15 incidents, 16 + maintenances, 15 17 }: { 16 18 monitors: PublicMonitor[]; 17 19 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 18 20 incidents: Incident[]; 21 + maintenances: Maintenance[]; 19 22 }) => { 23 + console.log({ maintenances }); 20 24 return ( 21 25 <div className="grid gap-4"> 22 26 {monitors.map((monitor, _index) => { 23 27 const monitorStatusReport = statusReports.filter((statusReport) => 24 28 statusReport.monitorsToStatusReports.some( 25 - (i) => i.monitor.id === monitor.id, 26 - ), 29 + (i) => i.monitor.id === monitor.id 30 + ) 27 31 ); 28 32 const monitorIncidents = incidents.filter( 29 - (incident) => incident.monitorId === monitor.id, 33 + (incident) => incident.monitorId === monitor.id 34 + ); 35 + const monitorMaintenances = maintenances.filter((maintenance) => 36 + maintenance.monitors?.includes(monitor.id) 30 37 ); 31 38 return ( 32 39 <Monitor ··· 34 41 monitor={monitor} 35 42 statusReports={monitorStatusReport} 36 43 incidents={monitorIncidents} 44 + maintenances={monitorMaintenances} 37 45 /> 38 46 ); 39 47 })}
+4
apps/web/src/components/status-page/monitor.tsx
··· 2 2 3 3 import type { 4 4 Incident, 5 + Maintenance, 5 6 PublicMonitor, 6 7 selectPublicStatusReportSchemaWithRelation, 7 8 } from "@openstatus/db/src/schema"; ··· 16 17 monitor, 17 18 statusReports, 18 19 incidents, 20 + maintenances, 19 21 }: { 20 22 monitor: PublicMonitor; 21 23 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 22 24 incidents: Incident[]; 25 + maintenances: Maintenance[]; 23 26 }) => { 24 27 const data = await tb.endpointStatusPeriod("45d")({ 25 28 monitorId: String(monitor.id), ··· 34 37 data={data} 35 38 reports={statusReports} 36 39 incidents={incidents} 40 + maintenances={maintenances} 37 41 {...monitor} 38 42 /> 39 43 );
+11 -2
apps/web/src/components/status-page/status-check.tsx
··· 1 - import type { Incident, StatusReport } from "@openstatus/db/src/schema"; 1 + import type { 2 + Incident, 3 + Maintenance, 4 + StatusReport, 5 + } from "@openstatus/db/src/schema"; 2 6 import type { StatusVariant } from "@openstatus/tracker"; 3 7 import { Tracker } from "@openstatus/tracker"; 4 8 ··· 9 13 export async function StatusCheck({ 10 14 statusReports, 11 15 incidents, 16 + maintenances, 12 17 }: { 13 18 statusReports: StatusReport[]; 14 19 incidents: Incident[]; 20 + maintenances: Maintenance[]; 15 21 }) { 16 - const tracker = new Tracker({ statusReports, incidents }); 22 + const tracker = new Tracker({ statusReports, incidents, maintenances }); 17 23 const className = tracker.currentClassName; 18 24 const details = tracker.currentDetails; 19 25 ··· 39 45 if (variant === "incident") { 40 46 const AlertTriangleIcon = Icons["alert-triangle"]; 41 47 return <AlertTriangleIcon className="h-5 w-5 text-background" />; 48 + } 49 + if (variant === "maintenance") { 50 + return <Icons.hammer className="h-5 w-5 text-background" />; 42 51 } 43 52 if (variant === "degraded") { 44 53 return <Icons.minus className="h-5 w-5 text-background" />;
+21 -23
apps/web/src/components/status-page/status-report-list.tsx
··· 34 34 return b.updatedAt.getTime() - a.updatedAt.getTime(); 35 35 }); 36 36 37 + if (!reports.length) { 38 + return <EmptyState />; 39 + } 40 + 37 41 return ( 38 - <> 39 - {reports?.length > 0 ? ( 40 - <div className="grid gap-8"> 41 - {reports.map((report, i) => { 42 - const affectedMonitors = report.monitorsToStatusReports 43 - .map(({ monitorId }) => { 44 - const monitor = monitors.find(({ id }) => monitorId === id); 45 - return monitor || undefined; 46 - }) 47 - .filter(notEmpty); 48 - const isLast = reports.length - 1 === i; 42 + <div className="grid gap-8"> 43 + {reports.map((report, i) => { 44 + const affectedMonitors = report.monitorsToStatusReports 45 + .map(({ monitorId }) => { 46 + const monitor = monitors.find(({ id }) => monitorId === id); 47 + return monitor || undefined; 48 + }) 49 + .filter(notEmpty); 50 + const isLast = reports.length - 1 === i; 49 51 50 - return ( 51 - <div key={report.id} className="grid gap-6"> 52 - <StatusReport monitors={affectedMonitors} report={report} /> 53 - {!isLast ? <Separator /> : null} 54 - </div> 55 - ); 56 - })} 57 - </div> 58 - ) : ( 59 - <EmptyState /> 60 - )} 61 - </> 52 + return ( 53 + <div key={report.id} className="grid gap-6"> 54 + <StatusReport monitors={affectedMonitors} report={report} /> 55 + {!isLast ? <Separator /> : null} 56 + </div> 57 + ); 58 + })} 59 + </div> 62 60 ); 63 61 }; 64 62
+5 -2
apps/web/src/components/status-page/status-report.tsx
··· 15 15 import { setPrefixUrl } from "@/app/status-page/[domain]/utils"; 16 16 import { StatusBadge } from "../status-update/status-badge"; 17 17 import { ProcessMessage } from "./process-message"; 18 + import { cn } from "@/lib/utils"; 18 19 19 20 function StatusReport({ 20 21 report, ··· 38 39 const params = useParams<{ domain: string }>(); 39 40 return ( 40 41 <div className="flex items-center gap-2"> 41 - <h3 className="font-semibold text-2xl">{report.title}</h3> 42 + <h3 className="font-semibold text-xl">{report.title}</h3> 42 43 <Button 43 44 variant="ghost" 44 45 size="icon" ··· 56 57 function StatusReportDescription({ 57 58 report, 58 59 monitors, 60 + className, 59 61 }: { 60 62 report: StatusReportWithUpdates; 61 63 monitors: PublicMonitor[]; 64 + className?: string; 62 65 }) { 63 66 const firstReport = 64 67 report.statusReportUpdates[report.statusReportUpdates.length - 1]; 65 68 return ( 66 - <div className="flex flex-wrap items-center gap-2"> 69 + <div className={cn("flex flex-wrap items-center gap-2", className)}> 67 70 <p className="text-muted-foreground"> 68 71 {format(firstReport.date || new Date(), "LLL dd, y HH:mm")} 69 72 </p>
+11 -3
apps/web/src/components/tracker/tracker.tsx
··· 8 8 9 9 import type { 10 10 Incident, 11 + Maintenance, 11 12 StatusReport, 12 13 StatusReportUpdate, 13 14 } from "@openstatus/db/src/schema"; ··· 50 51 description?: string; 51 52 reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 52 53 incidents?: Incident[]; 54 + maintenances?: Maintenance[]; 53 55 } 54 56 55 57 export function Tracker({ ··· 58 60 description, 59 61 reports, 60 62 incidents, 63 + maintenances, 61 64 }: TrackerProps) { 62 - const tracker = new OSTracker({ data, statusReports: reports, incidents }); 65 + const tracker = new OSTracker({ 66 + data, 67 + statusReports: reports, 68 + incidents, 69 + maintenances, 70 + }); 63 71 const uptime = tracker.totalUptime; 64 72 const isMissing = tracker.isDataMissing; 65 73 ··· 142 150 <div 143 151 className={cn( 144 152 rootClassName, 145 - "h-auto w-1 flex-none rounded-full", 153 + "h-auto w-1 flex-none rounded-full" 146 154 )} 147 155 /> 148 156 <div className="grid flex-1 gap-1"> ··· 237 245 Down for{" "} 238 246 {formatDuration( 239 247 { minutes, hours, days }, 240 - { format: ["days", "hours", "minutes", "seconds"], zero: false }, 248 + { format: ["days", "hours", "minutes", "seconds"], zero: false } 241 249 )} 242 250 </p> 243 251 );
+8 -1
apps/web/src/config/pages.ts
··· 101 101 icon: "users", 102 102 segment: "subscribers", 103 103 }, 104 + { 105 + title: "Maintenance", 106 + description: "Where you can see all the maintenance.", 107 + href: "/status-pages/[id]/maintenances", 108 + icon: "hammer", 109 + segment: "maintenances", 110 + }, 104 111 ]; 105 112 106 113 const incidentPagesConfig: Page[] = [ ··· 240 247 241 248 export function getPageBySegment( 242 249 segment: string | string[], 243 - currentPage: readonly Page[] = pagesConfig, 250 + currentPage: readonly Page[] = pagesConfig 244 251 ): Page | undefined { 245 252 if (typeof segment === "string") { 246 253 const page = currentPage.find((page) => page.segment === segment);
+12
apps/web/src/content/changelog/maintenance-status.mdx
··· 1 + --- 2 + title: Maintenance status 3 + description: Show your users that you're performing maintenance. 4 + image: /assets/changelog/maintenance-status.png 5 + publishedAt: 2024-06-09 6 + --- 7 + 8 + You can now set your status to _Under Maintenance_ to inform your users that you're performing maintenance. You'll be able to set the start and end times for the maintenance period. During this time, we will pause the connected monitors and suppress any notifications. 9 + 10 + To create a maintenance, go to the corresponding status page and click the 'Maintenance' sub navigation. From there, you can create a new maintenance status, including `title` and `description`. 11 + 12 + The users will be able to see all maintenances under a new tab on the status page.
+13 -1
apps/web/src/lib/timezone.ts
··· 38 38 return format(new Date(), "LLL dd, y HH:mm:ss zzz", { timeZone: "UTC" }); 39 39 } 40 40 41 + export function formatDate(date: Date) { 42 + return format(date, "LLL dd, y", { timeZone: "UTC" }); 43 + } 44 + 45 + export function formatDateTime(date: Date) { 46 + return format(date, "LLL dd, y HH:mm:ss zzz", { timeZone: "UTC" }); 47 + } 48 + 49 + export function formatTime(date: Date) { 50 + return format(date, "HH:mm:ss zzz", { timeZone: "UTC" }); 51 + } 52 + 41 53 /** 42 54 * All supported browser / node timezones 43 55 */ ··· 82 94 } 83 95 return prev; 84 96 }, 85 - { timezone: "UTC", minDifference: Number.POSITIVE_INFINITY }, 97 + { timezone: "UTC", minDifference: Number.POSITIVE_INFINITY } 86 98 ); 87 99 88 100 return closestTimezone.timezone as keyof typeof timeDifferences;
+6 -2
apps/web/src/lib/utils.ts
··· 19 19 return format(date, "LLL dd, y HH:mm:ss"); 20 20 } 21 21 22 + export function formatTime(date: Date) { 23 + return format(date, "HH:mm:ss"); 24 + } 25 + 22 26 export function formatDuration(ms: number) { 23 27 let v = ms; 24 28 if (ms < 0) v = -ms; ··· 36 40 } 37 41 38 42 export function notEmpty<TValue>( 39 - value: TValue | null | undefined, 43 + value: TValue | null | undefined 40 44 ): value is TValue { 41 45 return value !== null && value !== undefined; 42 46 } ··· 65 69 date?: { 66 70 from: Date | undefined; 67 71 to?: Date | undefined; 68 - } | null, 72 + } | null 69 73 ) { 70 74 const isToDateMidnight = String(date?.to?.getTime()).endsWith("00000"); 71 75
+2
packages/api/src/edge.ts
··· 2 2 import { incidentRouter } from "./router/incident"; 3 3 import { integrationRouter } from "./router/integration"; 4 4 import { invitationRouter } from "./router/invitation"; 5 + import { maintenanceRouter } from "./router/maintenance"; 5 6 import { monitorRouter } from "./router/monitor"; 6 7 import { monitorTagRouter } from "./router/monitorTag"; 7 8 import { notificationRouter } from "./router/notification"; ··· 28 29 pageSubscriber: pageSubscriberRouter, 29 30 tinybird: tinybirdRouter, 30 31 monitorTag: monitorTagRouter, 32 + maintenance: maintenanceRouter, 31 33 });
+163
packages/api/src/router/maintenance.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { and, eq, inArray } from "@openstatus/db"; 4 + import { 5 + insertMaintenanceSchema, 6 + maintenance, 7 + maintenancesToMonitors, 8 + selectMaintenanceSchema, 9 + } from "@openstatus/db/src/schema"; 10 + 11 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 12 + import { TRPCError } from "@trpc/server"; 13 + 14 + export const maintenanceRouter = createTRPCRouter({ 15 + create: protectedProcedure 16 + .input(insertMaintenanceSchema) 17 + .mutation(async (opts) => { 18 + const _maintenance = await opts.ctx.db 19 + .insert(maintenance) 20 + .values({ ...opts.input, workspaceId: opts.ctx.workspace.id }) 21 + .returning() 22 + .get(); 23 + 24 + if (opts.input.monitors?.length) { 25 + await opts.ctx.db 26 + .insert(maintenancesToMonitors) 27 + .values( 28 + opts.input.monitors.map((monitorId) => ({ 29 + maintenanceId: _maintenance.id, 30 + monitorId, 31 + })) 32 + ) 33 + .returning() 34 + .get(); 35 + } 36 + 37 + return _maintenance; 38 + }), 39 + getById: protectedProcedure 40 + .input(z.object({ id: z.number() })) 41 + .query(async (opts) => { 42 + const _maintenance = await opts.ctx.db 43 + .select() 44 + .from(maintenance) 45 + .where( 46 + and( 47 + eq(maintenance.id, opts.input.id), 48 + eq(maintenance.workspaceId, opts.ctx.workspace.id) 49 + ) 50 + ) 51 + .get(); 52 + 53 + if (!_maintenance) return undefined; 54 + // TODO: make it work with `with: { maintenacesToMonitors: true }` 55 + const _monitors = await opts.ctx.db 56 + .select() 57 + .from(maintenancesToMonitors) 58 + .where(eq(maintenancesToMonitors.maintenanceId, _maintenance.id)) 59 + .all(); 60 + return { ..._maintenance, monitors: _monitors.map((m) => m.monitorId) }; 61 + }), 62 + getByWorkspace: protectedProcedure.query(async (opts) => { 63 + const _maintenances = await opts.ctx.db 64 + .select() 65 + .from(maintenance) 66 + .where(eq(maintenance.workspaceId, opts.ctx.workspace.id)) 67 + .all(); 68 + return _maintenances; 69 + }), 70 + getByPage: protectedProcedure 71 + .input(z.object({ id: z.number() })) 72 + .query(async (opts) => { 73 + const _maintenances = await opts.ctx.db 74 + .select() 75 + .from(maintenance) 76 + .where( 77 + and( 78 + eq(maintenance.pageId, opts.input.id), 79 + eq(maintenance.workspaceId, opts.ctx.workspace.id) 80 + ) 81 + ) 82 + .all(); 83 + // TODO: 84 + return _maintenances; 85 + }), 86 + update: protectedProcedure 87 + .input(insertMaintenanceSchema) 88 + .mutation(async (opts) => { 89 + if (!opts.input.id) { 90 + throw new TRPCError({ code: "BAD_REQUEST", message: "id is required" }); 91 + } 92 + 93 + const _maintenance = await opts.ctx.db 94 + .update(maintenance) 95 + .set({ ...opts.input, workspaceId: opts.ctx.workspace.id }) 96 + .where( 97 + and( 98 + eq(maintenance.id, opts.input.id), 99 + eq(maintenance.workspaceId, opts.ctx.workspace.id) 100 + ) 101 + ) 102 + .returning() 103 + .get(); 104 + 105 + const _maintenancesToMonitors = await opts.ctx.db 106 + .select() 107 + .from(maintenancesToMonitors) 108 + .where(eq(maintenancesToMonitors.maintenanceId, _maintenance.id)) 109 + .all(); 110 + 111 + const _monitorsIds = _maintenancesToMonitors.map( 112 + ({ monitorId }) => monitorId 113 + ); 114 + 115 + const added = opts.input.monitors?.filter( 116 + (monitor) => !_monitorsIds.includes(monitor) 117 + ); 118 + 119 + if (added?.length) { 120 + await opts.ctx.db 121 + .insert(maintenancesToMonitors) 122 + .values( 123 + added.map((monitorId) => ({ 124 + maintenanceId: _maintenance.id, 125 + monitorId, 126 + })) 127 + ) 128 + .returning() 129 + .get(); 130 + } 131 + 132 + const removed = _monitorsIds.filter( 133 + (monitor) => !opts.input.monitors?.includes(monitor) 134 + ); 135 + 136 + if (removed?.length) { 137 + await opts.ctx.db 138 + .delete(maintenancesToMonitors) 139 + .where( 140 + and( 141 + eq(maintenancesToMonitors.maintenanceId, _maintenance.id), 142 + inArray(maintenancesToMonitors.monitorId, removed) 143 + ) 144 + ) 145 + .run(); 146 + } 147 + 148 + return _maintenance; 149 + }), 150 + delete: protectedProcedure 151 + .input(z.object({ id: z.number() })) 152 + .mutation(async (opts) => { 153 + return await opts.ctx.db 154 + .delete(maintenance) 155 + .where( 156 + and( 157 + eq(maintenance.id, opts.input.id), 158 + eq(maintenance.workspaceId, opts.ctx.workspace.id) 159 + ) 160 + ) 161 + .returning(); 162 + }), 163 + });
+42 -1
packages/api/src/router/monitor.ts
··· 181 181 .safeParse(_monitor); 182 182 183 183 if (!parsedMonitor.success) { 184 - console.log(parsedMonitor.error); 185 184 throw new TRPCError({ 186 185 code: "UNAUTHORIZED", 187 186 message: "You are not allowed to access the monitor.", ··· 450 449 ) 451 450 .parse(monitors); 452 451 }), 452 + 453 + getMonitorsByPageId: protectedProcedure 454 + .input(z.object({ id: z.number() })) 455 + .query(async (opts) => { 456 + const _page = await opts.ctx.db.query.page.findFirst({ 457 + where: and( 458 + eq(page.id, opts.input.id), 459 + eq(page.workspaceId, opts.ctx.workspace.id) 460 + ), 461 + }); 462 + 463 + if (!_page) return undefined; 464 + 465 + const monitors = await opts.ctx.db.query.monitor.findMany({ 466 + where: and( 467 + eq(monitor.workspaceId, opts.ctx.workspace.id), 468 + isNull(monitor.deletedAt) 469 + ), 470 + with: { 471 + monitorTagsToMonitors: { with: { monitorTag: true } }, 472 + monitorsToPages: { 473 + where: eq(monitorsToPages.pageId, _page.id), 474 + }, 475 + }, 476 + }); 477 + 478 + return z 479 + .array( 480 + selectMonitorSchema.extend({ 481 + monitorTagsToMonitors: z 482 + .array(z.object({ monitorTag: selectMonitorTagSchema })) 483 + .default([]), 484 + }) 485 + ) 486 + .parse( 487 + monitors.filter((monitor) => 488 + monitor.monitorsToPages 489 + .map(({ pageId }) => pageId) 490 + .includes(_page.id) 491 + ) 492 + ); 493 + }), 453 494 454 495 toggleMonitorActive: protectedProcedure 455 496 .input(z.object({ id: z.number() }))
+31 -20
packages/api/src/router/page.ts
··· 5 5 import { 6 6 incidentTable, 7 7 insertPageSchema, 8 + maintenance, 8 9 monitor, 9 10 monitorsToPages, 10 11 monitorsToStatusReport, ··· 65 66 where: and( 66 67 inArray(monitor.id, monitorIds), 67 68 eq(monitor.workspaceId, opts.ctx.workspace.id), 68 - isNull(monitor.deletedAt), 69 + isNull(monitor.deletedAt) 69 70 ), 70 71 }); 71 72 ··· 95 96 const firstPage = await opts.ctx.db.query.page.findFirst({ 96 97 where: and( 97 98 eq(page.id, opts.input.id), 98 - eq(page.workspaceId, opts.ctx.workspace.id), 99 + eq(page.workspaceId, opts.ctx.workspace.id) 99 100 ), 100 101 with: { 101 102 monitorsToPages: { ··· 132 133 .where( 133 134 and( 134 135 eq(page.id, pageInput.id), 135 - eq(page.workspaceId, opts.ctx.workspace.id), 136 - ), 136 + eq(page.workspaceId, opts.ctx.workspace.id) 137 + ) 137 138 ) 138 139 .returning() 139 140 .get(); ··· 144 145 where: and( 145 146 inArray(monitor.id, monitorIds), 146 147 eq(monitor.workspaceId, opts.ctx.workspace.id), 147 - isNull(monitor.deletedAt), 148 + isNull(monitor.deletedAt) 148 149 ), 149 150 }); 150 151 ··· 173 174 .where( 174 175 and( 175 176 inArray(monitorsToPages.monitorId, removedMonitors), 176 - eq(monitorsToPages.pageId, currentPage.id), 177 - ), 177 + eq(monitorsToPages.pageId, currentPage.id) 178 + ) 178 179 ); 179 180 } 180 181 ··· 202 203 .where( 203 204 and( 204 205 eq(page.id, opts.input.id), 205 - eq(page.workspaceId, opts.ctx.workspace.id), 206 - ), 206 + eq(page.workspaceId, opts.ctx.workspace.id) 207 + ) 207 208 ) 208 209 .run(); 209 210 }), ··· 245 246 .leftJoin(monitor, eq(monitorsToPages.monitorId, monitor.id)) 246 247 .where( 247 248 // make sur only active monitors are returned! 248 - and(eq(monitorsToPages.pageId, result.id), eq(monitor.active, true)), 249 + and(eq(monitorsToPages.pageId, result.id), eq(monitor.active, true)) 249 250 ) 250 251 .all(); 251 252 252 253 const monitorsId = monitorsToPagesResult.map( 253 - ({ monitors_to_pages }) => monitors_to_pages.monitorId, 254 + ({ monitors_to_pages }) => monitors_to_pages.monitorId 254 255 ); 255 256 256 257 const monitorsToStatusReportResult = ··· 269 270 .all(); 270 271 271 272 const monitorStatusReportIds = monitorsToStatusReportResult.map( 272 - ({ statusReportId }) => statusReportId, 273 + ({ statusReportId }) => statusReportId 273 274 ); 274 275 275 276 const pageStatusReportIds = statusReportsToPagesResult.map( 276 - ({ statusReportId }) => statusReportId, 277 + ({ statusReportId }) => statusReportId 277 278 ); 278 279 279 280 const statusReportIds = Array.from( 280 - new Set([...monitorStatusReportIds, ...pageStatusReportIds]), 281 + new Set([...monitorStatusReportIds, ...pageStatusReportIds]) 281 282 ); 282 283 283 284 const statusReports = ··· 304 305 and( 305 306 inArray(monitor.id, monitorsId), 306 307 eq(monitor.active, true), 307 - isNull(monitor.deletedAt), 308 - ), // REMINDER: this is hardcoded 308 + isNull(monitor.deletedAt) 309 + ) // REMINDER: this is hardcoded 309 310 ) 310 311 .all() 311 312 : []; ··· 318 319 .where( 319 320 inArray( 320 321 incidentTable.monitorId, 321 - monitors.map((m) => m.id), 322 - ), 322 + monitors.map((m) => m.id) 323 + ) 323 324 ) 324 325 .all() 325 326 : []; 326 327 328 + const maintenances = await opts.ctx.db.query.maintenance.findMany({ 329 + where: eq(maintenance.pageId, result.id), 330 + with: { maintenancesToMonitors: true }, 331 + orderBy: (maintenances, { desc }) => desc(maintenances.from), 332 + }); 333 + 327 334 return selectPublicPageSchemaWithRelation.parse({ 328 335 ...result, 329 336 // TODO: improve performance and move into SQLite query ··· 338 345 }), 339 346 incidents, 340 347 statusReports, 348 + maintenances: maintenances.map((m) => ({ 349 + ...m, 350 + monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 351 + })), 341 352 workspacePlan: workspaceResult?.plan, 342 353 }); 343 354 }), ··· 348 359 // had filter on some words we want to keep for us 349 360 if ( 350 361 ["api", "app", "www", "docs", "checker", "time", "help"].includes( 351 - opts.input.slug, 362 + opts.input.slug 352 363 ) 353 364 ) { 354 365 return false; ··· 361 372 362 373 addCustomDomain: protectedProcedure 363 374 .input( 364 - z.object({ customDomain: z.string().toLowerCase(), pageId: z.number() }), 375 + z.object({ customDomain: z.string().toLowerCase(), pageId: z.number() }) 365 376 ) 366 377 .mutation(async (opts) => { 367 378 // TODO Add some check ?
+22
packages/db/drizzle/0031_lowly_gabe_jones.sql
··· 1 + CREATE TABLE `maintenance` ( 2 + `id` integer PRIMARY KEY NOT NULL, 3 + `title` text(256) NOT NULL, 4 + `message` text NOT NULL, 5 + `from` integer NOT NULL, 6 + `to` integer NOT NULL, 7 + `workspace_id` integer, 8 + `page_id` integer, 9 + `created_at` integer DEFAULT (strftime('%s', 'now')), 10 + `updated_at` integer DEFAULT (strftime('%s', 'now')), 11 + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, 12 + FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE no action 13 + ); 14 + --> statement-breakpoint 15 + CREATE TABLE `maintenance_to_monitor` ( 16 + `monitor_id` integer NOT NULL, 17 + `maintenance_id` integer NOT NULL, 18 + `created_at` integer DEFAULT (strftime('%s', 'now')), 19 + PRIMARY KEY(`maintenance_id`, `monitor_id`), 20 + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, 21 + FOREIGN KEY (`maintenance_id`) REFERENCES `maintenance`(`id`) ON UPDATE no action ON DELETE cascade 22 + );
+2125
packages/db/drizzle/meta/0031_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "afc5bf87-9bfd-4753-af9f-edbf7b3351ba", 5 + "prevId": "baadea64-aa8c-4b6f-b0e5-55ca15fc05b0", 6 + "tables": { 7 + "status_report_to_monitors": { 8 + "name": "status_report_to_monitors", 9 + "columns": { 10 + "monitor_id": { 11 + "name": "monitor_id", 12 + "type": "integer", 13 + "primaryKey": false, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status_report_id": { 18 + "name": "status_report_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false, 30 + "default": "(strftime('%s', 'now'))" 31 + } 32 + }, 33 + "indexes": {}, 34 + "foreignKeys": { 35 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 36 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 37 + "tableFrom": "status_report_to_monitors", 38 + "tableTo": "monitor", 39 + "columnsFrom": [ 40 + "monitor_id" 41 + ], 42 + "columnsTo": [ 43 + "id" 44 + ], 45 + "onDelete": "cascade", 46 + "onUpdate": "no action" 47 + }, 48 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 49 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 50 + "tableFrom": "status_report_to_monitors", 51 + "tableTo": "status_report", 52 + "columnsFrom": [ 53 + "status_report_id" 54 + ], 55 + "columnsTo": [ 56 + "id" 57 + ], 58 + "onDelete": "cascade", 59 + "onUpdate": "no action" 60 + } 61 + }, 62 + "compositePrimaryKeys": { 63 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 64 + "columns": [ 65 + "monitor_id", 66 + "status_report_id" 67 + ], 68 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 69 + } 70 + }, 71 + "uniqueConstraints": {} 72 + }, 73 + "status_reports_to_pages": { 74 + "name": "status_reports_to_pages", 75 + "columns": { 76 + "page_id": { 77 + "name": "page_id", 78 + "type": "integer", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false 82 + }, 83 + "status_report_id": { 84 + "name": "status_report_id", 85 + "type": "integer", 86 + "primaryKey": false, 87 + "notNull": true, 88 + "autoincrement": false 89 + }, 90 + "created_at": { 91 + "name": "created_at", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false, 96 + "default": "(strftime('%s', 'now'))" 97 + } 98 + }, 99 + "indexes": {}, 100 + "foreignKeys": { 101 + "status_reports_to_pages_page_id_page_id_fk": { 102 + "name": "status_reports_to_pages_page_id_page_id_fk", 103 + "tableFrom": "status_reports_to_pages", 104 + "tableTo": "page", 105 + "columnsFrom": [ 106 + "page_id" 107 + ], 108 + "columnsTo": [ 109 + "id" 110 + ], 111 + "onDelete": "cascade", 112 + "onUpdate": "no action" 113 + }, 114 + "status_reports_to_pages_status_report_id_status_report_id_fk": { 115 + "name": "status_reports_to_pages_status_report_id_status_report_id_fk", 116 + "tableFrom": "status_reports_to_pages", 117 + "tableTo": "status_report", 118 + "columnsFrom": [ 119 + "status_report_id" 120 + ], 121 + "columnsTo": [ 122 + "id" 123 + ], 124 + "onDelete": "cascade", 125 + "onUpdate": "no action" 126 + } 127 + }, 128 + "compositePrimaryKeys": { 129 + "status_reports_to_pages_page_id_status_report_id_pk": { 130 + "columns": [ 131 + "page_id", 132 + "status_report_id" 133 + ], 134 + "name": "status_reports_to_pages_page_id_status_report_id_pk" 135 + } 136 + }, 137 + "uniqueConstraints": {} 138 + }, 139 + "status_report": { 140 + "name": "status_report", 141 + "columns": { 142 + "id": { 143 + "name": "id", 144 + "type": "integer", 145 + "primaryKey": true, 146 + "notNull": true, 147 + "autoincrement": false 148 + }, 149 + "status": { 150 + "name": "status", 151 + "type": "text", 152 + "primaryKey": false, 153 + "notNull": true, 154 + "autoincrement": false 155 + }, 156 + "title": { 157 + "name": "title", 158 + "type": "text(256)", 159 + "primaryKey": false, 160 + "notNull": true, 161 + "autoincrement": false 162 + }, 163 + "workspace_id": { 164 + "name": "workspace_id", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": false, 168 + "autoincrement": false 169 + }, 170 + "created_at": { 171 + "name": "created_at", 172 + "type": "integer", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false, 176 + "default": "(strftime('%s', 'now'))" 177 + }, 178 + "updated_at": { 179 + "name": "updated_at", 180 + "type": "integer", 181 + "primaryKey": false, 182 + "notNull": false, 183 + "autoincrement": false, 184 + "default": "(strftime('%s', 'now'))" 185 + } 186 + }, 187 + "indexes": {}, 188 + "foreignKeys": { 189 + "status_report_workspace_id_workspace_id_fk": { 190 + "name": "status_report_workspace_id_workspace_id_fk", 191 + "tableFrom": "status_report", 192 + "tableTo": "workspace", 193 + "columnsFrom": [ 194 + "workspace_id" 195 + ], 196 + "columnsTo": [ 197 + "id" 198 + ], 199 + "onDelete": "no action", 200 + "onUpdate": "no action" 201 + } 202 + }, 203 + "compositePrimaryKeys": {}, 204 + "uniqueConstraints": {} 205 + }, 206 + "status_report_update": { 207 + "name": "status_report_update", 208 + "columns": { 209 + "id": { 210 + "name": "id", 211 + "type": "integer", 212 + "primaryKey": true, 213 + "notNull": true, 214 + "autoincrement": false 215 + }, 216 + "status": { 217 + "name": "status", 218 + "type": "text(4)", 219 + "primaryKey": false, 220 + "notNull": true, 221 + "autoincrement": false 222 + }, 223 + "date": { 224 + "name": "date", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": true, 228 + "autoincrement": false 229 + }, 230 + "message": { 231 + "name": "message", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "status_report_id": { 238 + "name": "status_report_id", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "created_at": { 245 + "name": "created_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": false, 249 + "autoincrement": false, 250 + "default": "(strftime('%s', 'now'))" 251 + }, 252 + "updated_at": { 253 + "name": "updated_at", 254 + "type": "integer", 255 + "primaryKey": false, 256 + "notNull": false, 257 + "autoincrement": false, 258 + "default": "(strftime('%s', 'now'))" 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "status_report_update_status_report_id_status_report_id_fk": { 264 + "name": "status_report_update_status_report_id_status_report_id_fk", 265 + "tableFrom": "status_report_update", 266 + "tableTo": "status_report", 267 + "columnsFrom": [ 268 + "status_report_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {} 279 + }, 280 + "integration": { 281 + "name": "integration", 282 + "columns": { 283 + "id": { 284 + "name": "id", 285 + "type": "integer", 286 + "primaryKey": true, 287 + "notNull": true, 288 + "autoincrement": false 289 + }, 290 + "name": { 291 + "name": "name", 292 + "type": "text(256)", 293 + "primaryKey": false, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "workspace_id": { 298 + "name": "workspace_id", 299 + "type": "integer", 300 + "primaryKey": false, 301 + "notNull": false, 302 + "autoincrement": false 303 + }, 304 + "credential": { 305 + "name": "credential", 306 + "type": "text", 307 + "primaryKey": false, 308 + "notNull": false, 309 + "autoincrement": false 310 + }, 311 + "external_id": { 312 + "name": "external_id", 313 + "type": "text", 314 + "primaryKey": false, 315 + "notNull": true, 316 + "autoincrement": false 317 + }, 318 + "created_at": { 319 + "name": "created_at", 320 + "type": "integer", 321 + "primaryKey": false, 322 + "notNull": false, 323 + "autoincrement": false, 324 + "default": "(strftime('%s', 'now'))" 325 + }, 326 + "updated_at": { 327 + "name": "updated_at", 328 + "type": "integer", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false, 332 + "default": "(strftime('%s', 'now'))" 333 + }, 334 + "data": { 335 + "name": "data", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false 340 + } 341 + }, 342 + "indexes": {}, 343 + "foreignKeys": { 344 + "integration_workspace_id_workspace_id_fk": { 345 + "name": "integration_workspace_id_workspace_id_fk", 346 + "tableFrom": "integration", 347 + "tableTo": "workspace", 348 + "columnsFrom": [ 349 + "workspace_id" 350 + ], 351 + "columnsTo": [ 352 + "id" 353 + ], 354 + "onDelete": "no action", 355 + "onUpdate": "no action" 356 + } 357 + }, 358 + "compositePrimaryKeys": {}, 359 + "uniqueConstraints": {} 360 + }, 361 + "page": { 362 + "name": "page", 363 + "columns": { 364 + "id": { 365 + "name": "id", 366 + "type": "integer", 367 + "primaryKey": true, 368 + "notNull": true, 369 + "autoincrement": false 370 + }, 371 + "workspace_id": { 372 + "name": "workspace_id", 373 + "type": "integer", 374 + "primaryKey": false, 375 + "notNull": true, 376 + "autoincrement": false 377 + }, 378 + "title": { 379 + "name": "title", 380 + "type": "text", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "description": { 386 + "name": "description", 387 + "type": "text", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "icon": { 393 + "name": "icon", 394 + "type": "text(256)", 395 + "primaryKey": false, 396 + "notNull": false, 397 + "autoincrement": false, 398 + "default": "''" 399 + }, 400 + "slug": { 401 + "name": "slug", 402 + "type": "text(256)", 403 + "primaryKey": false, 404 + "notNull": true, 405 + "autoincrement": false 406 + }, 407 + "custom_domain": { 408 + "name": "custom_domain", 409 + "type": "text(256)", 410 + "primaryKey": false, 411 + "notNull": true, 412 + "autoincrement": false 413 + }, 414 + "published": { 415 + "name": "published", 416 + "type": "integer", 417 + "primaryKey": false, 418 + "notNull": false, 419 + "autoincrement": false, 420 + "default": false 421 + }, 422 + "password": { 423 + "name": "password", 424 + "type": "text(256)", 425 + "primaryKey": false, 426 + "notNull": false, 427 + "autoincrement": false 428 + }, 429 + "password_protected": { 430 + "name": "password_protected", 431 + "type": "integer", 432 + "primaryKey": false, 433 + "notNull": false, 434 + "autoincrement": false, 435 + "default": false 436 + }, 437 + "created_at": { 438 + "name": "created_at", 439 + "type": "integer", 440 + "primaryKey": false, 441 + "notNull": false, 442 + "autoincrement": false, 443 + "default": "(strftime('%s', 'now'))" 444 + }, 445 + "updated_at": { 446 + "name": "updated_at", 447 + "type": "integer", 448 + "primaryKey": false, 449 + "notNull": false, 450 + "autoincrement": false, 451 + "default": "(strftime('%s', 'now'))" 452 + } 453 + }, 454 + "indexes": { 455 + "page_slug_unique": { 456 + "name": "page_slug_unique", 457 + "columns": [ 458 + "slug" 459 + ], 460 + "isUnique": true 461 + } 462 + }, 463 + "foreignKeys": { 464 + "page_workspace_id_workspace_id_fk": { 465 + "name": "page_workspace_id_workspace_id_fk", 466 + "tableFrom": "page", 467 + "tableTo": "workspace", 468 + "columnsFrom": [ 469 + "workspace_id" 470 + ], 471 + "columnsTo": [ 472 + "id" 473 + ], 474 + "onDelete": "cascade", 475 + "onUpdate": "no action" 476 + } 477 + }, 478 + "compositePrimaryKeys": {}, 479 + "uniqueConstraints": {} 480 + }, 481 + "monitor": { 482 + "name": "monitor", 483 + "columns": { 484 + "id": { 485 + "name": "id", 486 + "type": "integer", 487 + "primaryKey": true, 488 + "notNull": true, 489 + "autoincrement": false 490 + }, 491 + "job_type": { 492 + "name": "job_type", 493 + "type": "text", 494 + "primaryKey": false, 495 + "notNull": true, 496 + "autoincrement": false, 497 + "default": "'other'" 498 + }, 499 + "periodicity": { 500 + "name": "periodicity", 501 + "type": "text", 502 + "primaryKey": false, 503 + "notNull": true, 504 + "autoincrement": false, 505 + "default": "'other'" 506 + }, 507 + "status": { 508 + "name": "status", 509 + "type": "text", 510 + "primaryKey": false, 511 + "notNull": true, 512 + "autoincrement": false, 513 + "default": "'active'" 514 + }, 515 + "active": { 516 + "name": "active", 517 + "type": "integer", 518 + "primaryKey": false, 519 + "notNull": false, 520 + "autoincrement": false, 521 + "default": false 522 + }, 523 + "regions": { 524 + "name": "regions", 525 + "type": "text", 526 + "primaryKey": false, 527 + "notNull": true, 528 + "autoincrement": false, 529 + "default": "''" 530 + }, 531 + "url": { 532 + "name": "url", 533 + "type": "text(2048)", 534 + "primaryKey": false, 535 + "notNull": true, 536 + "autoincrement": false 537 + }, 538 + "name": { 539 + "name": "name", 540 + "type": "text(256)", 541 + "primaryKey": false, 542 + "notNull": true, 543 + "autoincrement": false, 544 + "default": "''" 545 + }, 546 + "description": { 547 + "name": "description", 548 + "type": "text", 549 + "primaryKey": false, 550 + "notNull": true, 551 + "autoincrement": false, 552 + "default": "''" 553 + }, 554 + "headers": { 555 + "name": "headers", 556 + "type": "text", 557 + "primaryKey": false, 558 + "notNull": false, 559 + "autoincrement": false, 560 + "default": "''" 561 + }, 562 + "body": { 563 + "name": "body", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false, 567 + "autoincrement": false, 568 + "default": "''" 569 + }, 570 + "method": { 571 + "name": "method", 572 + "type": "text", 573 + "primaryKey": false, 574 + "notNull": false, 575 + "autoincrement": false, 576 + "default": "'GET'" 577 + }, 578 + "workspace_id": { 579 + "name": "workspace_id", 580 + "type": "integer", 581 + "primaryKey": false, 582 + "notNull": false, 583 + "autoincrement": false 584 + }, 585 + "assertions": { 586 + "name": "assertions", 587 + "type": "text", 588 + "primaryKey": false, 589 + "notNull": false, 590 + "autoincrement": false 591 + }, 592 + "public": { 593 + "name": "public", 594 + "type": "integer", 595 + "primaryKey": false, 596 + "notNull": false, 597 + "autoincrement": false, 598 + "default": false 599 + }, 600 + "created_at": { 601 + "name": "created_at", 602 + "type": "integer", 603 + "primaryKey": false, 604 + "notNull": false, 605 + "autoincrement": false, 606 + "default": "(strftime('%s', 'now'))" 607 + }, 608 + "updated_at": { 609 + "name": "updated_at", 610 + "type": "integer", 611 + "primaryKey": false, 612 + "notNull": false, 613 + "autoincrement": false, 614 + "default": "(strftime('%s', 'now'))" 615 + }, 616 + "deleted_at": { 617 + "name": "deleted_at", 618 + "type": "integer", 619 + "primaryKey": false, 620 + "notNull": false, 621 + "autoincrement": false 622 + } 623 + }, 624 + "indexes": {}, 625 + "foreignKeys": { 626 + "monitor_workspace_id_workspace_id_fk": { 627 + "name": "monitor_workspace_id_workspace_id_fk", 628 + "tableFrom": "monitor", 629 + "tableTo": "workspace", 630 + "columnsFrom": [ 631 + "workspace_id" 632 + ], 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onDelete": "no action", 637 + "onUpdate": "no action" 638 + } 639 + }, 640 + "compositePrimaryKeys": {}, 641 + "uniqueConstraints": {} 642 + }, 643 + "monitors_to_pages": { 644 + "name": "monitors_to_pages", 645 + "columns": { 646 + "monitor_id": { 647 + "name": "monitor_id", 648 + "type": "integer", 649 + "primaryKey": false, 650 + "notNull": true, 651 + "autoincrement": false 652 + }, 653 + "page_id": { 654 + "name": "page_id", 655 + "type": "integer", 656 + "primaryKey": false, 657 + "notNull": true, 658 + "autoincrement": false 659 + }, 660 + "created_at": { 661 + "name": "created_at", 662 + "type": "integer", 663 + "primaryKey": false, 664 + "notNull": false, 665 + "autoincrement": false, 666 + "default": "(strftime('%s', 'now'))" 667 + }, 668 + "order": { 669 + "name": "order", 670 + "type": "integer", 671 + "primaryKey": false, 672 + "notNull": false, 673 + "autoincrement": false, 674 + "default": 0 675 + } 676 + }, 677 + "indexes": {}, 678 + "foreignKeys": { 679 + "monitors_to_pages_monitor_id_monitor_id_fk": { 680 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 681 + "tableFrom": "monitors_to_pages", 682 + "tableTo": "monitor", 683 + "columnsFrom": [ 684 + "monitor_id" 685 + ], 686 + "columnsTo": [ 687 + "id" 688 + ], 689 + "onDelete": "cascade", 690 + "onUpdate": "no action" 691 + }, 692 + "monitors_to_pages_page_id_page_id_fk": { 693 + "name": "monitors_to_pages_page_id_page_id_fk", 694 + "tableFrom": "monitors_to_pages", 695 + "tableTo": "page", 696 + "columnsFrom": [ 697 + "page_id" 698 + ], 699 + "columnsTo": [ 700 + "id" 701 + ], 702 + "onDelete": "cascade", 703 + "onUpdate": "no action" 704 + } 705 + }, 706 + "compositePrimaryKeys": { 707 + "monitors_to_pages_monitor_id_page_id_pk": { 708 + "columns": [ 709 + "monitor_id", 710 + "page_id" 711 + ], 712 + "name": "monitors_to_pages_monitor_id_page_id_pk" 713 + } 714 + }, 715 + "uniqueConstraints": {} 716 + }, 717 + "account": { 718 + "name": "account", 719 + "columns": { 720 + "user_id": { 721 + "name": "user_id", 722 + "type": "integer", 723 + "primaryKey": false, 724 + "notNull": true, 725 + "autoincrement": false 726 + }, 727 + "type": { 728 + "name": "type", 729 + "type": "text", 730 + "primaryKey": false, 731 + "notNull": true, 732 + "autoincrement": false 733 + }, 734 + "provider": { 735 + "name": "provider", 736 + "type": "text", 737 + "primaryKey": false, 738 + "notNull": true, 739 + "autoincrement": false 740 + }, 741 + "provider_account_id": { 742 + "name": "provider_account_id", 743 + "type": "text", 744 + "primaryKey": false, 745 + "notNull": true, 746 + "autoincrement": false 747 + }, 748 + "refresh_token": { 749 + "name": "refresh_token", 750 + "type": "text", 751 + "primaryKey": false, 752 + "notNull": false, 753 + "autoincrement": false 754 + }, 755 + "access_token": { 756 + "name": "access_token", 757 + "type": "text", 758 + "primaryKey": false, 759 + "notNull": false, 760 + "autoincrement": false 761 + }, 762 + "expires_at": { 763 + "name": "expires_at", 764 + "type": "integer", 765 + "primaryKey": false, 766 + "notNull": false, 767 + "autoincrement": false 768 + }, 769 + "token_type": { 770 + "name": "token_type", 771 + "type": "text", 772 + "primaryKey": false, 773 + "notNull": false, 774 + "autoincrement": false 775 + }, 776 + "scope": { 777 + "name": "scope", 778 + "type": "text", 779 + "primaryKey": false, 780 + "notNull": false, 781 + "autoincrement": false 782 + }, 783 + "id_token": { 784 + "name": "id_token", 785 + "type": "text", 786 + "primaryKey": false, 787 + "notNull": false, 788 + "autoincrement": false 789 + }, 790 + "session_state": { 791 + "name": "session_state", 792 + "type": "text", 793 + "primaryKey": false, 794 + "notNull": false, 795 + "autoincrement": false 796 + } 797 + }, 798 + "indexes": {}, 799 + "foreignKeys": { 800 + "account_user_id_user_id_fk": { 801 + "name": "account_user_id_user_id_fk", 802 + "tableFrom": "account", 803 + "tableTo": "user", 804 + "columnsFrom": [ 805 + "user_id" 806 + ], 807 + "columnsTo": [ 808 + "id" 809 + ], 810 + "onDelete": "cascade", 811 + "onUpdate": "no action" 812 + } 813 + }, 814 + "compositePrimaryKeys": { 815 + "account_provider_provider_account_id_pk": { 816 + "columns": [ 817 + "provider", 818 + "provider_account_id" 819 + ], 820 + "name": "account_provider_provider_account_id_pk" 821 + } 822 + }, 823 + "uniqueConstraints": {} 824 + }, 825 + "session": { 826 + "name": "session", 827 + "columns": { 828 + "session_token": { 829 + "name": "session_token", 830 + "type": "text", 831 + "primaryKey": true, 832 + "notNull": true, 833 + "autoincrement": false 834 + }, 835 + "user_id": { 836 + "name": "user_id", 837 + "type": "integer", 838 + "primaryKey": false, 839 + "notNull": true, 840 + "autoincrement": false 841 + }, 842 + "expires": { 843 + "name": "expires", 844 + "type": "integer", 845 + "primaryKey": false, 846 + "notNull": true, 847 + "autoincrement": false 848 + } 849 + }, 850 + "indexes": {}, 851 + "foreignKeys": { 852 + "session_user_id_user_id_fk": { 853 + "name": "session_user_id_user_id_fk", 854 + "tableFrom": "session", 855 + "tableTo": "user", 856 + "columnsFrom": [ 857 + "user_id" 858 + ], 859 + "columnsTo": [ 860 + "id" 861 + ], 862 + "onDelete": "cascade", 863 + "onUpdate": "no action" 864 + } 865 + }, 866 + "compositePrimaryKeys": {}, 867 + "uniqueConstraints": {} 868 + }, 869 + "user": { 870 + "name": "user", 871 + "columns": { 872 + "id": { 873 + "name": "id", 874 + "type": "integer", 875 + "primaryKey": true, 876 + "notNull": true, 877 + "autoincrement": false 878 + }, 879 + "tenant_id": { 880 + "name": "tenant_id", 881 + "type": "text(256)", 882 + "primaryKey": false, 883 + "notNull": false, 884 + "autoincrement": false 885 + }, 886 + "first_name": { 887 + "name": "first_name", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": false, 891 + "autoincrement": false, 892 + "default": "''" 893 + }, 894 + "last_name": { 895 + "name": "last_name", 896 + "type": "text", 897 + "primaryKey": false, 898 + "notNull": false, 899 + "autoincrement": false, 900 + "default": "''" 901 + }, 902 + "photo_url": { 903 + "name": "photo_url", 904 + "type": "text", 905 + "primaryKey": false, 906 + "notNull": false, 907 + "autoincrement": false, 908 + "default": "''" 909 + }, 910 + "name": { 911 + "name": "name", 912 + "type": "text", 913 + "primaryKey": false, 914 + "notNull": false, 915 + "autoincrement": false 916 + }, 917 + "email": { 918 + "name": "email", 919 + "type": "text", 920 + "primaryKey": false, 921 + "notNull": false, 922 + "autoincrement": false, 923 + "default": "''" 924 + }, 925 + "emailVerified": { 926 + "name": "emailVerified", 927 + "type": "integer", 928 + "primaryKey": false, 929 + "notNull": false, 930 + "autoincrement": false 931 + }, 932 + "created_at": { 933 + "name": "created_at", 934 + "type": "integer", 935 + "primaryKey": false, 936 + "notNull": false, 937 + "autoincrement": false, 938 + "default": "(strftime('%s', 'now'))" 939 + }, 940 + "updated_at": { 941 + "name": "updated_at", 942 + "type": "integer", 943 + "primaryKey": false, 944 + "notNull": false, 945 + "autoincrement": false, 946 + "default": "(strftime('%s', 'now'))" 947 + } 948 + }, 949 + "indexes": { 950 + "user_tenant_id_unique": { 951 + "name": "user_tenant_id_unique", 952 + "columns": [ 953 + "tenant_id" 954 + ], 955 + "isUnique": true 956 + } 957 + }, 958 + "foreignKeys": {}, 959 + "compositePrimaryKeys": {}, 960 + "uniqueConstraints": {} 961 + }, 962 + "users_to_workspaces": { 963 + "name": "users_to_workspaces", 964 + "columns": { 965 + "user_id": { 966 + "name": "user_id", 967 + "type": "integer", 968 + "primaryKey": false, 969 + "notNull": true, 970 + "autoincrement": false 971 + }, 972 + "workspace_id": { 973 + "name": "workspace_id", 974 + "type": "integer", 975 + "primaryKey": false, 976 + "notNull": true, 977 + "autoincrement": false 978 + }, 979 + "role": { 980 + "name": "role", 981 + "type": "text", 982 + "primaryKey": false, 983 + "notNull": true, 984 + "autoincrement": false, 985 + "default": "'member'" 986 + }, 987 + "created_at": { 988 + "name": "created_at", 989 + "type": "integer", 990 + "primaryKey": false, 991 + "notNull": false, 992 + "autoincrement": false, 993 + "default": "(strftime('%s', 'now'))" 994 + } 995 + }, 996 + "indexes": {}, 997 + "foreignKeys": { 998 + "users_to_workspaces_user_id_user_id_fk": { 999 + "name": "users_to_workspaces_user_id_user_id_fk", 1000 + "tableFrom": "users_to_workspaces", 1001 + "tableTo": "user", 1002 + "columnsFrom": [ 1003 + "user_id" 1004 + ], 1005 + "columnsTo": [ 1006 + "id" 1007 + ], 1008 + "onDelete": "no action", 1009 + "onUpdate": "no action" 1010 + }, 1011 + "users_to_workspaces_workspace_id_workspace_id_fk": { 1012 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 1013 + "tableFrom": "users_to_workspaces", 1014 + "tableTo": "workspace", 1015 + "columnsFrom": [ 1016 + "workspace_id" 1017 + ], 1018 + "columnsTo": [ 1019 + "id" 1020 + ], 1021 + "onDelete": "no action", 1022 + "onUpdate": "no action" 1023 + } 1024 + }, 1025 + "compositePrimaryKeys": { 1026 + "users_to_workspaces_user_id_workspace_id_pk": { 1027 + "columns": [ 1028 + "user_id", 1029 + "workspace_id" 1030 + ], 1031 + "name": "users_to_workspaces_user_id_workspace_id_pk" 1032 + } 1033 + }, 1034 + "uniqueConstraints": {} 1035 + }, 1036 + "verification_token": { 1037 + "name": "verification_token", 1038 + "columns": { 1039 + "identifier": { 1040 + "name": "identifier", 1041 + "type": "text", 1042 + "primaryKey": false, 1043 + "notNull": true, 1044 + "autoincrement": false 1045 + }, 1046 + "token": { 1047 + "name": "token", 1048 + "type": "text", 1049 + "primaryKey": false, 1050 + "notNull": true, 1051 + "autoincrement": false 1052 + }, 1053 + "expires": { 1054 + "name": "expires", 1055 + "type": "integer", 1056 + "primaryKey": false, 1057 + "notNull": true, 1058 + "autoincrement": false 1059 + } 1060 + }, 1061 + "indexes": {}, 1062 + "foreignKeys": {}, 1063 + "compositePrimaryKeys": { 1064 + "verification_token_identifier_token_pk": { 1065 + "columns": [ 1066 + "identifier", 1067 + "token" 1068 + ], 1069 + "name": "verification_token_identifier_token_pk" 1070 + } 1071 + }, 1072 + "uniqueConstraints": {} 1073 + }, 1074 + "page_subscriber": { 1075 + "name": "page_subscriber", 1076 + "columns": { 1077 + "id": { 1078 + "name": "id", 1079 + "type": "integer", 1080 + "primaryKey": true, 1081 + "notNull": true, 1082 + "autoincrement": false 1083 + }, 1084 + "email": { 1085 + "name": "email", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true, 1089 + "autoincrement": false 1090 + }, 1091 + "page_id": { 1092 + "name": "page_id", 1093 + "type": "integer", 1094 + "primaryKey": false, 1095 + "notNull": true, 1096 + "autoincrement": false 1097 + }, 1098 + "token": { 1099 + "name": "token", 1100 + "type": "text", 1101 + "primaryKey": false, 1102 + "notNull": false, 1103 + "autoincrement": false 1104 + }, 1105 + "accepted_at": { 1106 + "name": "accepted_at", 1107 + "type": "integer", 1108 + "primaryKey": false, 1109 + "notNull": false, 1110 + "autoincrement": false 1111 + }, 1112 + "expires_at": { 1113 + "name": "expires_at", 1114 + "type": "integer", 1115 + "primaryKey": false, 1116 + "notNull": false, 1117 + "autoincrement": false 1118 + }, 1119 + "created_at": { 1120 + "name": "created_at", 1121 + "type": "integer", 1122 + "primaryKey": false, 1123 + "notNull": false, 1124 + "autoincrement": false, 1125 + "default": "(strftime('%s', 'now'))" 1126 + }, 1127 + "updated_at": { 1128 + "name": "updated_at", 1129 + "type": "integer", 1130 + "primaryKey": false, 1131 + "notNull": false, 1132 + "autoincrement": false, 1133 + "default": "(strftime('%s', 'now'))" 1134 + } 1135 + }, 1136 + "indexes": {}, 1137 + "foreignKeys": { 1138 + "page_subscriber_page_id_page_id_fk": { 1139 + "name": "page_subscriber_page_id_page_id_fk", 1140 + "tableFrom": "page_subscriber", 1141 + "tableTo": "page", 1142 + "columnsFrom": [ 1143 + "page_id" 1144 + ], 1145 + "columnsTo": [ 1146 + "id" 1147 + ], 1148 + "onDelete": "no action", 1149 + "onUpdate": "no action" 1150 + } 1151 + }, 1152 + "compositePrimaryKeys": {}, 1153 + "uniqueConstraints": {} 1154 + }, 1155 + "workspace": { 1156 + "name": "workspace", 1157 + "columns": { 1158 + "id": { 1159 + "name": "id", 1160 + "type": "integer", 1161 + "primaryKey": true, 1162 + "notNull": true, 1163 + "autoincrement": false 1164 + }, 1165 + "slug": { 1166 + "name": "slug", 1167 + "type": "text", 1168 + "primaryKey": false, 1169 + "notNull": true, 1170 + "autoincrement": false 1171 + }, 1172 + "name": { 1173 + "name": "name", 1174 + "type": "text", 1175 + "primaryKey": false, 1176 + "notNull": false, 1177 + "autoincrement": false 1178 + }, 1179 + "stripe_id": { 1180 + "name": "stripe_id", 1181 + "type": "text(256)", 1182 + "primaryKey": false, 1183 + "notNull": false, 1184 + "autoincrement": false 1185 + }, 1186 + "subscription_id": { 1187 + "name": "subscription_id", 1188 + "type": "text", 1189 + "primaryKey": false, 1190 + "notNull": false, 1191 + "autoincrement": false 1192 + }, 1193 + "plan": { 1194 + "name": "plan", 1195 + "type": "text", 1196 + "primaryKey": false, 1197 + "notNull": false, 1198 + "autoincrement": false 1199 + }, 1200 + "ends_at": { 1201 + "name": "ends_at", 1202 + "type": "integer", 1203 + "primaryKey": false, 1204 + "notNull": false, 1205 + "autoincrement": false 1206 + }, 1207 + "paid_until": { 1208 + "name": "paid_until", 1209 + "type": "integer", 1210 + "primaryKey": false, 1211 + "notNull": false, 1212 + "autoincrement": false 1213 + }, 1214 + "created_at": { 1215 + "name": "created_at", 1216 + "type": "integer", 1217 + "primaryKey": false, 1218 + "notNull": false, 1219 + "autoincrement": false, 1220 + "default": "(strftime('%s', 'now'))" 1221 + }, 1222 + "updated_at": { 1223 + "name": "updated_at", 1224 + "type": "integer", 1225 + "primaryKey": false, 1226 + "notNull": false, 1227 + "autoincrement": false, 1228 + "default": "(strftime('%s', 'now'))" 1229 + }, 1230 + "dsn": { 1231 + "name": "dsn", 1232 + "type": "text", 1233 + "primaryKey": false, 1234 + "notNull": false, 1235 + "autoincrement": false 1236 + } 1237 + }, 1238 + "indexes": { 1239 + "workspace_slug_unique": { 1240 + "name": "workspace_slug_unique", 1241 + "columns": [ 1242 + "slug" 1243 + ], 1244 + "isUnique": true 1245 + }, 1246 + "workspace_stripe_id_unique": { 1247 + "name": "workspace_stripe_id_unique", 1248 + "columns": [ 1249 + "stripe_id" 1250 + ], 1251 + "isUnique": true 1252 + }, 1253 + "workspace_id_dsn_unique": { 1254 + "name": "workspace_id_dsn_unique", 1255 + "columns": [ 1256 + "id", 1257 + "dsn" 1258 + ], 1259 + "isUnique": true 1260 + } 1261 + }, 1262 + "foreignKeys": {}, 1263 + "compositePrimaryKeys": {}, 1264 + "uniqueConstraints": {} 1265 + }, 1266 + "notification": { 1267 + "name": "notification", 1268 + "columns": { 1269 + "id": { 1270 + "name": "id", 1271 + "type": "integer", 1272 + "primaryKey": true, 1273 + "notNull": true, 1274 + "autoincrement": false 1275 + }, 1276 + "name": { 1277 + "name": "name", 1278 + "type": "text", 1279 + "primaryKey": false, 1280 + "notNull": true, 1281 + "autoincrement": false 1282 + }, 1283 + "provider": { 1284 + "name": "provider", 1285 + "type": "text", 1286 + "primaryKey": false, 1287 + "notNull": true, 1288 + "autoincrement": false 1289 + }, 1290 + "data": { 1291 + "name": "data", 1292 + "type": "text", 1293 + "primaryKey": false, 1294 + "notNull": false, 1295 + "autoincrement": false, 1296 + "default": "'{}'" 1297 + }, 1298 + "workspace_id": { 1299 + "name": "workspace_id", 1300 + "type": "integer", 1301 + "primaryKey": false, 1302 + "notNull": false, 1303 + "autoincrement": false 1304 + }, 1305 + "created_at": { 1306 + "name": "created_at", 1307 + "type": "integer", 1308 + "primaryKey": false, 1309 + "notNull": false, 1310 + "autoincrement": false, 1311 + "default": "(strftime('%s', 'now'))" 1312 + }, 1313 + "updated_at": { 1314 + "name": "updated_at", 1315 + "type": "integer", 1316 + "primaryKey": false, 1317 + "notNull": false, 1318 + "autoincrement": false, 1319 + "default": "(strftime('%s', 'now'))" 1320 + } 1321 + }, 1322 + "indexes": {}, 1323 + "foreignKeys": { 1324 + "notification_workspace_id_workspace_id_fk": { 1325 + "name": "notification_workspace_id_workspace_id_fk", 1326 + "tableFrom": "notification", 1327 + "tableTo": "workspace", 1328 + "columnsFrom": [ 1329 + "workspace_id" 1330 + ], 1331 + "columnsTo": [ 1332 + "id" 1333 + ], 1334 + "onDelete": "no action", 1335 + "onUpdate": "no action" 1336 + } 1337 + }, 1338 + "compositePrimaryKeys": {}, 1339 + "uniqueConstraints": {} 1340 + }, 1341 + "notifications_to_monitors": { 1342 + "name": "notifications_to_monitors", 1343 + "columns": { 1344 + "monitor_id": { 1345 + "name": "monitor_id", 1346 + "type": "integer", 1347 + "primaryKey": false, 1348 + "notNull": true, 1349 + "autoincrement": false 1350 + }, 1351 + "notification_id": { 1352 + "name": "notification_id", 1353 + "type": "integer", 1354 + "primaryKey": false, 1355 + "notNull": true, 1356 + "autoincrement": false 1357 + }, 1358 + "created_at": { 1359 + "name": "created_at", 1360 + "type": "integer", 1361 + "primaryKey": false, 1362 + "notNull": false, 1363 + "autoincrement": false, 1364 + "default": "(strftime('%s', 'now'))" 1365 + } 1366 + }, 1367 + "indexes": {}, 1368 + "foreignKeys": { 1369 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1370 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1371 + "tableFrom": "notifications_to_monitors", 1372 + "tableTo": "monitor", 1373 + "columnsFrom": [ 1374 + "monitor_id" 1375 + ], 1376 + "columnsTo": [ 1377 + "id" 1378 + ], 1379 + "onDelete": "cascade", 1380 + "onUpdate": "no action" 1381 + }, 1382 + "notifications_to_monitors_notification_id_notification_id_fk": { 1383 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1384 + "tableFrom": "notifications_to_monitors", 1385 + "tableTo": "notification", 1386 + "columnsFrom": [ 1387 + "notification_id" 1388 + ], 1389 + "columnsTo": [ 1390 + "id" 1391 + ], 1392 + "onDelete": "cascade", 1393 + "onUpdate": "no action" 1394 + } 1395 + }, 1396 + "compositePrimaryKeys": { 1397 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1398 + "columns": [ 1399 + "monitor_id", 1400 + "notification_id" 1401 + ], 1402 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1403 + } 1404 + }, 1405 + "uniqueConstraints": {} 1406 + }, 1407 + "monitor_status": { 1408 + "name": "monitor_status", 1409 + "columns": { 1410 + "monitor_id": { 1411 + "name": "monitor_id", 1412 + "type": "integer", 1413 + "primaryKey": false, 1414 + "notNull": true, 1415 + "autoincrement": false 1416 + }, 1417 + "region": { 1418 + "name": "region", 1419 + "type": "text", 1420 + "primaryKey": false, 1421 + "notNull": true, 1422 + "autoincrement": false, 1423 + "default": "''" 1424 + }, 1425 + "status": { 1426 + "name": "status", 1427 + "type": "text", 1428 + "primaryKey": false, 1429 + "notNull": true, 1430 + "autoincrement": false, 1431 + "default": "'active'" 1432 + }, 1433 + "created_at": { 1434 + "name": "created_at", 1435 + "type": "integer", 1436 + "primaryKey": false, 1437 + "notNull": false, 1438 + "autoincrement": false, 1439 + "default": "(strftime('%s', 'now'))" 1440 + }, 1441 + "updated_at": { 1442 + "name": "updated_at", 1443 + "type": "integer", 1444 + "primaryKey": false, 1445 + "notNull": false, 1446 + "autoincrement": false, 1447 + "default": "(strftime('%s', 'now'))" 1448 + } 1449 + }, 1450 + "indexes": { 1451 + "monitor_status_idx": { 1452 + "name": "monitor_status_idx", 1453 + "columns": [ 1454 + "monitor_id", 1455 + "region" 1456 + ], 1457 + "isUnique": false 1458 + } 1459 + }, 1460 + "foreignKeys": { 1461 + "monitor_status_monitor_id_monitor_id_fk": { 1462 + "name": "monitor_status_monitor_id_monitor_id_fk", 1463 + "tableFrom": "monitor_status", 1464 + "tableTo": "monitor", 1465 + "columnsFrom": [ 1466 + "monitor_id" 1467 + ], 1468 + "columnsTo": [ 1469 + "id" 1470 + ], 1471 + "onDelete": "cascade", 1472 + "onUpdate": "no action" 1473 + } 1474 + }, 1475 + "compositePrimaryKeys": { 1476 + "monitor_status_monitor_id_region_pk": { 1477 + "columns": [ 1478 + "monitor_id", 1479 + "region" 1480 + ], 1481 + "name": "monitor_status_monitor_id_region_pk" 1482 + } 1483 + }, 1484 + "uniqueConstraints": {} 1485 + }, 1486 + "invitation": { 1487 + "name": "invitation", 1488 + "columns": { 1489 + "id": { 1490 + "name": "id", 1491 + "type": "integer", 1492 + "primaryKey": true, 1493 + "notNull": true, 1494 + "autoincrement": false 1495 + }, 1496 + "email": { 1497 + "name": "email", 1498 + "type": "text", 1499 + "primaryKey": false, 1500 + "notNull": true, 1501 + "autoincrement": false 1502 + }, 1503 + "role": { 1504 + "name": "role", 1505 + "type": "text", 1506 + "primaryKey": false, 1507 + "notNull": true, 1508 + "autoincrement": false, 1509 + "default": "'member'" 1510 + }, 1511 + "workspace_id": { 1512 + "name": "workspace_id", 1513 + "type": "integer", 1514 + "primaryKey": false, 1515 + "notNull": true, 1516 + "autoincrement": false 1517 + }, 1518 + "token": { 1519 + "name": "token", 1520 + "type": "text", 1521 + "primaryKey": false, 1522 + "notNull": true, 1523 + "autoincrement": false 1524 + }, 1525 + "expires_at": { 1526 + "name": "expires_at", 1527 + "type": "integer", 1528 + "primaryKey": false, 1529 + "notNull": true, 1530 + "autoincrement": false 1531 + }, 1532 + "created_at": { 1533 + "name": "created_at", 1534 + "type": "integer", 1535 + "primaryKey": false, 1536 + "notNull": false, 1537 + "autoincrement": false, 1538 + "default": "(strftime('%s', 'now'))" 1539 + }, 1540 + "accepted_at": { 1541 + "name": "accepted_at", 1542 + "type": "integer", 1543 + "primaryKey": false, 1544 + "notNull": false, 1545 + "autoincrement": false 1546 + } 1547 + }, 1548 + "indexes": {}, 1549 + "foreignKeys": {}, 1550 + "compositePrimaryKeys": {}, 1551 + "uniqueConstraints": {} 1552 + }, 1553 + "incident": { 1554 + "name": "incident", 1555 + "columns": { 1556 + "id": { 1557 + "name": "id", 1558 + "type": "integer", 1559 + "primaryKey": true, 1560 + "notNull": true, 1561 + "autoincrement": false 1562 + }, 1563 + "title": { 1564 + "name": "title", 1565 + "type": "text", 1566 + "primaryKey": false, 1567 + "notNull": true, 1568 + "autoincrement": false, 1569 + "default": "''" 1570 + }, 1571 + "summary": { 1572 + "name": "summary", 1573 + "type": "text", 1574 + "primaryKey": false, 1575 + "notNull": true, 1576 + "autoincrement": false, 1577 + "default": "''" 1578 + }, 1579 + "status": { 1580 + "name": "status", 1581 + "type": "text", 1582 + "primaryKey": false, 1583 + "notNull": true, 1584 + "autoincrement": false, 1585 + "default": "'triage'" 1586 + }, 1587 + "monitor_id": { 1588 + "name": "monitor_id", 1589 + "type": "integer", 1590 + "primaryKey": false, 1591 + "notNull": false, 1592 + "autoincrement": false 1593 + }, 1594 + "workspace_id": { 1595 + "name": "workspace_id", 1596 + "type": "integer", 1597 + "primaryKey": false, 1598 + "notNull": false, 1599 + "autoincrement": false 1600 + }, 1601 + "started_at": { 1602 + "name": "started_at", 1603 + "type": "integer", 1604 + "primaryKey": false, 1605 + "notNull": true, 1606 + "autoincrement": false, 1607 + "default": "(strftime('%s', 'now'))" 1608 + }, 1609 + "acknowledged_at": { 1610 + "name": "acknowledged_at", 1611 + "type": "integer", 1612 + "primaryKey": false, 1613 + "notNull": false, 1614 + "autoincrement": false 1615 + }, 1616 + "acknowledged_by": { 1617 + "name": "acknowledged_by", 1618 + "type": "integer", 1619 + "primaryKey": false, 1620 + "notNull": false, 1621 + "autoincrement": false 1622 + }, 1623 + "resolved_at": { 1624 + "name": "resolved_at", 1625 + "type": "integer", 1626 + "primaryKey": false, 1627 + "notNull": false, 1628 + "autoincrement": false 1629 + }, 1630 + "resolved_by": { 1631 + "name": "resolved_by", 1632 + "type": "integer", 1633 + "primaryKey": false, 1634 + "notNull": false, 1635 + "autoincrement": false 1636 + }, 1637 + "incident_screenshot_url": { 1638 + "name": "incident_screenshot_url", 1639 + "type": "text", 1640 + "primaryKey": false, 1641 + "notNull": false, 1642 + "autoincrement": false 1643 + }, 1644 + "recovery_screenshot_url": { 1645 + "name": "recovery_screenshot_url", 1646 + "type": "text", 1647 + "primaryKey": false, 1648 + "notNull": false, 1649 + "autoincrement": false 1650 + }, 1651 + "auto_resolved": { 1652 + "name": "auto_resolved", 1653 + "type": "integer", 1654 + "primaryKey": false, 1655 + "notNull": false, 1656 + "autoincrement": false, 1657 + "default": false 1658 + }, 1659 + "created_at": { 1660 + "name": "created_at", 1661 + "type": "integer", 1662 + "primaryKey": false, 1663 + "notNull": false, 1664 + "autoincrement": false, 1665 + "default": "(strftime('%s', 'now'))" 1666 + }, 1667 + "updated_at": { 1668 + "name": "updated_at", 1669 + "type": "integer", 1670 + "primaryKey": false, 1671 + "notNull": false, 1672 + "autoincrement": false, 1673 + "default": "(strftime('%s', 'now'))" 1674 + } 1675 + }, 1676 + "indexes": { 1677 + "incident_monitor_id_started_at_unique": { 1678 + "name": "incident_monitor_id_started_at_unique", 1679 + "columns": [ 1680 + "monitor_id", 1681 + "started_at" 1682 + ], 1683 + "isUnique": true 1684 + } 1685 + }, 1686 + "foreignKeys": { 1687 + "incident_monitor_id_monitor_id_fk": { 1688 + "name": "incident_monitor_id_monitor_id_fk", 1689 + "tableFrom": "incident", 1690 + "tableTo": "monitor", 1691 + "columnsFrom": [ 1692 + "monitor_id" 1693 + ], 1694 + "columnsTo": [ 1695 + "id" 1696 + ], 1697 + "onDelete": "set default", 1698 + "onUpdate": "no action" 1699 + }, 1700 + "incident_workspace_id_workspace_id_fk": { 1701 + "name": "incident_workspace_id_workspace_id_fk", 1702 + "tableFrom": "incident", 1703 + "tableTo": "workspace", 1704 + "columnsFrom": [ 1705 + "workspace_id" 1706 + ], 1707 + "columnsTo": [ 1708 + "id" 1709 + ], 1710 + "onDelete": "no action", 1711 + "onUpdate": "no action" 1712 + }, 1713 + "incident_acknowledged_by_user_id_fk": { 1714 + "name": "incident_acknowledged_by_user_id_fk", 1715 + "tableFrom": "incident", 1716 + "tableTo": "user", 1717 + "columnsFrom": [ 1718 + "acknowledged_by" 1719 + ], 1720 + "columnsTo": [ 1721 + "id" 1722 + ], 1723 + "onDelete": "no action", 1724 + "onUpdate": "no action" 1725 + }, 1726 + "incident_resolved_by_user_id_fk": { 1727 + "name": "incident_resolved_by_user_id_fk", 1728 + "tableFrom": "incident", 1729 + "tableTo": "user", 1730 + "columnsFrom": [ 1731 + "resolved_by" 1732 + ], 1733 + "columnsTo": [ 1734 + "id" 1735 + ], 1736 + "onDelete": "no action", 1737 + "onUpdate": "no action" 1738 + } 1739 + }, 1740 + "compositePrimaryKeys": {}, 1741 + "uniqueConstraints": {} 1742 + }, 1743 + "monitor_tag": { 1744 + "name": "monitor_tag", 1745 + "columns": { 1746 + "id": { 1747 + "name": "id", 1748 + "type": "integer", 1749 + "primaryKey": true, 1750 + "notNull": true, 1751 + "autoincrement": false 1752 + }, 1753 + "workspace_id": { 1754 + "name": "workspace_id", 1755 + "type": "integer", 1756 + "primaryKey": false, 1757 + "notNull": true, 1758 + "autoincrement": false 1759 + }, 1760 + "name": { 1761 + "name": "name", 1762 + "type": "text", 1763 + "primaryKey": false, 1764 + "notNull": true, 1765 + "autoincrement": false 1766 + }, 1767 + "color": { 1768 + "name": "color", 1769 + "type": "text", 1770 + "primaryKey": false, 1771 + "notNull": true, 1772 + "autoincrement": false 1773 + }, 1774 + "created_at": { 1775 + "name": "created_at", 1776 + "type": "integer", 1777 + "primaryKey": false, 1778 + "notNull": false, 1779 + "autoincrement": false, 1780 + "default": "(strftime('%s', 'now'))" 1781 + }, 1782 + "updated_at": { 1783 + "name": "updated_at", 1784 + "type": "integer", 1785 + "primaryKey": false, 1786 + "notNull": false, 1787 + "autoincrement": false, 1788 + "default": "(strftime('%s', 'now'))" 1789 + } 1790 + }, 1791 + "indexes": {}, 1792 + "foreignKeys": { 1793 + "monitor_tag_workspace_id_workspace_id_fk": { 1794 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1795 + "tableFrom": "monitor_tag", 1796 + "tableTo": "workspace", 1797 + "columnsFrom": [ 1798 + "workspace_id" 1799 + ], 1800 + "columnsTo": [ 1801 + "id" 1802 + ], 1803 + "onDelete": "cascade", 1804 + "onUpdate": "no action" 1805 + } 1806 + }, 1807 + "compositePrimaryKeys": {}, 1808 + "uniqueConstraints": {} 1809 + }, 1810 + "monitor_tag_to_monitor": { 1811 + "name": "monitor_tag_to_monitor", 1812 + "columns": { 1813 + "monitor_id": { 1814 + "name": "monitor_id", 1815 + "type": "integer", 1816 + "primaryKey": false, 1817 + "notNull": true, 1818 + "autoincrement": false 1819 + }, 1820 + "monitor_tag_id": { 1821 + "name": "monitor_tag_id", 1822 + "type": "integer", 1823 + "primaryKey": false, 1824 + "notNull": true, 1825 + "autoincrement": false 1826 + }, 1827 + "created_at": { 1828 + "name": "created_at", 1829 + "type": "integer", 1830 + "primaryKey": false, 1831 + "notNull": false, 1832 + "autoincrement": false, 1833 + "default": "(strftime('%s', 'now'))" 1834 + } 1835 + }, 1836 + "indexes": {}, 1837 + "foreignKeys": { 1838 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 1839 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 1840 + "tableFrom": "monitor_tag_to_monitor", 1841 + "tableTo": "monitor", 1842 + "columnsFrom": [ 1843 + "monitor_id" 1844 + ], 1845 + "columnsTo": [ 1846 + "id" 1847 + ], 1848 + "onDelete": "cascade", 1849 + "onUpdate": "no action" 1850 + }, 1851 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 1852 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 1853 + "tableFrom": "monitor_tag_to_monitor", 1854 + "tableTo": "monitor_tag", 1855 + "columnsFrom": [ 1856 + "monitor_tag_id" 1857 + ], 1858 + "columnsTo": [ 1859 + "id" 1860 + ], 1861 + "onDelete": "cascade", 1862 + "onUpdate": "no action" 1863 + } 1864 + }, 1865 + "compositePrimaryKeys": { 1866 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 1867 + "columns": [ 1868 + "monitor_id", 1869 + "monitor_tag_id" 1870 + ], 1871 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 1872 + } 1873 + }, 1874 + "uniqueConstraints": {} 1875 + }, 1876 + "application": { 1877 + "name": "application", 1878 + "columns": { 1879 + "id": { 1880 + "name": "id", 1881 + "type": "integer", 1882 + "primaryKey": true, 1883 + "notNull": true, 1884 + "autoincrement": false 1885 + }, 1886 + "name": { 1887 + "name": "name", 1888 + "type": "text", 1889 + "primaryKey": false, 1890 + "notNull": false, 1891 + "autoincrement": false 1892 + }, 1893 + "dsn": { 1894 + "name": "dsn", 1895 + "type": "text", 1896 + "primaryKey": false, 1897 + "notNull": false, 1898 + "autoincrement": false 1899 + }, 1900 + "workspace_id": { 1901 + "name": "workspace_id", 1902 + "type": "integer", 1903 + "primaryKey": false, 1904 + "notNull": false, 1905 + "autoincrement": false 1906 + }, 1907 + "created_at": { 1908 + "name": "created_at", 1909 + "type": "integer", 1910 + "primaryKey": false, 1911 + "notNull": false, 1912 + "autoincrement": false, 1913 + "default": "(strftime('%s', 'now'))" 1914 + }, 1915 + "updated_at": { 1916 + "name": "updated_at", 1917 + "type": "integer", 1918 + "primaryKey": false, 1919 + "notNull": false, 1920 + "autoincrement": false, 1921 + "default": "(strftime('%s', 'now'))" 1922 + } 1923 + }, 1924 + "indexes": { 1925 + "application_dsn_unique": { 1926 + "name": "application_dsn_unique", 1927 + "columns": [ 1928 + "dsn" 1929 + ], 1930 + "isUnique": true 1931 + } 1932 + }, 1933 + "foreignKeys": { 1934 + "application_workspace_id_workspace_id_fk": { 1935 + "name": "application_workspace_id_workspace_id_fk", 1936 + "tableFrom": "application", 1937 + "tableTo": "workspace", 1938 + "columnsFrom": [ 1939 + "workspace_id" 1940 + ], 1941 + "columnsTo": [ 1942 + "id" 1943 + ], 1944 + "onDelete": "no action", 1945 + "onUpdate": "no action" 1946 + } 1947 + }, 1948 + "compositePrimaryKeys": {}, 1949 + "uniqueConstraints": {} 1950 + }, 1951 + "maintenance": { 1952 + "name": "maintenance", 1953 + "columns": { 1954 + "id": { 1955 + "name": "id", 1956 + "type": "integer", 1957 + "primaryKey": true, 1958 + "notNull": true, 1959 + "autoincrement": false 1960 + }, 1961 + "title": { 1962 + "name": "title", 1963 + "type": "text(256)", 1964 + "primaryKey": false, 1965 + "notNull": true, 1966 + "autoincrement": false 1967 + }, 1968 + "message": { 1969 + "name": "message", 1970 + "type": "text", 1971 + "primaryKey": false, 1972 + "notNull": true, 1973 + "autoincrement": false 1974 + }, 1975 + "from": { 1976 + "name": "from", 1977 + "type": "integer", 1978 + "primaryKey": false, 1979 + "notNull": true, 1980 + "autoincrement": false 1981 + }, 1982 + "to": { 1983 + "name": "to", 1984 + "type": "integer", 1985 + "primaryKey": false, 1986 + "notNull": true, 1987 + "autoincrement": false 1988 + }, 1989 + "workspace_id": { 1990 + "name": "workspace_id", 1991 + "type": "integer", 1992 + "primaryKey": false, 1993 + "notNull": false, 1994 + "autoincrement": false 1995 + }, 1996 + "page_id": { 1997 + "name": "page_id", 1998 + "type": "integer", 1999 + "primaryKey": false, 2000 + "notNull": false, 2001 + "autoincrement": false 2002 + }, 2003 + "created_at": { 2004 + "name": "created_at", 2005 + "type": "integer", 2006 + "primaryKey": false, 2007 + "notNull": false, 2008 + "autoincrement": false, 2009 + "default": "(strftime('%s', 'now'))" 2010 + }, 2011 + "updated_at": { 2012 + "name": "updated_at", 2013 + "type": "integer", 2014 + "primaryKey": false, 2015 + "notNull": false, 2016 + "autoincrement": false, 2017 + "default": "(strftime('%s', 'now'))" 2018 + } 2019 + }, 2020 + "indexes": {}, 2021 + "foreignKeys": { 2022 + "maintenance_workspace_id_workspace_id_fk": { 2023 + "name": "maintenance_workspace_id_workspace_id_fk", 2024 + "tableFrom": "maintenance", 2025 + "tableTo": "workspace", 2026 + "columnsFrom": [ 2027 + "workspace_id" 2028 + ], 2029 + "columnsTo": [ 2030 + "id" 2031 + ], 2032 + "onDelete": "no action", 2033 + "onUpdate": "no action" 2034 + }, 2035 + "maintenance_page_id_page_id_fk": { 2036 + "name": "maintenance_page_id_page_id_fk", 2037 + "tableFrom": "maintenance", 2038 + "tableTo": "page", 2039 + "columnsFrom": [ 2040 + "page_id" 2041 + ], 2042 + "columnsTo": [ 2043 + "id" 2044 + ], 2045 + "onDelete": "no action", 2046 + "onUpdate": "no action" 2047 + } 2048 + }, 2049 + "compositePrimaryKeys": {}, 2050 + "uniqueConstraints": {} 2051 + }, 2052 + "maintenance_to_monitor": { 2053 + "name": "maintenance_to_monitor", 2054 + "columns": { 2055 + "monitor_id": { 2056 + "name": "monitor_id", 2057 + "type": "integer", 2058 + "primaryKey": false, 2059 + "notNull": true, 2060 + "autoincrement": false 2061 + }, 2062 + "maintenance_id": { 2063 + "name": "maintenance_id", 2064 + "type": "integer", 2065 + "primaryKey": false, 2066 + "notNull": true, 2067 + "autoincrement": false 2068 + }, 2069 + "created_at": { 2070 + "name": "created_at", 2071 + "type": "integer", 2072 + "primaryKey": false, 2073 + "notNull": false, 2074 + "autoincrement": false, 2075 + "default": "(strftime('%s', 'now'))" 2076 + } 2077 + }, 2078 + "indexes": {}, 2079 + "foreignKeys": { 2080 + "maintenance_to_monitor_monitor_id_monitor_id_fk": { 2081 + "name": "maintenance_to_monitor_monitor_id_monitor_id_fk", 2082 + "tableFrom": "maintenance_to_monitor", 2083 + "tableTo": "monitor", 2084 + "columnsFrom": [ 2085 + "monitor_id" 2086 + ], 2087 + "columnsTo": [ 2088 + "id" 2089 + ], 2090 + "onDelete": "cascade", 2091 + "onUpdate": "no action" 2092 + }, 2093 + "maintenance_to_monitor_maintenance_id_maintenance_id_fk": { 2094 + "name": "maintenance_to_monitor_maintenance_id_maintenance_id_fk", 2095 + "tableFrom": "maintenance_to_monitor", 2096 + "tableTo": "maintenance", 2097 + "columnsFrom": [ 2098 + "maintenance_id" 2099 + ], 2100 + "columnsTo": [ 2101 + "id" 2102 + ], 2103 + "onDelete": "cascade", 2104 + "onUpdate": "no action" 2105 + } 2106 + }, 2107 + "compositePrimaryKeys": { 2108 + "maintenance_to_monitor_monitor_id_maintenance_id_pk": { 2109 + "columns": [ 2110 + "maintenance_id", 2111 + "monitor_id" 2112 + ], 2113 + "name": "maintenance_to_monitor_monitor_id_maintenance_id_pk" 2114 + } 2115 + }, 2116 + "uniqueConstraints": {} 2117 + } 2118 + }, 2119 + "enums": {}, 2120 + "_meta": { 2121 + "schemas": {}, 2122 + "tables": {}, 2123 + "columns": {} 2124 + } 2125 + }
+8 -1
packages/db/drizzle/meta/_journal.json
··· 218 218 "when": 1716364430118, 219 219 "tag": "0030_elite_barracuda", 220 220 "breakpoints": true 221 + }, 222 + { 223 + "idx": 31, 224 + "version": "6", 225 + "when": 1717837961923, 226 + "tag": "0031_lowly_gabe_jones", 227 + "breakpoints": true 221 228 } 222 229 ] 223 - } 230 + }
+1
packages/db/src/schema/index.ts
··· 12 12 export * from "./incidents"; 13 13 export * from "./monitor_tags"; 14 14 export * from "./applications"; 15 + export * from "./maintenances";
+2
packages/db/src/schema/maintenances/index.ts
··· 1 + export * from "./maintenance"; 2 + export * from "./validation";
+70
packages/db/src/schema/maintenances/maintenance.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { 3 + integer, 4 + primaryKey, 5 + sqliteTable, 6 + text, 7 + } from "drizzle-orm/sqlite-core"; 8 + 9 + import { page } from "../pages"; 10 + import { workspace } from "../workspaces"; 11 + import { monitor } from "../monitors"; 12 + 13 + export const maintenance = sqliteTable("maintenance", { 14 + id: integer("id").primaryKey(), 15 + title: text("title", { length: 256 }).notNull(), 16 + message: text("message").notNull(), 17 + 18 + from: integer("from", { mode: "timestamp" }).notNull(), 19 + to: integer("to", { mode: "timestamp" }).notNull(), 20 + 21 + workspaceId: integer("workspace_id").references(() => workspace.id), 22 + pageId: integer("page_id").references(() => page.id), 23 + 24 + createdAt: integer("created_at", { mode: "timestamp" }).default( 25 + sql`(strftime('%s', 'now'))` 26 + ), 27 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 28 + sql`(strftime('%s', 'now'))` 29 + ), 30 + }); 31 + 32 + export const maintenanceRelations = relations(maintenance, ({ one, many }) => ({ 33 + maintenancesToMonitors: many(maintenancesToMonitors), 34 + workspace: one(workspace, { 35 + fields: [maintenance.workspaceId], 36 + references: [workspace.id], 37 + }), 38 + })); 39 + 40 + export const maintenancesToMonitors = sqliteTable( 41 + "maintenance_to_monitor", 42 + { 43 + monitorId: integer("monitor_id") 44 + .notNull() 45 + .references(() => monitor.id, { onDelete: "cascade" }), 46 + maintenanceId: integer("maintenance_id") 47 + .notNull() 48 + .references(() => maintenance.id, { onDelete: "cascade" }), 49 + createdAt: integer("created_at", { mode: "timestamp" }).default( 50 + sql`(strftime('%s', 'now'))` 51 + ), 52 + }, 53 + (t) => ({ 54 + pk: primaryKey(t.monitorId, t.maintenanceId), 55 + }) 56 + ); 57 + 58 + export const maintenancesToMonitorsRelations = relations( 59 + maintenancesToMonitors, 60 + ({ one }) => ({ 61 + monitor: one(monitor, { 62 + fields: [maintenancesToMonitors.monitorId], 63 + references: [monitor.id], 64 + }), 65 + page: one(maintenance, { 66 + fields: [maintenancesToMonitors.maintenanceId], 67 + references: [maintenance.id], 68 + }), 69 + }) 70 + );
+24
packages/db/src/schema/maintenances/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import { maintenance } from "./maintenance"; 3 + import { z } from "zod"; 4 + 5 + export const insertMaintenanceSchema = createInsertSchema(maintenance) 6 + .extend({ 7 + // REMINDER: trick to make the react-hook-form controlled but not allow empty string 8 + title: z.string().min(1, { message: "Required" }), 9 + message: z.string().min(1, { message: "Required" }), 10 + 11 + monitors: z.number().array().default([]).optional(), 12 + }) 13 + // REMINDER: validate that `from` date is before `to` date 14 + .refine((data) => data.from < data.to, { 15 + path: ["to"], 16 + message: "End date cannot be earlier than start date.", 17 + }); 18 + 19 + export const selectMaintenanceSchema = createSelectSchema(maintenance).extend({ 20 + monitors: z.number().array().default([]).optional(), 21 + }); 22 + 23 + export type InsertMaintenance = z.infer<typeof insertMaintenanceSchema>; 24 + export type Maintenance = z.infer<typeof selectMaintenanceSchema>;
+21 -3
packages/db/src/schema/shared.ts
··· 8 8 selectStatusReportUpdateSchema, 9 9 } from "./status_reports"; 10 10 import { workspacePlanSchema } from "./workspaces"; 11 + import { 12 + maintenancesToMonitors, 13 + selectMaintenanceSchema, 14 + } from "./maintenances"; 11 15 12 16 // TODO: create a 'public-status' schema with all the different types and validations 13 17 ··· 25 29 monitorId: z.number(), 26 30 statusReportId: z.number(), 27 31 monitor: selectPublicMonitorSchema, 28 - }), 32 + }) 29 33 ) 30 34 .default([]), 31 35 }); 32 36 37 + export const selectMaintenancePageSchema = selectMaintenanceSchema.extend({ 38 + maintenancesToMonitors: z 39 + .array( 40 + z.object({ 41 + monitorId: z.number(), 42 + maintenanceId: z.number(), 43 + }) 44 + ) 45 + .default([]), 46 + }); 47 + // TODO: it would be nice to automatically add the monitor relation here 48 + // .refine((data) => ({ monitors: data.maintenancesToMonitors.map((m) => m.monitorId) })); 49 + 33 50 export const selectPageSchemaWithRelation = selectPageSchema.extend({ 34 51 monitors: z.array(selectMonitorSchema), 35 52 statusReports: z.array(selectStatusReportPageSchema), ··· 42 59 pageId: z.number(), 43 60 order: z.number().default(0).optional(), 44 61 monitor: selectMonitorSchema, 45 - }), 62 + }) 46 63 ), 47 64 }); 48 65 ··· 51 68 monitors: z.array(selectPublicMonitorSchema), 52 69 statusReports: z.array(selectStatusReportPageSchema), 53 70 incidents: z.array(selectIncidentSchema), 71 + maintenances: z.array(selectMaintenancePageSchema), 54 72 workspacePlan: workspacePlanSchema 55 73 .nullable() 56 74 .default("free") ··· 69 87 monitorId: z.number(), 70 88 statusReportId: z.number(), 71 89 monitor: selectPublicMonitorSchema, 72 - }), 90 + }) 73 91 ) 74 92 .default([]), 75 93 statusReportUpdates: z.array(selectStatusReportUpdateSchema),
+4
packages/plans/src/config.ts
··· 22 22 "multi-region": true, 23 23 "data-retention": "14 days", 24 24 "status-pages": 1, 25 + maintenance: true, 25 26 "status-subscribers": false, 26 27 "custom-domain": false, 27 28 "password-protection": false, ··· 43 44 "multi-region": true, 44 45 "data-retention": "3 months", 45 46 "status-pages": 1, 47 + maintenance: true, 46 48 "status-subscribers": true, 47 49 "custom-domain": true, 48 50 "password-protection": true, ··· 64 66 "multi-region": true, 65 67 "data-retention": "12 months", 66 68 "status-pages": 5, 69 + maintenance: true, 67 70 "status-subscribers": true, 68 71 "custom-domain": true, 69 72 "password-protection": true, ··· 85 88 "multi-region": true, 86 89 "data-retention": "24 months", 87 90 "status-pages": 20, 91 + maintenance: true, 88 92 "status-subscribers": true, 89 93 "custom-domain": true, 90 94 "password-protection": true,
+4
packages/plans/src/pricing-table.ts
··· 33 33 label: "Number of status pages", 34 34 }, 35 35 { 36 + value: "maintenance", 37 + label: "Maintenance status", 38 + }, 39 + { 36 40 value: "status-subscribers", 37 41 label: "Subscribers", 38 42 },
+1
packages/plans/src/types.ts
··· 8 8 "data-retention": string; 9 9 // status pages 10 10 "status-pages": number; 11 + maintenance: boolean; 11 12 "status-subscribers": boolean; 12 13 "custom-domain": boolean; 13 14 "password-protection": boolean;
+1 -1
packages/react/src/utils.ts
··· 30 30 }, 31 31 under_maintenance: { 32 32 label: "Under Maintenance", 33 - color: "bg-gray-500", 33 + color: "bg-blue-500", 34 34 }, 35 35 } as const;
+2 -1
packages/tracker/src/config.ts
··· 25 25 [Status.UnderMaintenance]: { 26 26 long: "Under Maintenance", 27 27 short: "Maintenance", 28 - variant: "empty", 28 + variant: "maintenance", 29 29 }, 30 30 [Status.Unknown]: { 31 31 long: "Unknown", ··· 46 46 down: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 47 47 empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 48 48 incident: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 49 + maintenance: "bg-blue-500/90 data-[state=open]:bg-blue-500 border-blue-500", 49 50 };
+38 -6
packages/tracker/src/tracker.ts
··· 1 1 import type { 2 2 Incident, 3 + Maintenance, 3 4 StatusReport, 4 5 StatusReportUpdate, 5 6 } from "@openstatus/db/src/schema"; ··· 16 17 statusReportUpdates?: StatusReportUpdate[]; 17 18 })[]; 18 19 type Incidents = Incident[]; 20 + type Maintenances = Maintenance[]; 19 21 20 22 /** 21 23 * Tracker Class is supposed to handle the data and calculate from a single monitor. ··· 27 29 private data: Monitors = []; 28 30 private statusReports: StatusReports = []; 29 31 private incidents: Incidents = []; 32 + private maintenances: Maintenances = []; 30 33 31 34 constructor(arg: { 32 35 data?: Monitors; 33 36 statusReports?: StatusReports; 34 37 incidents?: Incidents; 38 + maintenances?: Maintenance[]; 35 39 }) { 36 40 this.data = arg.data || []; // TODO: use another Class to handle a single Day 37 41 this.statusReports = arg.statusReports || []; 38 42 this.incidents = arg.incidents || []; 43 + this.maintenances = arg.maintenances || []; 39 44 } 40 45 41 46 private calculateUptime(data: { ok: number; count: number }[]) { ··· 51 56 prev.count += curr.count; 52 57 return prev; 53 58 }, 54 - { count: 0, ok: 0 }, 59 + { count: 0, ok: 0 } 55 60 ); 56 61 } 57 62 ··· 75 80 private isOngoingReport() { 76 81 const resolved: StatusReport["status"][] = ["monitoring", "resolved"]; 77 82 return this.statusReports.some( 78 - (report) => !resolved.includes(report.status), 83 + (report) => !resolved.includes(report.status) 79 84 ); 80 85 } 81 86 87 + private isOngoingMaintenance() { 88 + return this.maintenances.some((maintenance) => { 89 + const now = new Date(); 90 + return ( 91 + new Date(maintenance.from).getTime() <= now.getTime() && 92 + new Date(maintenance.to).getTime() >= now.getTime() 93 + ); 94 + }); 95 + } 96 + 82 97 get totalUptime(): number { 83 98 return this.calculateUptime(this.data); 84 99 } 85 100 86 101 get currentStatus(): Status { 102 + if (this.isOngoingMaintenance()) return Status.UnderMaintenance; 87 103 if (this.isOngoingReport()) return Status.DegradedPerformance; 88 104 if (this.isOngoingIncident()) return Status.Incident; 89 105 return this.calculateUptimeStatus(this.data); ··· 136 152 private getStatusReportsByDay(props: Monitor): StatusReports { 137 153 const statusReports = this.statusReports?.filter((report) => { 138 154 const firstStatusReportUpdate = report?.statusReportUpdates?.sort( 139 - (a, b) => a.date.getTime() - b.date.getTime(), 155 + (a, b) => a.date.getTime() - b.date.getTime() 140 156 )?.[0]; 141 157 142 158 if (!firstStatusReportUpdate) return false; ··· 147 163 return statusReports; 148 164 } 149 165 166 + private getMaintenancesByDay(day: Date): Maintenances { 167 + const maintenances = this.maintenances.filter((maintenance) => { 168 + const eod = endOfDay(day); 169 + const sod = startOfDay(day); 170 + return ( 171 + maintenance.from.getTime() <= eod.getTime() && 172 + maintenance.to.getTime() >= sod.getTime() 173 + ); 174 + }); 175 + return maintenances; 176 + } 177 + 150 178 // TODO: it would be great to create a class to handle a single day 151 179 // FIXME: will be always generated on each tracker.days call - needs to be in the constructor? 152 180 get days() { ··· 155 183 const blacklist = isInBlacklist(day); 156 184 const incidents = this.getIncidentsByDay(day); 157 185 const statusReports = this.getStatusReportsByDay(props); 186 + const maintenances = this.getMaintenancesByDay(day); 158 187 159 188 const isMissingData = props.count === 0; 160 189 161 190 // FIXME: 162 - const status = incidents.length 191 + const status = maintenances.length 192 + ? Status.UnderMaintenance 193 + : incidents.length 163 194 ? Status.Incident 164 195 : isMissingData 165 - ? Status.Unknown 166 - : this.calculateUptimeStatus([props]); 196 + ? Status.Unknown 197 + : this.calculateUptimeStatus([props]); 167 198 168 199 const variant = statusDetails[status].variant; 169 200 const label = statusDetails[status].short; ··· 173 204 blacklist, 174 205 incidents, 175 206 statusReports, 207 + maintenances, 176 208 status, 177 209 variant, 178 210 label: isMissingData ? "Missing" : label,
+9 -2
packages/tracker/src/types.ts
··· 6 6 DegradedPerformance = "degraded_performance", 7 7 PartialOutage = "partial_outage", 8 8 MajorOutage = "major_outage", 9 - UnderMaintenance = "under_maintenance", // not used 9 + UnderMaintenance = "under_maintenance", 10 10 Unknown = "unknown", 11 11 Incident = "incident", 12 12 } 13 13 14 - export type StatusVariant = "up" | "degraded" | "down" | "empty" | "incident"; 14 + // TODO: duplicate to `Status` enum above 15 + export type StatusVariant = 16 + | "up" 17 + | "degraded" 18 + | "down" 19 + | "empty" 20 + | "incident" 21 + | "maintenance"; 15 22 16 23 export type StatusDetails = { 17 24 long: string;