Openstatus www.openstatus.dev

chore: post-maintenance visual improvements on dashboard (#869)

* fix: hidden status page header

* chore: improve visibility of maintenance on monitors

* chore: improve visibility on dashboard status page section

authored by

Maximilian Kaske and committed by
GitHub
bf72eb6b 94bc780e

+187 -56
+14 -9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 31 31 if (v === "true") return true; 32 32 if (v === "false") return false; 33 33 return undefined; 34 - }), 34 + }) 35 35 ) 36 36 .optional(), 37 37 }); ··· 61 61 /> 62 62 ); 63 63 64 - const _incidents = await api.incident.getIncidentsByWorkspace.query(); 64 + const _incidents = await api.incident.getIncidentsByWorkspace.query(); // TODO: filter by last 7 days 65 65 const tags = await api.monitorTag.getMonitorTagsByWorkspace.query(); 66 + const _maintenances = await api.maintenance.getLast7DaysByWorkspace.query(); 66 67 67 68 // maybe not very efficient? 68 69 // use Suspense and Client call instead? ··· 72 73 { 73 74 monitorId: String(monitor.id), 74 75 }, 75 - { cache: "no-store", revalidate: 0 }, 76 + { cache: "no-store", revalidate: 0 } 76 77 ); 77 78 78 79 const data = await tb.endpointStatusPeriod("7d")( 79 80 { 80 81 monitorId: String(monitor.id), 81 82 }, 82 - { cache: "no-store", revalidate: 0 }, 83 + { cache: "no-store", revalidate: 0 } 83 84 ); 84 85 85 86 const [current] = metrics?.sort((a, b) => 86 - (a.lastTimestamp || 0) - (b.lastTimestamp || 0) < 0 ? 1 : -1, 87 + (a.lastTimestamp || 0) - (b.lastTimestamp || 0) < 0 ? 1 : -1 87 88 ) || [undefined]; 88 89 89 90 const incidents = _incidents.filter( 90 - (incident) => incident.monitorId === monitor.id, 91 + (incident) => incident.monitorId === monitor.id 91 92 ); 92 93 93 94 const tags = monitor.monitorTagsToMonitors.map( 94 - ({ monitorTag }) => monitorTag, 95 + ({ monitorTag }) => monitorTag 96 + ); 97 + 98 + const maintenances = _maintenances.filter((maintenance) => 99 + maintenance.monitors.includes(monitor.id) 95 100 ); 96 101 97 - return { monitor, metrics: current, data, incidents, tags }; 98 - }), 102 + return { monitor, metrics: current, data, incidents, maintenances, tags }; 103 + }) 99 104 ); 100 105 101 106 return (
+2 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/layout.tsx
··· 36 36 <StatusDotWithTooltip 37 37 active={monitor.active} 38 38 status={monitor.status} 39 + maintenance={monitor.maintenance} 39 40 /> 40 41 {monitor.monitorTagsToMonitors.length > 0 ? ( 41 42 <> 42 43 <span className="text-muted-foreground/50 text-xs">•</span> 43 44 <TagBadgeWithTooltip 44 45 tags={monitor.monitorTagsToMonitors.map( 45 - ({ monitorTag }) => monitorTag, 46 + ({ monitorTag }) => monitorTag 46 47 )} 47 48 /> 48 49 </>
-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; 50 49 return ( 51 50 <TabsLink key={segment} {...{ active, href, label, disabled }}> 52 51 {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; 57 56 return ( 58 57 <li key={href} className="w-full"> 59 58 <AppLink {...{ href, label, active, disabled }} />
+24
apps/web/src/components/data-table/maintenance/columns.tsx
··· 5 5 import type { Maintenance } from "@openstatus/db/src/schema"; 6 6 7 7 import { DataTableRowActions } from "./data-table-row-actions"; 8 + import { formatDateTime } from "@/lib/utils"; 9 + import { format } from "date-fns"; 8 10 9 11 export const columns: ColumnDef<Maintenance>[] = [ 10 12 { ··· 18 20 return ( 19 21 <p className="flex max-w-[125px] lg:max-w-[250px] xl:max-w-[350px]"> 20 22 <span className="truncate">{row.getValue("message")}</span> 23 + </p> 24 + ); 25 + }, 26 + }, 27 + { 28 + accessorKey: "from", 29 + header: "Start", 30 + cell: ({ row }) => { 31 + return ( 32 + <p className="text-muted-foreground"> 33 + {format(row.getValue("from"), "LLL dd, y HH:mm zzzz")} 34 + </p> 35 + ); 36 + }, 37 + }, 38 + { 39 + accessorKey: "to", 40 + header: "End", 41 + cell: ({ row }) => { 42 + return ( 43 + <p className="text-muted-foreground"> 44 + {format(row.getValue("to"), "LLL dd, y HH:mm zzzz")} 21 45 </p> 22 46 ); 23 47 },
+17 -3
apps/web/src/components/data-table/monitor/columns.tsx
··· 4 4 import { formatDistanceToNowStrict } from "date-fns"; 5 5 import Link from "next/link"; 6 6 7 - import type { Incident, Monitor, MonitorTag } from "@openstatus/db/src/schema"; 7 + import type { 8 + Incident, 9 + Maintenance, 10 + Monitor, 11 + MonitorTag, 12 + } from "@openstatus/db/src/schema"; 8 13 import type { 9 14 Monitor as MonitorTracker, 10 15 ResponseTimeMetrics, ··· 21 26 import { StatusDotWithTooltip } from "@/components/monitor/status-dot-with-tooltip"; 22 27 import { TagBadgeWithTooltip } from "@/components/monitor/tag-badge-with-tooltip"; 23 28 import { Bar } from "@/components/tracker/tracker"; 29 + import { isActiveMaintenance } from "@/lib/maintenances/utils"; 30 + 24 31 import { DataTableRowActions } from "./data-table-row-actions"; 25 32 26 33 export const columns: ColumnDef<{ ··· 28 35 metrics?: ResponseTimeMetrics; 29 36 data?: MonitorTracker[]; 30 37 incidents?: Incident[]; 38 + maintenances?: Maintenance[]; 31 39 tags?: MonitorTag[]; 32 40 }>[] = [ 33 41 { ··· 36 44 header: "Name", 37 45 cell: ({ row }) => { 38 46 const { active, status, name, public: _public } = row.original.monitor; 47 + const maintenance = isActiveMaintenance(row.original.maintenances); 39 48 return ( 40 49 <div className="flex gap-2"> 41 50 <Link 42 51 href={`./monitors/${row.original.monitor.id}/overview`} 43 52 className="group flex max-w-[150px] items-center gap-2 md:max-w-[250px]" 44 53 > 45 - <StatusDotWithTooltip active={active} status={status} /> 54 + <StatusDotWithTooltip 55 + active={active} 56 + status={status} 57 + maintenance={maintenance} 58 + /> 46 59 <span className="truncate group-hover:underline">{name}</span> 47 60 </Link> 48 61 {_public ? <Badge variant="secondary">public</Badge> : null} ··· 71 84 // REMINDER: if one value is found, return true 72 85 // we could consider restricting it to all the values have to be found 73 86 return value.some((item) => 74 - row.original.tags?.some((tag) => tag.name === item), 87 + row.original.tags?.some((tag) => tag.name === item) 75 88 ); 76 89 }, 77 90 }, ··· 84 97 const tracker = new Tracker({ 85 98 data: row.original.data?.slice(0, 7).reverse(), 86 99 incidents: row.original.incidents, 100 + maintenances: row.original.maintenances, 87 101 }); 88 102 return ( 89 103 <div className="flex w-24 gap-1">
+8 -2
apps/web/src/components/data-table/status-page/columns.tsx
··· 5 5 import Link from "next/link"; 6 6 import * as z from "zod"; 7 7 8 - import type { Page } from "@openstatus/db/src/schema"; 8 + import type { Maintenance, Page } from "@openstatus/db/src/schema"; 9 9 import { 10 10 Badge, 11 11 Tooltip, ··· 18 18 import { Check } from "lucide-react"; 19 19 20 20 export const columns: ColumnDef< 21 - Page & { monitorsToPages: { monitor: { name: string } }[] } 21 + Page & { 22 + monitorsToPages: { monitor: { name: string } }[]; 23 + maintenancesToPages: Maintenance[]; // we get only the active maintenances! 24 + } 22 25 >[] = [ 23 26 { 24 27 accessorKey: "title", ··· 32 35 <span className="max-w-[125px] truncate group-hover:underline"> 33 36 {row.getValue("title")} 34 37 </span> 38 + {row.original.maintenancesToPages.length > 0 ? ( 39 + <Badge>Maintenance</Badge> 40 + ) : null} 35 41 </Link> 36 42 ); 37 43 },
+6 -1
apps/web/src/components/forms/maintenance/general.tsx
··· 17 17 } from "@openstatus/ui"; 18 18 19 19 import { SectionHeader } from "../shared/section-header"; 20 + import { format } from "date-fns"; 20 21 21 22 interface Props { 22 23 form: UseFormReturn<InsertMaintenance>; ··· 101 102 )} 102 103 /> 103 104 <FormDescription className="sm:-mt-2 col-span-full"> 104 - The date and time when the incident took place. 105 + The period{" "} 106 + <span className="font-medium"> 107 + in local time {format(new Date(), "z")} 108 + </span>{" "} 109 + when the maintenance takes place. 105 110 </FormDescription> 106 111 </div> 107 112 </div>
+9 -10
apps/web/src/components/monitor/status-dot-with-tooltip.tsx
··· 10 10 11 11 export interface StatusDotWithTooltipProps extends StatusDotProps {} 12 12 13 - export function StatusDotWithTooltip({ 14 - status, 15 - active, 16 - }: StatusDotWithTooltipProps) { 13 + export function StatusDotWithTooltip(props: StatusDotWithTooltipProps) { 14 + const { active, maintenance, status } = props; 17 15 return ( 18 16 <TooltipProvider delayDuration={50}> 19 17 <Tooltip> 20 18 <TooltipTrigger> 21 - <StatusDot {...{ status, active }} /> 19 + <StatusDot {...props} /> 22 20 </TooltipTrigger> 23 21 <TooltipContent> 24 - {active 25 - ? status === "active" 26 - ? "Monitor is active" 27 - : "Monitor has failed" 28 - : "Monitor is inactive"} 22 + {(() => { 23 + if (!active) return "Monitor is inactive"; 24 + if (maintenance) return "Monitor in maintenance"; 25 + if (status === "error") return "Monitor has failed"; 26 + return "Monitor is active"; 27 + })()} 29 28 </TooltipContent> 30 29 </Tooltip> 31 30 </TooltipProvider>
+23 -8
apps/web/src/components/monitor/status-dot.tsx
··· 1 1 import type { Monitor } from "@openstatus/db/src/schema"; 2 2 3 - export interface StatusDotProps extends Pick<Monitor, "active" | "status"> {} 3 + export interface StatusDotProps { 4 + active?: Monitor["active"]; 5 + status?: Monitor["status"]; 6 + maintenance?: boolean; 7 + } 4 8 5 - export function StatusDot({ active, status }: StatusDotProps) { 9 + export function StatusDot({ active, status, maintenance }: StatusDotProps) { 6 10 if (!active) { 7 11 return ( 8 12 <span className="relative flex h-2 w-2"> ··· 10 14 </span> 11 15 ); 12 16 } 17 + if (maintenance) { 18 + return ( 19 + <span className="relative flex h-2 w-2"> 20 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500/80 opacity-75 duration-1000" /> 21 + <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" /> 22 + </span> 23 + ); 24 + } 25 + if (status === "error") { 26 + return ( 27 + <span className="relative flex h-2 w-2"> 28 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-1000" /> 29 + <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 30 + </span> 31 + ); 32 + } 13 33 14 - return status === "active" ? ( 34 + return ( 15 35 <span className="relative flex h-2 w-2"> 16 36 <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-1000" /> 17 37 <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> 18 - </span> 19 - ) : ( 20 - <span className="relative flex h-2 w-2"> 21 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-1000" /> 22 - <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 23 38 </span> 24 39 ); 25 40 }
+8
apps/web/src/lib/maintenances/utils.ts
··· 1 + import type { Maintenance } from "@openstatus/db/src/schema"; 2 + 3 + export function isActiveMaintenance(maintenances?: Maintenance[]) { 4 + if (!maintenances) return false; 5 + return maintenances.some((maintenance) => { 6 + return maintenance.from <= new Date() && maintenance.to >= new Date(); 7 + }); 8 + }
+28 -1
packages/api/src/router/maintenance.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { and, eq, inArray } from "@openstatus/db"; 3 + import { and, eq, gte, inArray, lte } from "@openstatus/db"; 4 4 import { 5 5 insertMaintenanceSchema, 6 6 maintenance, ··· 67 67 .all(); 68 68 return _maintenances; 69 69 }), 70 + getLast7DaysByWorkspace: protectedProcedure.query(async (opts) => { 71 + const _maintenances = await opts.ctx.db.query.maintenance.findMany({ 72 + where: and( 73 + eq(maintenance.workspaceId, opts.ctx.workspace.id), 74 + gte(maintenance.from, new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) 75 + ), 76 + with: { maintenancesToMonitors: true }, 77 + }); 78 + return _maintenances.map((m) => ({ 79 + ...m, 80 + monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 81 + })); 82 + }), 70 83 getByPage: protectedProcedure 71 84 .input(z.object({ id: z.number() })) 72 85 .query(async (opts) => { ··· 83 96 // TODO: 84 97 return _maintenances; 85 98 }), 99 + getActiveByWorkspace: protectedProcedure.query(async (opts) => { 100 + const _maintenances = await opts.ctx.db 101 + .select() 102 + .from(maintenance) 103 + .where( 104 + and( 105 + eq(maintenance.workspaceId, opts.ctx.workspace.id), 106 + gte(maintenance.to, new Date()), 107 + lte(maintenance.from, new Date()) 108 + ) 109 + ) 110 + .all(); 111 + return _maintenances; 112 + }), 86 113 update: protectedProcedure 87 114 .input(insertMaintenanceSchema) 88 115 .mutation(async (opts) => {
+15 -1
packages/api/src/router/monitor.ts
··· 10 10 import { and, eq, inArray, isNull, sql } from "@openstatus/db"; 11 11 import { 12 12 insertMonitorSchema, 13 + maintenancesToMonitors, 13 14 monitor, 14 15 monitorTag, 15 16 monitorTagsToMonitors, ··· 18 19 notification, 19 20 notificationsToMonitors, 20 21 page, 22 + selectMaintenanceSchema, 21 23 selectMonitorSchema, 22 24 selectMonitorTagSchema, 23 25 selectNotificationSchema, ··· 167 169 ), 168 170 with: { 169 171 monitorTagsToMonitors: { with: { monitorTag: true } }, 172 + maintenancesToMonitors: { 173 + with: { maintenance: true }, 174 + where: eq(maintenancesToMonitors.monitorId, opts.input.id), 175 + }, 170 176 }, 171 177 }); 172 178 ··· 177 183 monitorTag: selectMonitorTagSchema, 178 184 }) 179 185 .array(), 186 + maintenance: z.boolean().default(false).optional(), 180 187 }) 181 - .safeParse(_monitor); 188 + .safeParse({ 189 + ..._monitor, 190 + maintenance: _monitor?.maintenancesToMonitors.some( 191 + (item) => 192 + item.maintenance.from.getTime() <= Date.now() && 193 + item.maintenance.to.getTime() >= Date.now() 194 + ), 195 + }); 182 196 183 197 if (!parsedMonitor.success) { 184 198 throw new TRPCError({
+7 -1
packages/api/src/router/page.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 - import { and, eq, inArray, isNull, or, sql } from "@openstatus/db"; 4 + import { and, eq, gte, inArray, isNull, lte, or, sql } from "@openstatus/db"; 5 5 import { 6 6 incidentTable, 7 7 insertPageSchema, ··· 213 213 where: and(eq(page.workspaceId, opts.ctx.workspace.id)), 214 214 with: { 215 215 monitorsToPages: { with: { monitor: true } }, 216 + maintenancesToPages: { 217 + where: and( 218 + lte(maintenance.from, new Date()), 219 + gte(maintenance.to, new Date()) 220 + ), 221 + }, 216 222 }, 217 223 }); 218 224 return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages);
+13 -9
packages/db/src/schema/maintenances/maintenance.ts
··· 29 29 ), 30 30 }); 31 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 32 export const maintenancesToMonitors = sqliteTable( 41 33 "maintenance_to_monitor", 42 34 { ··· 62 54 fields: [maintenancesToMonitors.monitorId], 63 55 references: [monitor.id], 64 56 }), 65 - page: one(maintenance, { 57 + maintenance: one(maintenance, { 66 58 fields: [maintenancesToMonitors.maintenanceId], 67 59 references: [maintenance.id], 68 60 }), 69 61 }) 70 62 ); 63 + 64 + export const maintenanceRelations = relations(maintenance, ({ one, many }) => ({ 65 + maintenancesToMonitors: many(maintenancesToMonitors), 66 + page: one(page, { 67 + fields: [maintenance.pageId], 68 + references: [page.id], 69 + }), 70 + workspace: one(workspace, { 71 + fields: [maintenance.workspaceId], 72 + references: [workspace.id], 73 + }), 74 + }));
+7 -5
packages/db/src/schema/monitors/monitor.ts
··· 17 17 monitorPeriodicity, 18 18 monitorStatus, 19 19 } from "./constants"; 20 + import { maintenancesToMonitors } from "../maintenances"; 20 21 21 22 export const monitor = sqliteTable("monitor", { 22 23 id: integer("id").primaryKey(), ··· 46 47 public: integer("public", { mode: "boolean" }).default(false), 47 48 48 49 createdAt: integer("created_at", { mode: "timestamp" }).default( 49 - sql`(strftime('%s', 'now'))`, 50 + sql`(strftime('%s', 'now'))` 50 51 ), 51 52 updatedAt: integer("updated_at", { mode: "timestamp" }).default( 52 - sql`(strftime('%s', 'now'))`, 53 + sql`(strftime('%s', 'now'))` 53 54 ), 54 55 55 56 deletedAt: integer("deleted_at", { mode: "timestamp" }), ··· 64 65 references: [workspace.id], 65 66 }), 66 67 monitorsToNotifications: many(notificationsToMonitors), 68 + maintenancesToMonitors: many(maintenancesToMonitors), 67 69 })); 68 70 69 71 export const monitorsToPages = sqliteTable( ··· 76 78 .notNull() 77 79 .references(() => page.id, { onDelete: "cascade" }), 78 80 createdAt: integer("created_at", { mode: "timestamp" }).default( 79 - sql`(strftime('%s', 'now'))`, 81 + sql`(strftime('%s', 'now'))` 80 82 ), 81 83 order: integer("order").default(0), 82 84 }, 83 85 (t) => ({ 84 86 pk: primaryKey(t.monitorId, t.pageId), 85 - }), 87 + }) 86 88 ); 87 89 88 90 export const monitorsToPagesRelation = relations( ··· 96 98 fields: [monitorsToPages.pageId], 97 99 references: [page.id], 98 100 }), 99 - }), 101 + }) 100 102 );
+5 -3
packages/db/src/schema/pages/page.ts
··· 4 4 import { monitorsToPages } from "../monitors"; 5 5 import { pagesToStatusReports } from "../status_reports"; 6 6 import { workspace } from "../workspaces"; 7 + import { maintenance } from "../maintenances"; 7 8 8 9 export const page = sqliteTable("page", { 9 10 id: integer("id").primaryKey(), ··· 22 23 // Password protecting the status page - no specific restriction on password 23 24 password: text("password", { length: 256 }), 24 25 passwordProtected: integer("password_protected", { mode: "boolean" }).default( 25 - false, 26 + false 26 27 ), 27 28 28 29 createdAt: integer("created_at", { mode: "timestamp" }).default( 29 - sql`(strftime('%s', 'now'))`, 30 + sql`(strftime('%s', 'now'))` 30 31 ), 31 32 updatedAt: integer("updated_at", { mode: "timestamp" }).default( 32 - sql`(strftime('%s', 'now'))`, 33 + sql`(strftime('%s', 'now'))` 33 34 ), 34 35 }); 35 36 36 37 export const pageRelations = relations(page, ({ many, one }) => ({ 37 38 monitorsToPages: many(monitorsToPages), 38 39 pagesToStatusReports: many(pagesToStatusReports), 40 + maintenancesToPages: many(maintenance), 39 41 workspace: one(workspace, { 40 42 fields: [page.workspaceId], 41 43 references: [workspace.id],
+1
packages/db/src/schema/shared.ts
··· 61 61 monitor: selectMonitorSchema, 62 62 }) 63 63 ), 64 + maintenancesToPages: selectMaintenanceSchema.array().default([]), 64 65 }); 65 66 66 67 export const selectPublicPageSchemaWithRelation = selectPageSchema