diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index abe8c9406..f2e18d332 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -45,9 +45,11 @@ import { SectionHeaderRow, SectionTitle, } from "@/components/content/section"; +import { useMaintenanceTick } from "@/components/maintenances/use-maintenance-status"; import { type ActivitySpan, activityDayLines, + activityPeriod, activityRange, groupByDay, spansOnDay, @@ -173,7 +175,6 @@ export default function Page() { const hydrated = useHydrated(); const [showOlder, setShowOlder] = useState(false); - const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); // no period — an incident open for weeks must still surface here const { data: monitorIncidents } = useQuery({ @@ -193,10 +194,10 @@ export default function Page() { trpc.incident.list.queryOptions({ status: ["resolved", "canceled"] }), ); + // a window starting or ending moves it between Scheduled, Open and Resolved + useMaintenanceTick(maintenances ?? []); const now = new Date(); const data = - monitors && - pages && monitorIncidents && statusReports && maintenances && @@ -205,12 +206,10 @@ export default function Page() { !endedIncidentsPending ? buildOverviewData( { - monitors, - pages, monitorIncidents, statusReports, maintenances, - // undefined on a failed query — falls back to the downtime count + // undefined on a failed query; the rest still renders managedIncidents: openIncidents, endedIncidents, }, @@ -225,36 +224,18 @@ export default function Page() { return null; const { + events, needsAttention, upcomingMaintenances, recentlyResolved, allResolved, } = data; - const range = activityRange(now); - const events: OverviewEvent[] = [ - ...(openIncidents ?? []).map((incident) => ({ - type: "managedIncident" as const, - incident, - })), - ...(endedIncidents ?? []).map((incident) => ({ - type: "managedIncident" as const, - incident, - })), - ...statusReports.map((report) => ({ type: "report" as const, report })), - ...maintenances.map((maintenance) => ({ - type: "maintenance" as const, - maintenance, - })), - ...monitorIncidents.map((incident) => ({ - type: "incident" as const, - incident, - })), - ].filter((event) => { - // still running counts too - const { start, end } = spanOf(event); - return (end ?? now) >= range.from && start <= range.to; - }); + // looks ahead as far as the last scheduled window ends, capped + const until = maintenances + .map((m) => m.to) + .reduce((max, to) => (!max || to > max ? to : max), undefined); + const range = activityRange(now, until); const days = groupByDay(range, events, spanOf, now).map(({ date, items }) => { if (items.length === 0) return { date }; const [lead, ...others] = dayEntries(items, date, now); @@ -279,6 +260,7 @@ export default function Page() { links: [...links.values()], }; }); + const anyUpcoming = days.some((day) => day.upcoming); const attention = needsAttention.length; return ( @@ -307,7 +289,7 @@ export default function Page() { Activity - Last 12 months + {activityPeriod(range, now)} @@ -337,10 +319,12 @@ export default function Page() { Maintenance - - - Upcoming - + {anyUpcoming ? ( + + + Upcoming + + ) : null} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index 5269da2f0..973dae0d0 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -47,8 +47,14 @@ import { } from "@/components/content/section"; import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { MaintenanceRow } from "@/components/maintenances/maintenance-row"; +import { useMaintenanceTick } from "@/components/maintenances/use-maintenance-status"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; -import { activityRange, groupByDay, spansOnDay } from "@/data/activity.client"; +import { + activityPeriod, + activityRange, + groupByDay, + spansOnDay, +} from "@/data/activity.client"; import { maintenanceActivity, maintenanceSpan, @@ -95,6 +101,8 @@ export default function Page() { }), ); + // regroups when a window starts or ends, in step with each row's status + useMaintenanceTick(maintenances ?? []); const now = new Date(); const upcoming = (maintenances ?? []) .filter((m) => getMaintenanceStatus(m, now) !== "completed") @@ -126,7 +134,11 @@ export default function Page() { }); }; - const range = activityRange(now); + // looks ahead as far as the last scheduled window ends, capped + const until = upcoming + .map((m) => m.to) + .reduce((max, to) => (!max || to > max ? to : max), undefined); + const range = activityRange(now, until); const days = groupByDay(range, maintenances, maintenanceSpan, now).map( ({ date, items }) => { if (items.length === 0) return { date }; @@ -206,7 +218,7 @@ export default function Page() { Activity - Last 12 months + {activityPeriod(range, now)} @@ -226,11 +238,15 @@ export default function Page() { Over 4h - - - - Upcoming - + {days.some((day) => "upcoming" in day && day.upcoming) ? ( + <> + + + + Upcoming + + + ) : null} diff --git a/apps/dashboard/src/components/content/activity-calendar.tsx b/apps/dashboard/src/components/content/activity-calendar.tsx index d7e6d7a9a..2e3e6af31 100644 --- a/apps/dashboard/src/components/content/activity-calendar.tsx +++ b/apps/dashboard/src/components/content/activity-calendar.tsx @@ -91,6 +91,7 @@ export function ActivityCalendarCell({ const WEEKDAYS = ["Mon", "", "Wed", "", "Fri", "", ""]; const DAYS_PER_WEEK = 7; +const WEEK_MS = DAYS_PER_WEEK * 24 * 60 * 60 * 1000; // a month label needs this many columns so it never runs into the next one const MIN_MONTH_COLUMNS = 3; const STICKY = @@ -104,10 +105,8 @@ const ARROW_STEPS: Record = { }; /** - * Weeks as columns, Monday on top. `days` is consecutive; the first week is - * padded back to Monday. The grid ends today: later days stay blank unless - * something is scheduled. Dates and tooltips render after hydration so the - * server's timezone never leaks into the first paint. + * Weeks as columns, Monday on top; days after today are outlines unless scheduled. + * Dates render after hydration so the server's timezone never reaches the first paint. */ export function ActivityCalendarGrid({ days, @@ -139,12 +138,16 @@ export function ActivityCalendarGrid({ const from = (e.target as HTMLElement).closest("[data-index]"); if (!step || !from) return; e.preventDefault(); + const start = Number(from.dataset.index); + // up/down stay in the week's column + const row = (i: number) => (days[i].date.getDay() + 6) % DAYS_PER_WEEK; // blank future days aren't buttons: hop over them for ( - let i = Number(from.dataset.index) + step; + let i = start + step; i >= 0 && i < days.length; i += step ) { + if (Math.abs(step) === 1 && row(i) !== row(i - step) + step) return; const next = scroller.current?.querySelector( `button[data-index="${i}"]`, ); @@ -206,7 +209,7 @@ export function ActivityCalendarGrid({ hydrated={hydrated} tabbable={slot.index === current} // which of these is past today isn't known before hydration - thisWeek={slot.index >= days.length - DAYS_PER_WEEK} + recent={slot.day.date.getTime() > today.getTime() - WEEK_MS} /> ) : (
@@ -225,21 +228,32 @@ function ActivityCalendarDay({ today, hydrated, tabbable, - thisWeek, + recent, }: { day: ActivityDay; index: number; today: Date; hydrated: boolean; tabbable: boolean; - thisWeek: boolean; + recent: boolean; }) { if (!hydrated) { - return ; + return ; } - // after today only something scheduled shows, and always as dashed + // after today: empty days are a faint outline, scheduled ones dashed blue const future = day.date > today; - if (future && !day.upcoming && day.scale === undefined) return
; + // not a button, so arrow keys hop over it + if (future && !day.upcoming && day.scale === undefined) { + return ( +
+ ); + } const upcoming = day.upcoming || future; const date = format(day.date, "EEE, MMM d"); const lines = day.lines ?? []; diff --git a/apps/dashboard/src/components/content/event-list.tsx b/apps/dashboard/src/components/content/event-list.tsx index dc06a813b..563ffb58b 100644 --- a/apps/dashboard/src/components/content/event-list.tsx +++ b/apps/dashboard/src/components/content/event-list.tsx @@ -14,11 +14,8 @@ import { formatEventDate } from "@/lib/formatter"; import { cn } from "@/lib/utils"; /** - * Rows of events in a bordered list. A row is one line when the list is wide - * enough (date, status, title + meta, quick actions, actor, duration, menu) - * and folds to two below that: title above; status, date, meta and duration - * below. Quick actions hide when folded, the menu carries them. The switch is - * a container query on the list, so a sidebar narrowing the page folds it too. + * Event rows: one line when wide, two when narrow (quick actions move into the menu). + * A container query on the list, so a sidebar narrowing the page folds it too. */ export function EventList({ className, ...props }: React.ComponentProps<"ol">) { return ( diff --git a/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx index 001746175..7d9426bbd 100644 --- a/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx @@ -2,7 +2,6 @@ import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Row } from "@tanstack/react-table"; import { useState } from "react"; import { QuickActions } from "@/components/dropdowns/quick-actions"; @@ -16,10 +15,6 @@ import { type Incident = RouterOutputs["monitorIncident"]["list"][number]; -export function DataTableRowActions({ row }: { row: Row }) { - return ; -} - export function IncidentRowActions({ incident }: { incident: Incident }) { const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx index f58b6af8b..24eeafc05 100644 --- a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx @@ -2,7 +2,6 @@ import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Row } from "@tanstack/react-table"; import { useState } from "react"; import { QuickActions } from "@/components/dropdowns/quick-actions"; @@ -12,10 +11,6 @@ import { useTRPC } from "@/lib/trpc/client"; type Maintenance = RouterOutputs["maintenance"]["list"][number]; -export function DataTableRowActions({ row }: { row: Row }) { - return ; -} - /** Row menu; owns the edit sheet unless the row passes `onEdit` to open its own. */ export function MaintenanceRowActions({ maintenance, diff --git a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx index 0b168e90c..ec34c80f4 100644 --- a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx @@ -2,7 +2,6 @@ import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { Row } from "@tanstack/react-table"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getPageUrl } from "@/data/status-pages.client"; @@ -11,13 +10,7 @@ import { useTRPC } from "@/lib/trpc/client"; type StatusReport = RouterOutputs["statusReport"]["list"][number]; -// NOTE: avoid using useParams to get status page :id -// because we are using the table in the /overview page - -export function DataTableRowActions({ row }: { row: Row }) { - return ; -} - +// no useParams for the page :id: these rows also render on /overview export function StatusReportRowActions({ report, onAddUpdate, diff --git a/apps/dashboard/src/components/incidents/incident-row.tsx b/apps/dashboard/src/components/incidents/incident-row.tsx index d1ab3cae6..1b548a5ae 100644 --- a/apps/dashboard/src/components/incidents/incident-row.tsx +++ b/apps/dashboard/src/components/incidents/incident-row.tsx @@ -1,7 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { Acknowledge, Check, Close } from "@openstatus/icons"; +import { Acknowledge, Check, Close, Reset } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { personName } from "@openstatus/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -40,6 +40,14 @@ import { ConfirmCloseDialog } from "./confirm-close-dialog"; import { ResolveReportDialog } from "./resolve-report-dialog"; type Incident = NonNullable[number]; +type IncidentStatus = Incident["status"]; + +const transitionActions = { + mitigated: { label: "Mitigate", icon: Acknowledge }, + resolved: { label: "Resolve", icon: Check }, + open: { label: "Reopen", icon: Reset }, + canceled: { label: "Cancel", icon: Close }, +} satisfies Record; /** `kind` labels the row by type for a mixed list; the bars still carry the severity. */ export function IncidentRow({ @@ -110,8 +118,12 @@ export function IncidentRow({ : formatDurationShort( (endedAt ?? new Date()).getTime() - incident.startedAt.getTime(), ); - const transition = (next: "mitigated" | "resolved") => - setStatus.mutate({ id: incident.id, status: next }); + const allowed = new Set(incident.allowedTransitions); + // canceling closes the incident for good; ask first + const transition = (next: IncidentStatus) => + next === "canceled" + ? setConfirmCancel(true) + : setStatus.mutate({ id: incident.id, status: next }); return ( @@ -136,64 +148,35 @@ export function IncidentRow({ - {incident.status === "open" ? ( - - ) : null} - {open ? ( - - ) : null} + {(["mitigated", "resolved"] as const) + .filter((next) => allowed.has(next)) + .map((next) => ( + + ))} {duration} transition("mitigated"), - }, - ] - : []), - ...(open - ? [ - { - id: "resolve", - label: "Resolve", - icon: Check, - variant: "default" as const, - onClick: () => transition("resolved"), - }, - { - id: "cancel", - label: "Cancel", - icon: Close, - variant: "default" as const, - onClick: () => setConfirmCancel(true), - }, - ] - : []), - ]} + actions={( + Object.keys(transitionActions) as (keyof typeof transitionActions)[] + ) + .filter((next) => allowed.has(next)) + .map((next) => ({ + id: next, + ...transitionActions[next], + variant: "default" as const, + onClick: () => transition(next), + }))} deleteAction={ incident.deletable ? { diff --git a/apps/dashboard/src/components/maintenances/use-maintenance-status.ts b/apps/dashboard/src/components/maintenances/use-maintenance-status.ts index 650b5b8e2..f850463cd 100644 --- a/apps/dashboard/src/components/maintenances/use-maintenance-status.ts +++ b/apps/dashboard/src/components/maintenances/use-maintenance-status.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer } from "react"; import { type MaintenanceStatus, @@ -10,24 +10,28 @@ import { // setTimeout overflows past 2^31-1 ms and would fire immediately. const MAX_DELAY = 2 ** 31 - 1; +/** Re-renders when the next `from` or `to` passes. */ +export function useMaintenanceTick(maintenances: { from: Date; to: Date }[]) { + const [tick, rerender] = useReducer((n: number) => n + 1, 0); + const now = Date.now(); + const next = maintenances + .flatMap((m) => [m.from.getTime(), m.to.getTime()]) + .filter((t) => t > now) + .reduce((min, t) => Math.min(min, t), Number.POSITIVE_INFINITY); + + // tick re-arms a timer capped at MAX_DELAY, which fires before `next` moves + useEffect(() => { + if (next === Number.POSITIVE_INFINITY) return; + const timer = setTimeout(rerender, Math.min(next - Date.now(), MAX_DELAY)); + return () => clearTimeout(timer); + }, [next, tick]); +} + /** `getMaintenanceStatus`, re-evaluated when `from` or `to` passes. */ export function useMaintenanceStatus(maintenance: { from: Date; to: Date; }): MaintenanceStatus { - const [now, setNow] = useState(() => new Date()); - const from = maintenance.from.getTime(); - const to = maintenance.to.getTime(); - - useEffect(() => { - const next = [from, to].find((t) => t > now.getTime()); - if (next === undefined) return; - const timer = setTimeout( - () => setNow(new Date()), - Math.min(next - now.getTime(), MAX_DELAY), - ); - return () => clearTimeout(timer); - }, [from, to, now]); - - return getMaintenanceStatus(maintenance, now); + useMaintenanceTick([maintenance]); + return getMaintenanceStatus(maintenance, new Date()); } diff --git a/apps/dashboard/src/data/activity.client.test.ts b/apps/dashboard/src/data/activity.client.test.ts index a2cd7b57f..dbe25dcf3 100644 --- a/apps/dashboard/src/data/activity.client.test.ts +++ b/apps/dashboard/src/data/activity.client.test.ts @@ -2,14 +2,10 @@ import { differenceInCalendarDays } from "date-fns"; import { describe, expect, it } from "vitest"; import { + activityPeriod, activityRange, groupByDay, levelFor, - median, - monthsInRange, - mostCommon, - percentInRange, - spanInRange, spansOnDay, } from "./activity.client"; @@ -23,6 +19,28 @@ describe("activityRange", () => { expect(to >= now).toBe(true); expect(differenceInCalendarDays(to, from) + 1).toBe(53 * 7); }); + + it("extends to the week of `until`, at most 4 weeks ahead", () => { + const base = activityRange(now); + const inTwoWeeks = activityRange(now, new Date(2026, 10, 4)); + expect(inTwoWeeks.from).toEqual(base.from); + expect(differenceInCalendarDays(inTwoWeeks.to, base.to)).toBe(14); + const farOut = activityRange(now, new Date(2027, 5, 1)); + expect(differenceInCalendarDays(farOut.to, base.to)).toBe(28); + expect(activityRange(now, new Date(2026, 9, 21)).to).toEqual(base.to); + }); +}); + +describe("activityPeriod", () => { + it("names the lookahead only when there is one", () => { + expect(activityPeriod(activityRange(now), now)).toBe("Last 12 months"); + expect(activityPeriod(activityRange(now, new Date(2026, 9, 28)), now)).toBe( + "Last 12 months · next 1 week", + ); + expect(activityPeriod(activityRange(now, new Date(2026, 10, 4)), now)).toBe( + "Last 12 months · next 2 weeks", + ); + }); }); describe("groupByDay", () => { @@ -52,52 +70,12 @@ describe("groupByDay", () => { }); }); -describe("spanInRange", () => { - const range = { from: new Date(2026, 9, 1), to: new Date(2026, 9, 10) }; - - it("counts only the overlap", () => { - const ms = spanInRange(range, { - start: new Date(2026, 8, 30), - end: new Date(2026, 9, 2), - }); - expect(ms).toBe(86_400_000); - }); -}); - -describe("percentInRange / monthsInRange", () => { - const range = { from: new Date(2026, 0, 1), to: new Date(2026, 2, 1) }; - - it("clamps to the axis", () => { - expect(percentInRange(range, new Date(2025, 0, 1))).toBe(0); - expect(percentInRange(range, new Date(2027, 0, 1))).toBe(100); - expect(percentInRange(range, new Date(2026, 0, 1))).toBe(0); - }); - - it("lists month starts inside the range", () => { - expect(monthsInRange(range).map((m) => m.date.getMonth())).toEqual([ - 0, 1, 2, - ]); - }); -}); - describe("small stats", () => { it("levelFor", () => { expect(levelFor(1, [2, 4])).toBe(1); expect(levelFor(3, [2, 4])).toBe(2); expect(levelFor(4, [2, 4])).toBe(3); }); - - it("median", () => { - expect(median([])).toBeNull(); - expect(median([3, 1, 2])).toBe(2); - expect(median([4, 1, 2, 3])).toBe(2.5); - }); - - it("mostCommon", () => { - expect(mostCommon([])).toBeNull(); - expect(mostCommon(["a", "b", "b"])).toBe("b"); - expect(mostCommon([2, 1, 1])).toBe(1); - }); }); describe("day boundaries", () => { diff --git a/apps/dashboard/src/data/activity.client.ts b/apps/dashboard/src/data/activity.client.ts index e2ecd89c6..ce53c8aa2 100644 --- a/apps/dashboard/src/data/activity.client.ts +++ b/apps/dashboard/src/data/activity.client.ts @@ -1,7 +1,8 @@ import { addDays, + addWeeks, + differenceInCalendarWeeks, eachDayOfInterval, - eachMonthOfInterval, endOfWeek, format, startOfDay, @@ -13,15 +14,33 @@ export const ACTIVITY_WEEKS = 53; export type ActivityRange = { from: Date; to: Date }; -/** Monday-start weeks ending with the current one, so today sits in the last column. */ -export function activityRange(now = new Date()): ActivityRange { - const to = endOfWeek(now, { weekStartsOn: 1 }); - const from = startOfWeek(subWeeks(to, ACTIVITY_WEEKS - 1), { +/** Weeks the grid looks ahead for scheduled work, at most. */ +const ACTIVITY_WEEKS_AHEAD = 4; + +/** + * Monday-start weeks ending with the current one, so today sits in the last + * column; `until` extends it to that day's week, capped at ACTIVITY_WEEKS_AHEAD. + */ +export function activityRange(now = new Date(), until?: Date): ActivityRange { + const current = endOfWeek(now, { weekStartsOn: 1 }); + const from = startOfWeek(subWeeks(current, ACTIVITY_WEEKS - 1), { weekStartsOn: 1, }); + const cap = addWeeks(current, ACTIVITY_WEEKS_AHEAD); + const to = + until && until > current + ? endOfWeek(until < cap ? until : cap, { weekStartsOn: 1 }) + : current; return { from, to }; } +/** "Last 12 months", plus "· next 2 weeks" when the range looks ahead. */ +export function activityPeriod(range: ActivityRange, now = new Date()): string { + const ahead = differenceInCalendarWeeks(range.to, now, { weekStartsOn: 1 }); + if (ahead <= 0) return "Last 12 months"; + return `Last 12 months · next ${ahead} ${ahead === 1 ? "week" : "weeks"}`; +} + export type ActivitySpan = { start: Date; end: Date | null }; export type ActivityDayItems = { date: Date; items: T[] }; @@ -69,25 +88,6 @@ export function dayKey(date: Date): string { return format(date, "yyyy-MM-dd"); } -/** How long of a span falls inside the range (ms); an open span ends at `now`. */ -export function spanInRange( - range: ActivityRange, - { start, end }: ActivitySpan, - now = new Date(), -): number { - const first = Math.max(start.getTime(), range.from.getTime()); - const last = Math.min((end ?? now).getTime(), range.to.getTime()); - return Math.max(0, last - first); -} - -export function overlapsRange( - range: ActivityRange, - { start, end }: ActivitySpan, - now = new Date(), -): boolean { - return start <= range.to && (end ?? now) >= range.from; -} - /** * How much of `date`'s calendar day the spans cover (ms), overlaps counted * once; an open span ends at `now`. @@ -116,22 +116,6 @@ export function spansOnDay( return total; } -/** 0–100, clamped; where a date sits on an axis spanning the range. */ -export function percentInRange(range: ActivityRange, date: Date): number { - const total = range.to.getTime() - range.from.getTime(); - const at = ((date.getTime() - range.from.getTime()) / total) * 100; - return Math.min(100, Math.max(0, at)); -} - -/** First day of every month inside the range, with its axis position. */ -export function monthsInRange( - range: ActivityRange, -): { date: Date; at: number }[] { - return eachMonthOfInterval({ start: range.from, end: range.to }) - .filter((date) => date >= range.from) - .map((date) => ({ date, at: percentInRange(range, date) })); -} - /** 1–3 by thresholds: below the first, below the second, else the top. */ export function levelFor( value: number, @@ -142,28 +126,6 @@ export function levelFor( return 3; } -export function median(values: number[]): number | null { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -export function mostCommon(values: T[]): T | null { - const counts = new Map(); - let best: T | null = null; - let bestCount = 0; - for (const value of values) { - const count = (counts.get(value) ?? 0) + 1; - counts.set(value, count); - if (count > bestCount) { - best = value; - bestCount = count; - } - } - return best; -} - /** Tooltip lines under the date: the lead, then "+2 canceled" or "+3 more". */ export function activityDayLines(lead: string, others: string[]): string[] { if (others.length === 0) return [lead]; diff --git a/apps/dashboard/src/data/overview-events.client.test.ts b/apps/dashboard/src/data/overview-events.client.test.ts index f2450fa05..c1c2eff64 100644 --- a/apps/dashboard/src/data/overview-events.client.test.ts +++ b/apps/dashboard/src/data/overview-events.client.test.ts @@ -14,8 +14,6 @@ const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000); // safe because buildOverviewData only reads the fields set below function input(partial: Partial>): Input { return { - monitors: [], - pages: [], monitorIncidents: [], statusReports: [], maintenances: [], @@ -23,10 +21,6 @@ function input(partial: Partial>): Input { } as unknown as Input; } -function metric(data: ReturnType, title: string) { - return data.metrics.find((m) => m.title === title); -} - describe("getIncidentStatus", () => { it("prefers resolved over acknowledged", () => { expect( @@ -67,14 +61,9 @@ describe("buildOverviewData", () => { ); expect(data.needsAttention.map((e) => e.type)).toEqual(["incident"]); expect(data.recentlyResolved).toHaveLength(1); - expect(metric(data, "Open incidents")).toMatchObject({ - value: 1, - variant: "destructive", - href: undefined, - }); }); - it("counts managed incidents instead of monitor incidents when provided", () => { + it("lists managed incidents next to monitor incidents", () => { const data = buildOverviewData( { ...input({ @@ -95,10 +84,9 @@ describe("buildOverviewData", () => { }, now, ); - expect(metric(data, "Open incidents")).toMatchObject({ - value: 2, - href: "/incidents", - }); + expect( + data.needsAttention.filter((e) => e.type === "managedIncident"), + ).toHaveLength(2); expect( data.recentlyResolved.filter((e) => e.type === "managedIncident"), ).toHaveLength(1); @@ -114,10 +102,6 @@ describe("buildOverviewData", () => { expect(data.needsAttention).toEqual([]); expect(data.upcomingMaintenances).toHaveLength(1); expect(data.recentlyResolved).toHaveLength(1); - expect(metric(data, "Maintenances")).toMatchObject({ - value: 1, - variant: "info", - }); }); it("flags open status reports", () => { @@ -137,29 +121,19 @@ describe("buildOverviewData", () => { ); expect(data.needsAttention.map((e) => e.type)).toEqual(["report"]); expect(data.recentlyResolved.map((e) => e.type)).toEqual(["report"]); - expect(metric(data, "Open reports")).toMatchObject({ - value: 1, - variant: "warning", - }); }); - it("uses default variants when nothing is open", () => { + it("returns every event for the calendar", () => { const data = buildOverviewData( - input({ monitors: [{}, {}], pages: [{}] }), + input({ + monitorIncidents: [{ startedAt: daysAgo(1), resolvedAt: null }], + maintenances: [{ from: daysAgo(-1), to: daysAgo(-2) }], + }), now, ); - expect( - data.metrics.map(({ title, value, variant }) => ({ - title, - value, - variant, - })), - ).toEqual([ - { title: "Monitors", value: 2, variant: "default" }, - { title: "Status pages", value: 1, variant: "default" }, - { title: "Open incidents", value: 0, variant: "default" }, - { title: "Open reports", value: 0, variant: "default" }, - { title: "Maintenances", value: 0, variant: "default" }, + expect(data.events.map((e) => e.type)).toEqual([ + "maintenance", + "incident", ]); }); }); diff --git a/apps/dashboard/src/data/overview-events.client.ts b/apps/dashboard/src/data/overview-events.client.ts index 434fbe33a..8a45802a0 100644 --- a/apps/dashboard/src/data/overview-events.client.ts +++ b/apps/dashboard/src/data/overview-events.client.ts @@ -1,17 +1,13 @@ import type { RouterOutputs } from "@openstatus/api"; import { CloudOff, - Monitor, - type IconType, Report, - StatusPage, Incident as IncidentIcon, Maintenance as MaintenanceIcon, } from "@openstatus/icons"; import type { StatusVariant } from "@/components/common/status-dot"; import { incidentEndedAt } from "@/data/managed-incidents.client"; -import { getMonitorHealth } from "@/data/monitors.client"; import { reportResolvedAt, reportStartedAt, @@ -123,30 +119,16 @@ function isOpen(event: OverviewEvent, now: Date): boolean { } } -export type OverviewMetric = { - title: string; - value: number; - /** Mono aside after the value: "12 up · 1 down". */ - detail?: string; - href?: string; - variant: "default" | "destructive" | "warning" | "info"; - icon: IconType; -}; - export function buildOverviewData( { - monitors, - pages, monitorIncidents, statusReports, maintenances, managedIncidents, endedIncidents, }: { - monitors: RouterOutputs["monitor"]["list"]; - pages: RouterOutputs["page"]["list"]; monitorIncidents: MonitorIncident[]; - // replaces the downtime count; undefined while loading or on a failed query + // open/mitigated; undefined while loading or on a failed query managedIncidents?: RouterOutputs["incident"]["list"]; // resolved/canceled managed incidents — feed Recent Activity endedIncidents?: RouterOutputs["incident"]["list"]; @@ -157,11 +139,8 @@ export function buildOverviewData( ) { const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + // this order lists a calendar day's links: incidents and reports before downtime const events: OverviewEvent[] = [ - ...monitorIncidents.map((incident) => ({ - type: "incident" as const, - incident, - })), ...[...(managedIncidents ?? []), ...(endedIncidents ?? [])].map( (incident) => ({ type: "managedIncident" as const, incident }), ), @@ -170,6 +149,10 @@ export function buildOverviewData( type: "maintenance" as const, maintenance, })), + ...monitorIncidents.map((incident) => ({ + type: "incident" as const, + incident, + })), ]; const byStart = (a: OverviewEvent, b: OverviewEvent) => @@ -193,101 +176,15 @@ export function buildOverviewData( return resolvedAt !== null && resolvedAt >= sevenDaysAgo; }); - const openIncidentsCount = managedIncidents - ? managedIncidents.length - : monitorIncidents.filter((i) => !i.resolvedAt).length; - const openReports = statusReports.filter((r) => r.status !== "resolved"); - const activeMaintenances = maintenances.filter((m) => m.to >= now); - const runningMaintenances = activeMaintenances.filter((m) => m.from <= now); - const pagesWithReport = pages.filter((p) => p.hasActiveStatusReport).length; - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const incidentsIn30d = managedIncidents - ? [...managedIncidents, ...(endedIncidents ?? [])].filter( - (i) => i.declaredAt >= thirtyDaysAgo, - ).length - : monitorIncidents.filter((i) => i.startedAt >= thirtyDaysAgo).length; - const reportsIn30d = statusReports.filter( - (r) => reportStartedAt(r) >= thirtyDaysAgo, - ).length; - - const metrics: OverviewMetric[] = [ - { - title: "Monitors", - value: monitors.length, - detail: monitorHealthDetail(monitors), - href: "/monitors", - variant: "default", - icon: Monitor, - }, - { - title: "Status pages", - value: pages.length, - detail: - pages.length === 0 - ? undefined - : pagesWithReport === 0 - ? "all operational" - : `${pagesWithReport} with open report`, - href: "/status-pages", - variant: "default", - icon: StatusPage, - }, - { - title: "Open incidents", - value: openIncidentsCount, - detail: `${incidentsIn30d} in 30d`, - href: managedIncidents ? "/incidents" : undefined, - variant: openIncidentsCount > 0 ? "destructive" : "default", - icon: IncidentIcon, - }, - { - title: "Open reports", - value: openReports.length, - detail: `${reportsIn30d} in 30d`, - variant: openReports.length > 0 ? "warning" : "default", - icon: eventTypeConfig.report.icon, - }, - { - title: "Maintenances", - value: activeMaintenances.length, - detail: joinDetail([ - [runningMaintenances.length, "running"], - [activeMaintenances.length - runningMaintenances.length, "upcoming"], - ]), - variant: activeMaintenances.length > 0 ? "info" : "default", - icon: eventTypeConfig.maintenance.icon, - }, - ]; - return { + events, needsAttention, upcomingMaintenances, recentlyResolved, allResolved, - metrics, }; } -/** "12 up · 1 down · 2 paused"; zero counts drop out. */ -function monitorHealthDetail(monitors: RouterOutputs["monitor"]["list"]) { - const counts = { active: 0, degraded: 0, error: 0, inactive: 0 }; - for (const monitor of monitors) counts[getMonitorHealth(monitor)] += 1; - return joinDetail([ - [counts.active, "up"], - [counts.degraded, "degraded"], - [counts.error, "down"], - [counts.inactive, "paused"], - ]); -} - -function joinDetail(parts: [number, string][]): string | undefined { - const text = parts - .filter(([count]) => count > 0) - .map(([count, label]) => `${count} ${label}`) - .join(" · "); - return text || undefined; -} - export function eventKey(event: OverviewEvent): string { switch (event.type) { case "incident": diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 2004e9c13..71769bdd7 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -33,6 +33,7 @@ import { getIncidentOrThrow, linkIncidentStatusReport, listIncidentEvents, + listAllIncidents, listIncidents, listLinkedStatusReportIds, type IncidentEffects, @@ -89,16 +90,7 @@ export const incidentRouter = createTRPCRouter({ if (input?.limit !== undefined) return (await listIncidents({ ctx: serviceCtx, input })).items; // no limit = every incident; dashboard calendars and counts need the full history - const items = []; - for (let offset = input?.offset ?? 0; ; offset += 100) { - const page = await listIncidents({ - ctx: serviceCtx, - input: { ...input, limit: 100, offset }, - }); - items.push(...page.items); - if (page.items.length < 100 || offset + 100 >= page.totalSize) - return items; - } + return await listAllIncidents({ ctx: serviceCtx, input }); } catch (err) { toTRPCError(err); } diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts index ea5720588..7032b63a6 100644 --- a/packages/services/src/incident/__tests__/incident.test.ts +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -44,6 +44,7 @@ import { getIncidentOrThrow, linkIncidentStatusReport, listIncidentEvents, + listAllIncidents, listIncidents, setIncidentStatus, unbindIncidentSlackChannel, @@ -839,6 +840,28 @@ describe("reads", () => { }); }); + test("listAll returns every match past the page limit, workspace-scoped", async () => { + await withTestTransaction(async (tx) => { + for (let i = 0; i < 101; i++) { + await createIncident(workspace.id, { status: "mitigated" }, tx); + } + const foreign = await createIncident( + otherWorkspace.id, + { status: "mitigated" }, + tx, + ); + const items = await listAllIncidents({ + ctx: as(memberId, tx), + input: { status: ["mitigated"] }, + }); + expect(items.length).toBeGreaterThanOrEqual(101); + expect(items.every((i) => i.status === "mitigated")).toBe(true); + expect(items.map((i) => i.id)).not.toContain(foreign.id); + expect(new Set(items.map((i) => i.id)).size).toBe(items.length); + expect(items[0]?.allowedTransitions).toBeDefined(); + }); + }); + test("list returns the total size across pages", async () => { await withTestTransaction(async (tx) => { for (let i = 0; i < 3; i++) { diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index f43288c19..96d451165 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -40,6 +40,7 @@ export { getIncidentBySlackChannel, getIncidentForStatusReport, getIncidentOrThrow, + listAllIncidents, listIncidents, listLinkedStatusReportIds, toIncidentView, diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts index da919ca83..aad8db3cd 100644 --- a/packages/services/src/incident/list.ts +++ b/packages/services/src/incident/list.ts @@ -24,16 +24,16 @@ export function toIncidentView( }; } -/** Newest declared first. */ -export async function listIncidents(args: { - ctx: ServiceContext; - input?: ListIncidentsInput; -}) { - const { ctx } = args; - const input = ListIncidentsInput.parse(args.input ?? {}); - const db = getReadDb(ctx); +const incidentListWith = { + commander: { columns: incidentUserColumns }, + statusReport: { columns: { id: true, title: true, status: true } }, +} as const; - const where = and( +function listWhere( + ctx: ServiceContext, + input: Pick, +) { + return and( eq(incident.workspaceId, ctx.workspace.id), input.status?.length ? inArray(incident.status, input.status) : undefined, input.closed === undefined @@ -42,16 +42,25 @@ export async function listIncidents(args: { ? isNotNull(incident.closedAt) : isNull(incident.closedAt), ); +} + +/** Newest declared first. */ +export async function listIncidents(args: { + ctx: ServiceContext; + input?: ListIncidentsInput; +}) { + const { ctx } = args; + const input = ListIncidentsInput.parse(args.input ?? {}); + const db = getReadDb(ctx); + + const where = listWhere(ctx, input); const items = ( await db.query.incident.findMany({ where, orderBy: [desc(incident.declaredAt), desc(incident.id)], limit: input.limit, offset: input.offset, - with: { - commander: { columns: incidentUserColumns }, - statusReport: { columns: { id: true, title: true, status: true } }, - }, + with: incidentListWith, }) ).map(toIncidentView); @@ -71,6 +80,19 @@ export async function listIncidents(args: { return { items, totalSize }; } +/** Every matching incident in one read, newest declared first; for dashboard calendars and counts. */ +export async function listAllIncidents(args: { + ctx: ServiceContext; + input?: Pick; +}) { + const rows = await getReadDb(args.ctx).query.incident.findMany({ + where: listWhere(args.ctx, args.input ?? {}), + orderBy: [desc(incident.declaredAt), desc(incident.id)], + with: incidentListWith, + }); + return rows.map(toIncidentView); +} + export async function getIncident(args: { ctx: ServiceContext; input: IncidentIdInput;