diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts index e4961473..a1a57bfe 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts @@ -1,5 +1,12 @@ import type { LucideIcon } from "lucide-react"; -import { Cog, Hammer, LayoutTemplate, Megaphone, Users } from "lucide-react"; +import { + CalendarDays, + Cog, + Hammer, + LayoutTemplate, + Megaphone, + Users, +} from "lucide-react"; export const STATUS_PAGE_TABS: { value: string; @@ -10,5 +17,7 @@ export const STATUS_PAGE_TABS: { { value: "maintenances", label: "Maintenances", icon: Hammer }, { value: "subscribers", label: "Subscribers", icon: Users }, { value: "components", label: "Components", icon: LayoutTemplate }, + // TODO: hidden in the tabs but still accessible via direct link - can be enabled in the future + // { value: "history", label: "History", icon: CalendarDays }, { value: "edit", label: "Settings", icon: Cog }, ]; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx new file mode 100644 index 00000000..120a0315 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; +import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { Info, Lock } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useQueryStates } from "nuqs"; +import { useMemo, useState } from "react"; + +import { Note, NoteButton } from "@/components/common/note"; +import { + BillingOverlay, + BillingOverlayButton, + BillingOverlayContainer, + BillingOverlayDescription, +} from "@/components/content/billing-overlay"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + HintCollapsible, + HintCollapsibleContent, + HintCollapsibleDescription, + HintCollapsibleTitle, + HintCollapsibleTrigger, +} from "@/components/content/hint-collapsible"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { getColumns } from "@/components/data-table/status-page-history/columns"; +import { UpgradeDialog } from "@/components/dialogs/upgrade"; +import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { + MetricCard, + MetricCardGroup, + MetricCardHeader, + MetricCardTitle, + MetricCardValue, +} from "@/components/metric/metric-card"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { + HISTORY_WINDOWS, + getColumnVisibility, + parseWindow, + windowKey, +} from "@/data/status-page-history"; +import { useTRPC } from "@/lib/trpc/client"; + +import { buildExampleHistory } from "./examples"; +import { searchParamsParsers } from "./search-params"; + +export function Client() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const [{ window }, setSearchParams] = useQueryStates(searchParamsParsers); + const [openDialog, setOpenDialog] = useState(false); + + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const isLimited = workspace?.limits["uptime-history"] === false; + + // one 24-month fetch; the window tabs only toggle column visibility + const { data: liveHistory } = useQuery({ + ...trpc.page.getUptimeHistory.queryOptions({ id: Number.parseInt(id) }), + enabled: !!workspace && !isLimited, + }); + + const exampleHistory = useMemo( + () => (isLimited ? buildExampleHistory() : null), + [isLimited], + ); + const history = isLimited ? exampleHistory : liveHistory; + + const columns = useMemo( + () => getColumns(history?.months ?? [], window), + [history?.months, window], + ); + const columnVisibility = useMemo( + () => getColumnVisibility(window, history?.months.length ?? 0), + [window, history?.months.length], + ); + + if (!history) return null; + + const summary = history.summary[windowKey(window)]; + const hasAnyData = history.rows.some((row) => + Object.values(row.months).some((value) => value !== null), + ); + + return ( + +
+ + +
+ History + + Long-term uptime, build from your page's components. + +
+ setSearchParams({ window: parseWindow(v) })} + > + + {HISTORY_WINDOWS.map((w) => ( + + {w} months + + ))} + + +
+
+
+
+ + + + + Page Uptime + + + + {summary.uptime === null ? "—" : `${summary.uptime.toFixed(2)}%`} + + + + + Reports + + {summary.reports} + + + + + Components + + + {history.rows.length} + + + + + Created At + + + + {history.createdAt ? format(history.createdAt, "MMM yy") : "—"} + + + + {isLimited ? ( + +
+ +
+ + setOpenDialog(true)}> + + Upgrade + + + Keep an overview of your uptime history for the last 24 months. + + + +
+ ) : hasAnyData ? ( +
+ +
+ ) : ( + + No history yet + + The first{" "} + snapshot{" "} + freezes on the{" "} + + 10th of next month + {" "} + — until then, the current month is served live. + + + )} + + + + How uptime is calculated + + + Each percentage depends on the page's calculation mode and the + component type. + + + +

+ The calculation mode is set page-wide in the status page settings + and applies to every month shown here. What is frozen monthly are + the raw daily check counts — changing the mode re-renders all + months from those immutable counts. +

+
    +
  • + + requests + {" "} + + — Monitor components. Uptime is{" "} + + (ok + degraded) / total checks + + . Degraded responses still count as up. + +
  • +
  • + + duration + {" "} + + — Monitor components. Downtime is measured from incident and + status-report intervals, weighted by impact (major outage + counts fully, partial outage at half) and merged so + overlapping events are never double-counted. + +
  • +
  • + + manual + {" "} + + — No probe data is considered; uptime is derived entirely from + reported incidents. Static components always work this way, + with their full history kept — reports never expire. + +
  • +
+

+ Months without recorded checks are shown as{" "} + no data and + excluded from totals — they are never counted as downtime. +

+
+
+ {!isLimited ? ( + + + If there are any missing months, please contact us. + + Request Backfill + + + ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts new file mode 100644 index 00000000..4ea78aea --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts @@ -0,0 +1,95 @@ +import type { RouterOutputs } from "@openstatus/api"; + +type UptimeHistory = RouterOutputs["page"]["getUptimeHistory"]; +type HistoryRow = UptimeHistory["rows"][number]; + +// TODO: rework later - currently not happy how we keep the metrics data available + +const HISTORY_MONTHS = 24; +const WINDOWS = ["6", "12", "24"] as const; + +// deterministic PRNG so the placeholder rows don't reshuffle between renders +function mulberry32(seed: number) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// UTC buckets, matching the server's monthKeys +function monthKeys(now: Date): string[] { + return Array.from({ length: HISTORY_MONTHS }, (_, i) => { + const d = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() - (HISTORY_MONTHS - 1 - i), + 1, + ), + ); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + return `${d.getUTCFullYear()}-${mm}`; + }); +} + +const COMPONENTS: { id: number; name: string; type: "monitor" | "static" }[] = [ + { id: 1, name: "API", type: "monitor" }, + { id: 2, name: "Dashboard", type: "monitor" }, + { id: 3, name: "Documentation", type: "static" }, +]; + +const round = (value: number) => Math.round(value * 100) / 100; + +function average(values: (number | null)[]): number | null { + const present = values.filter((v): v is number => v !== null); + if (present.length === 0) return null; + return round(present.reduce((a, b) => a + b, 0) / present.length); +} + +export function buildExampleHistory(now = new Date()): UptimeHistory { + const months = monthKeys(now); + + const rows: HistoryRow[] = COMPONENTS.map((component, index) => { + const rng = mulberry32(component.id * 7919); + const values = months.map((_, i) => { + // stagger creation dates so older cells show the no-data state + if (i < index * 3) return null; + const roll = rng(); + if (roll > 0.96) return round(97 + rng() * 1.9); + if (roll > 0.85) return round(99 + rng() * 0.85); + return round(99.9 + rng() * 0.1); + }); + + return { + component: { ...component, monitorId: null }, + months: Object.fromEntries(months.map((key, i) => [key, values[i]])), + rolling: Object.fromEntries( + WINDOWS.map((w) => [w, average(values.slice(-Number(w)))]), + ) as HistoryRow["rolling"], + events: [], + }; + }); + + const summary = Object.fromEntries( + WINDOWS.map((w) => [ + w, + { + uptime: average(rows.map((row) => row.rolling[w])), + reports: Number(w) / 3, + }, + ]), + ) as UptimeHistory["summary"]; + + return { + mode: "requests", + months, + createdAt: new Date( + Date.UTC(now.getUTCFullYear() - 2, now.getUTCMonth(), 1), + ), + summary, + rows, + }; +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx new file mode 100644 index 00000000..9b18b101 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/layout.tsx @@ -0,0 +1,46 @@ +import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; + +import { + RIGHT_SIDEBAR_COOKIE, + getSidebarDefaultOpen, +} from "@/lib/sidebar-cookie"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +import { Sidebar } from "../sidebar"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const queryClient = getQueryClient(); + + await Promise.all([ + queryClient.prefetchQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ), + queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), + queryClient.prefetchQuery( + trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), + ), + ]); + const defaultOpen = await getSidebarDefaultOpen(RIGHT_SIDEBAR_COOKIE, false); + + return ( + + +
{children}
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx new file mode 100644 index 00000000..706e19c5 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/page.tsx @@ -0,0 +1,29 @@ +import type { SearchParams } from "nuqs"; + +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise; +}) { + const { id } = await params; + const queryClient = getQueryClient(); + + // NOTE: store in cache to avoid flicker on clients first render + await searchParamsCache.parse(searchParams); + await queryClient.prefetchQuery( + trpc.page.getUptimeHistory.queryOptions({ id: Number.parseInt(id) }), + ); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/search-params.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/search-params.ts new file mode 100644 index 00000000..d07cc78b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/search-params.ts @@ -0,0 +1,9 @@ +import { createSearchParamsCache, parseAsNumberLiteral } from "nuqs/server"; + +import { HISTORY_WINDOWS } from "@/data/status-page-history"; + +export const searchParamsParsers = { + window: parseAsNumberLiteral(HISTORY_WINDOWS).withDefault(6), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx index 1b335b32..7a57fdd3 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -109,45 +109,47 @@ export default function Page() { {page?.title} List of all subscribers. - {isLimited ? ( - - ) : ( - { - if (values.channelType === "email") { - await createAction.mutateAsync({ - pageId, - channelType: "email", - email: values.email, - name: values.name || null, - componentIds: values.componentIds, - }); - } else { - await createAction.mutateAsync({ - pageId, - channelType: "webhook", - webhookUrl: values.webhookUrl, - name: values.name || null, - headers: values.headers, - componentIds: values.componentIds, - }); - } - }} - > - - - )} + ) : ( + { + if (values.channelType === "email") { + await createAction.mutateAsync({ + pageId, + channelType: "email", + email: values.email, + name: values.name || null, + componentIds: values.componentIds, + }); + } else { + await createAction.mutateAsync({ + pageId, + channelType: "webhook", + webhookUrl: values.webhookUrl, + name: values.name || null, + headers: values.headers, + componentIds: values.componentIds, + }); + } + }} + > + + + )} +
diff --git a/apps/dashboard/src/components/common/note.tsx b/apps/dashboard/src/components/common/note.tsx index a00947e1..85f81ca5 100644 --- a/apps/dashboard/src/components/common/note.tsx +++ b/apps/dashboard/src/components/common/note.tsx @@ -3,7 +3,7 @@ import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; const noteVariants = cva( - "flex items-center gap-2 rounded-xl border [&>svg]:shrink-0 [&>svg]:text-current", + "flex items-center gap-2 rounded-lg border [&>svg]:shrink-0 [&>svg]:text-current", { variants: { variant: { diff --git a/apps/dashboard/src/components/content/hint-collapsible.tsx b/apps/dashboard/src/components/content/hint-collapsible.tsx new file mode 100644 index 00000000..f9cbd787 --- /dev/null +++ b/apps/dashboard/src/components/content/hint-collapsible.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@openstatus/ui/components/ui/collapsible"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export function HintCollapsible({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function HintCollapsibleTrigger({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {/* spans with display:block — a + + +
+ {monthLabel} +
+ {cell.percentage === null ? ( +
+ No data recorded for {monthLabel}. +
+ ) : ( +
+ + Uptime + + {cell.percentage.toFixed(2)} + % + +
+ )} + {events.length > 0 && ( + <> +
+
+ {events.map((event) => { + const key = `${event.type}-${event.id}`; + const href = eventHref(event, pageId, monitorId); + const node = ; + if (!href) return {node}; + return ( + + {node} + + ); + })} +
+ + )} + + + ); +} diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx index 084bb62a..332056dd 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx @@ -53,6 +53,8 @@ export interface DataTableProps { setSorting?: React.Dispatch>; pagination?: PaginationState; setPagination?: React.Dispatch>; + columnVisibility?: VisibilityState; + setColumnVisibility?: React.Dispatch>; } export function DataTable({ @@ -74,11 +76,13 @@ export function DataTable({ setSorting, pagination, setPagination, + columnVisibility, + setColumnVisibility, }: DataTableProps) { // oxlint-disable-next-line typescript/no-explicit-any const [globalFilter, setGlobalFilter] = React.useState(); const [rowSelection, setRowSelection] = React.useState({}); - const [columnVisibility, setColumnVisibility] = + const [internalColumnVisibility, setInternalColumnVisibility] = React.useState(defaultColumnVisibility); const [internalPagination, setInternalPagination] = React.useState(defaultPagination); @@ -94,13 +98,16 @@ export function DataTable({ const setSortingState = setSorting ?? setInternalSorting; const paginationState = pagination ?? internalPagination; const setPaginationState = setPagination ?? setInternalPagination; + const columnVisibilityState = columnVisibility ?? internalColumnVisibility; + const setColumnVisibilityState = + setColumnVisibility ?? setInternalColumnVisibility; const table = useReactTable({ data, columns, state: { sorting: sortingState, - columnVisibility, + columnVisibility: columnVisibilityState, rowSelection, pagination: paginationState, columnFilters: columnFiltersState, @@ -110,7 +117,7 @@ export function DataTable({ onRowSelectionChange: setRowSelection, onSortingChange: setSortingState, onColumnFiltersChange: setColumnFiltersState, - onColumnVisibilityChange: setColumnVisibility, + onColumnVisibilityChange: setColumnVisibilityState, onPaginationChange: setPaginationState, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), diff --git a/apps/dashboard/src/data/plans.ts b/apps/dashboard/src/data/plans.ts index 1a6f99a4..0e1bcc6f 100644 --- a/apps/dashboard/src/data/plans.ts +++ b/apps/dashboard/src/data/plans.ts @@ -40,6 +40,10 @@ export const config: Record< value: "monitor-values-visibility", label: "Toggle numbers visibility", }, + // { + // value: "uptime-history", + // label: "Uptime history (24 months)", + // }, { value: "status-subscribers", label: "Subscribers", diff --git a/apps/dashboard/src/data/status-page-history.ts b/apps/dashboard/src/data/status-page-history.ts new file mode 100644 index 00000000..33edea62 --- /dev/null +++ b/apps/dashboard/src/data/status-page-history.ts @@ -0,0 +1,104 @@ +import type { RouterOutputs } from "@openstatus/api"; +import { format } from "date-fns"; + +// single client-side source for the window set; the server keeps its own +// WINDOWS tuple (services can't be imported into client bundles) +export const HISTORY_WINDOWS = [6, 12, 24] as const; +export type HistoryWindow = (typeof HISTORY_WINDOWS)[number]; + +// safe because callers only pass values rendered from HISTORY_WINDOWS tabs; +// anything else falls back to the default window +export function parseWindow(value: string): HistoryWindow { + const window = Number(value); + return (HISTORY_WINDOWS as readonly number[]).includes(window) + ? (window as HistoryWindow) + : HISTORY_WINDOWS[0]; +} + +export type UptimeStatus = + | "operational" + | "degraded" + | "down" + | "in-progress" + | "no-data"; + +export type MonthCell = { + percentage: number | null; + status: UptimeStatus; +}; + +// wire types inferred from the endpoint so server shape changes surface here +export type HistoryRow = + RouterOutputs["page"]["getUptimeHistory"]["rows"][number]; +export type HistoryEvent = HistoryRow["events"][number]; + +// safe because HistoryRow.rolling keys are exactly the string forms of HistoryWindow +export function windowKey(window: HistoryWindow): keyof HistoryRow["rolling"] { + return String(window) as keyof HistoryRow["rolling"]; +} + +/** + * Events overlapping the month; open-ended events count up to now. + * UTC boundaries — the server buckets months in UTC, so local boundaries + * would list an event under a neighboring month for non-UTC users. + */ +export function eventsForMonth( + events: HistoryEvent[], + key: string, +): HistoryEvent[] { + const [year, month] = key.split("-").map(Number); + const start = Date.UTC(year, month - 1, 1); + const end = Date.UTC(year, month, 1); + const now = Date.now(); + return events.filter((e) => { + const from = e.from.getTime(); + const to = e.to?.getTime() ?? now; + return from < end && to >= start; + }); +} + +// presentational thresholds only — the verb owns the uptime math +export function cellFromPercentage( + percentage: number | null, + isCurrent = false, +): MonthCell { + if (percentage === null) return { percentage, status: "no-data" }; + if (isCurrent) return { percentage, status: "in-progress" }; + return { + percentage, + status: + percentage >= 99.9 + ? "operational" + : percentage >= 99 + ? "degraded" + : "down", + }; +} + +function monthKeyToDate(key: string): Date { + const [year, month] = key.split("-").map(Number); + return new Date(year, month - 1, 1); +} + +export function monthKeyToLabel(key: string): string { + return format(monthKeyToDate(key), "MMM yy"); +} + +/** Full month + year, e.g. "October 2025". */ +export function monthKeyToFullLabel(key: string): string { + return format(monthKeyToDate(key), "MMMM yyyy"); +} + +/** Show slots 1…window; hide the rest of the served months. */ +export function getColumnVisibility( + window: HistoryWindow, + monthCount: number, +): Record { + if (window >= monthCount) return {}; + return Object.fromEntries( + Array.from({ length: monthCount - window }, (_, i) => [ + String(i + window + 1), + false, + ]), + ); +} diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index 45bd9b6e..f6444ddc 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -16,6 +16,7 @@ import { StepPaused, workflowStepSchema, } from "./monitor"; +import { handleUptimeFreezeCron } from "./uptime-freeze"; const app = new Hono({ strict: false }); @@ -93,6 +94,10 @@ app.get("/external-incidents-prune", async (c) => { return handleExternalIncidentsPruneCron(c); }); +app.get("/uptime-freeze", async (c) => { + return handleUptimeFreezeCron(c); +}); + app.get("/emails/follow-up", async (c) => { try { await sendFollowUpEmails(); diff --git a/apps/workflows/src/cron/uptime-freeze.ts b/apps/workflows/src/cron/uptime-freeze.ts new file mode 100644 index 00000000..2ee0e231 --- /dev/null +++ b/apps/workflows/src/cron/uptime-freeze.ts @@ -0,0 +1,70 @@ +import { getLogger } from "@logtape/logtape"; +import { + type UptimeFreezePipes, + runUptimeFreeze, +} from "@openstatus/services/frozen-uptime"; +import { OSTinybird } from "@openstatus/tinybird"; +import type { Context } from "hono"; + +import { env } from "../env"; +import { reportBackgroundError, runSentryCron } from "../lib/sentry"; + +const logger = getLogger(["workflow", "uptime-freeze"]); + +const tb = new OSTinybird(env().TINY_BIRD_API_KEY); + +const pipes: UptimeFreezePipes = { + http: tb.httpStatus45d, + tcp: tb.tcpStatus45d, + dns: tb.dnsStatus45d, +}; + +export async function handleUptimeFreezeCron(c: Context) { + const { cronCompleted, cronFailed } = runSentryCron("uptime-freeze"); + + // Background chain: must not capture `c` or anything derived from it — + // the handler returns 200 before this resolves (see external-status.ts) + void runUptimeFreeze({ + pipes, + onChunkFailure: ({ jobType, error }) => { + logger.warn("uptime-freeze: tinybird {jobType} chunk failed: {reason}", { + jobType, + reason: error instanceof Error ? error.message : String(error), + }); + }, + }) + .then(async (res) => { + if (res.failures.length > 0) { + // isolate: a Sentry transport failure must not flip a completed + // freeze run into cronFailed via the outer catch + try { + await reportBackgroundError( + `uptime-freeze ${res.month}: ${res.failures.length} failures (frozen=${res.frozen}, alreadyFrozen=${res.alreadyFrozen}, skipped=${res.skipped}). First: ${res.failures.slice(0, 5).join("; ")}`, + ); + } catch (reportError) { + logger.warn("uptime-freeze: reportBackgroundError failed: {reason}", { + reason: + reportError instanceof Error + ? reportError.message + : String(reportError), + }); + } + } + logger.info( + "uptime-freeze complete: month={month} frozen={frozen} alreadyFrozen={alreadyFrozen} skipped={skipped} failed={failed}", + { ...res, failed: res.failures.length }, + ); + void cronCompleted(); + }) + .catch((e) => { + logger.error("uptime-freeze errored: {message}", { + message: e instanceof Error ? e.message : String(e), + }); + void reportBackgroundError( + `uptime-freeze failed: ${e instanceof Error ? e.message : String(e)}`, + ); + void cronFailed(); + }); + + return c.json({ success: true }, 200); +} diff --git a/deno.lock b/deno.lock index 59e89299..0fb0ec68 100644 --- a/deno.lock +++ b/deno.lock @@ -54,224 +54,6 @@ "dependencies": [ "npm:turbo@2.9.14" ] - }, - "members": { - "apps/server": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "apps/status-page": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "apps/web": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/api": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/db": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/emails": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/header-analysis": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/importers": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/base": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/bird-whatsapp": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/discord": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/google-chat": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/grafana-oncall": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/ms-teams": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/ntfy": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/opsgenie": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/pagerduty": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/slack": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/telegram": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/twillio-sms": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/notifications/webhook": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/services": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/status-fetcher": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/subscriptions": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/test-utils": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/tracker": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - }, - "packages/utils": { - "packageJson": { - "dependencies": [ - "npm:@jsr/std__expect@^1.0.19", - "npm:@jsr/std__testing@^1.0.19" - ] - } - } } } } diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index e7b069f6..1c4c262a 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -1,6 +1,7 @@ import { Events } from "@openstatus/analytics"; import { locales } from "@openstatus/locales"; import { NotFoundError } from "@openstatus/services"; +import { getUptimeHistory } from "@openstatus/services/frozen-uptime"; import { type CreatePageInput, // `CreatePageInput` re-exports the drizzle insert schema so routers @@ -153,6 +154,19 @@ export const pageRouter = createTRPCRouter({ } }), + getUptimeHistory: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }) => { + try { + return await getUptimeHistory({ + ctx: toServiceCtx(ctx), + input: { pageId: input.id }, + }); + } catch (err) { + toTRPCError(err); + } + }), + // TODO: rename to create new: protectedProcedure .meta({ track: Events.CreatePage, trackProps: ["slug"] }) diff --git a/packages/api/src/router/statusPage.utils.test.ts b/packages/api/src/router/statusPage.utils.test.ts index f7fe16d9..a9ae1c57 100644 --- a/packages/api/src/router/statusPage.utils.test.ts +++ b/packages/api/src/router/statusPage.utils.test.ts @@ -2095,10 +2095,17 @@ describe("withTinybirdFallback", () => { }); it("falls back when the read exceeds the timeout", async () => { + // keep a handle on the slow read's timer — deno's leak sanitizer fails + // the test if it outlives the assertion + let slowRead!: ReturnType; const result = await withTinybirdFallback( - () => new Promise((resolve) => setTimeout(resolve, 50)), + () => + new Promise((resolve) => { + slowRead = setTimeout(resolve, 50); + }), 10, ); + clearTimeout(slowRead); expect(result).toEqual({ ok: false, data: null }); }); }); diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/api/src/router/statusPage.utils.ts index 5f6d5dfa..32f97855 100644 --- a/packages/api/src/router/statusPage.utils.ts +++ b/packages/api/src/router/statusPage.utils.ts @@ -1,18 +1,20 @@ import type { PageComponentImpact } from "@openstatus/db/src/schema"; -import { - LEGACY_IMPACT_WEIGHT, - impactToStatusType, - impactUptimeWeight, - worstImpact, -} from "@openstatus/db/src/schema"; +import { impactToStatusType, worstImpact } from "@openstatus/db/src/schema"; import { type Event, + MS_PER_DAY, type StatusData, + type UptimeWindow, + dayCoverage, + durationDowntimeMs, + floorPct, getHighestPriorityStatus, getWorstVariant, isDateWithinEvent, reportEventDayImpact, reportEventDayStatus, + reportsOnlyDowntimeMs, + requestsTally, } from "@openstatus/services/status-timeline"; export * from "@openstatus/services/status-timeline"; @@ -69,8 +71,6 @@ type UptimeData = { }[]; }; -// Constants for time calculations -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; const MILLISECONDS_PER_MINUTE = 1000 * 60; // Helper to format numbers @@ -170,7 +170,7 @@ function getTotalEventsDurationMs(events: Event[], date: Date): number { }, 0); // Cap at 24 hours per day - return Math.min(total, MILLISECONDS_PER_DAY); + return Math.min(total, MS_PER_DAY); } export function setDataByType({ @@ -233,13 +233,11 @@ export function setDataByType({ return [ { status: "success" as const, - height: - ((MILLISECONDS_PER_DAY - errorSegmentCount) / MILLISECONDS_PER_DAY) * - 100, + height: ((MS_PER_DAY - errorSegmentCount) / MS_PER_DAY) * 100, }, { status: "error" as const, - height: (errorSegmentCount / MILLISECONDS_PER_DAY) * 100, + height: (errorSegmentCount / MS_PER_DAY) * 100, }, ]; } @@ -252,8 +250,7 @@ export function setDataByType({ const errorMs = segments .filter((segment) => segment.status === "error") .reduce((sum, segment) => sum + segment.count, 0); - const errorHeight = - (Math.min(errorMs, MILLISECONDS_PER_DAY) / MILLISECONDS_PER_DAY) * 100; + const errorHeight = (Math.min(errorMs, MS_PER_DAY) / MS_PER_DAY) * 100; const remainingHeight = Math.max(0, 100 - errorHeight); const highlightSegments = segments.filter( @@ -641,31 +638,6 @@ export function setDataByType({ }); } -type WeightedInterval = { from: number; to: number; weight: number }; - -// concurrent events describing the same outage must not double-count -// downtime: per time slice the worst (max) weight wins, mirroring -// mergeWorstImpactIntervals — summing could push uptime negative -function mergedDowntimeMs(intervals: WeightedInterval[]): number { - const boundaries = [ - ...new Set(intervals.flatMap((iv) => [iv.from, iv.to])), - ].sort((a, b) => a - b); - - let total = 0; - for (let i = 0; i + 1 < boundaries.length; i++) { - const sliceStart = boundaries[i]; - const sliceEnd = boundaries[i + 1]; - let weight = 0; - for (const iv of intervals) { - if (iv.from <= sliceStart && iv.to >= sliceEnd) { - weight = Math.max(weight, iv.weight); - } - } - total += weight * (sliceEnd - sliceStart); - } - return total; -} - export function getUptime({ data, events, @@ -677,81 +649,27 @@ export function getUptime({ barType: "absolute" | "dominant" | "manual"; cardType: "requests" | "duration" | "dominant" | "manual"; }): string { - // Clamp event durations to the data lookback window to avoid - // events outside the window producing negative uptime values. - const timestamps = data.map((d) => new Date(d.day).getTime()); - const windowStart = timestamps.length > 0 ? Math.min(...timestamps) : 0; - const windowEndDate = new Date( - timestamps.length > 0 ? Math.max(...timestamps) : Date.now(), - ); - windowEndDate.setUTCHours(23, 59, 59, 999); - const windowEnd = windowEndDate.getTime(); - - function clampedInterval( - from: Date, - to: Date | null, - weight: number, - ): WeightedInterval | null { - const start = Math.max(from.getTime(), windowStart); - const end = Math.min((to ?? new Date()).getTime(), windowEnd); - if (end <= start || weight === 0) return null; - return { from: start, to: end, weight }; - } - - function reportImpactIntervals(event: Event): WeightedInterval[] { - return (event.impactIntervals ?? []) - .map((iv) => - clampedInterval(iv.from, iv.to, impactUptimeWeight(iv.impact)), - ) - .filter((iv): iv is WeightedInterval => iv !== null); - } - - if (barType === "manual") { - // NOTE: we want only user events; legacy reports (no impact rows) keep - // their full duration as downtime - const intervals = events - .filter((e) => e.type === "report") - .flatMap((e) => - e.impactIntervals - ? reportImpactIntervals(e) - : (clampedInterval(e.from, e.to, LEGACY_IMPACT_WEIGHT) ?? []), - ); - - const total = data.length * MILLISECONDS_PER_DAY; - if (total === 0) return "100%"; - const duration = mergedDowntimeMs(intervals); - - return `${Math.floor(((total - duration) / total) * 10000) / 100}%`; - } - - if (cardType === "duration") { - // incidents and impact-report downtime share one timeline so an incident - // plus a report describing the same outage counts once; legacy reports - // stay ignored to preserve pre-impact uptime values - const intervals = events.flatMap((e) => { - if (e.type === "incident") return clampedInterval(e.from, e.to, 1) ?? []; - if (e.type === "report") return reportImpactIntervals(e); - return []; - }); - - const total = data.length * MILLISECONDS_PER_DAY; + if (barType === "manual" || cardType === "duration") { + // Clamp event durations to the data lookback window to avoid + // events outside the window producing negative uptime values. + const timestamps = data.map((d) => new Date(d.day).getTime()); + const { segments: coverage, totalMs: total } = dayCoverage(timestamps); if (total === 0) return "100%"; - const duration = mergedDowntimeMs(intervals); - - return `${Math.floor(((total - duration) / total) * 10000) / 100}%`; + const windowEndDate = new Date(Math.max(...timestamps)); + windowEndDate.setUTCHours(23, 59, 59, 999); + const window: UptimeWindow = { + start: Math.min(...timestamps), + end: windowEndDate.getTime(), + now: Date.now(), + }; + const duration = + barType === "manual" + ? reportsOnlyDowntimeMs(events, window, coverage) + : durationDowntimeMs(events, window, coverage); + return `${floorPct((total - duration) / total)}%`; } - const { ok, total } = data.reduce( - (acc, item) => ({ - ok: acc.ok + item.ok + item.degraded, - total: acc.total + item.ok + item.degraded + item.error, - }), - { - ok: 0, - total: 0, - }, - ); - + const { up, total } = requestsTally(data); if (total === 0) return "100%"; - return `${Math.floor((ok / total) * 10000) / 100}%`; + return `${floorPct(up / total)}%`; } diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index abb22729..192f5e5c 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -260,7 +260,7 @@ export const webhookRouter = createTRPCRouter({ for (const m of activeMonitors.slice(1)) { await tx .update(monitor) - .set({ active: false }) + .set({ active: false, updatedAt: new Date() }) .where(eq(monitor.id, m.id)) .run(); } @@ -283,6 +283,7 @@ export const webhookRouter = createTRPCRouter({ password: null, accessType: "public", authEmailDomains: null, + updatedAt: new Date(), }) .where(eq(page.id, statusPages[0].id)) .run(); diff --git a/packages/db/drizzle/0077_military_jackpot.sql b/packages/db/drizzle/0077_military_jackpot.sql new file mode 100644 index 00000000..5e36e4a1 --- /dev/null +++ b/packages/db/drizzle/0077_military_jackpot.sql @@ -0,0 +1,13 @@ +CREATE TABLE `frozen_monitor_uptime` ( + `id` integer PRIMARY KEY NOT NULL, + `workspace_id` integer NOT NULL, + `monitor_id` integer NOT NULL, + `month` text NOT NULL, + `days` text NOT NULL, + `created_at` integer DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `frozen_monitor_uptime_workspace_id_idx` ON `frozen_monitor_uptime` (`workspace_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `frozen_monitor_uptime_monitor_id_month_unique` ON `frozen_monitor_uptime` (`monitor_id`,`month`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0077_snapshot.json b/packages/db/drizzle/meta/0077_snapshot.json new file mode 100644 index 00000000..2efba0c9 --- /dev/null +++ b/packages/db/drizzle/meta/0077_snapshot.json @@ -0,0 +1,4696 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e88d56a5-ae87-4b25-9d59-1153f97a63bb", + "prevId": "bcbfadc8-aa40-4d0c-ad3b-eb3f3caa61ea", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_idx": { + "name": "incident_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_idx": { + "name": "monitor_run_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 28d7004f..13a807b7 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -540,6 +540,13 @@ "when": 1781258845086, "tag": "0076_tense_night_thrasher", "breakpoints": true + }, + { + "idx": 77, + "version": "6", + "when": 1782994900937, + "tag": "0077_military_jackpot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/frozen_uptime/frozen_monitor_uptime.ts b/packages/db/src/schema/frozen_uptime/frozen_monitor_uptime.ts new file mode 100644 index 00000000..206738c8 --- /dev/null +++ b/packages/db/src/schema/frozen_uptime/frozen_monitor_uptime.ts @@ -0,0 +1,56 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + sqliteTable, + text, + unique, +} from "drizzle-orm/sqlite-core"; + +import { monitor } from "../monitors/monitor"; +import { workspace } from "../workspaces/workspace"; +import type { FrozenMonitorUptimeDay } from "./validation"; + +export const frozenMonitorUptime = sqliteTable( + "frozen_monitor_uptime", + { + id: integer("id").primaryKey(), + workspaceId: integer("workspace_id") + .notNull() + .references(() => workspace.id, { onDelete: "cascade" }), + monitorId: integer("monitor_id") + .notNull() + .references(() => monitor.id, { onDelete: "cascade" }), + // UTC first-of-month, YYYY-MM-01 + month: text("month").notNull(), + days: text("days", { mode: "json" }) + .$type() + .notNull(), + + createdAt: integer("created_at", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ), + }, + (t) => [ + // idempotency key: re-freezing a month is a silent no-op + unique("frozen_monitor_uptime_monitor_id_month_unique").on( + t.monitorId, + t.month, + ), + index("frozen_monitor_uptime_workspace_id_idx").on(t.workspaceId), + ], +); + +export const frozenMonitorUptimeRelations = relations( + frozenMonitorUptime, + ({ one }) => ({ + monitor: one(monitor, { + fields: [frozenMonitorUptime.monitorId], + references: [monitor.id], + }), + workspace: one(workspace, { + fields: [frozenMonitorUptime.workspaceId], + references: [workspace.id], + }), + }), +); diff --git a/packages/db/src/schema/frozen_uptime/index.ts b/packages/db/src/schema/frozen_uptime/index.ts new file mode 100644 index 00000000..10997af8 --- /dev/null +++ b/packages/db/src/schema/frozen_uptime/index.ts @@ -0,0 +1,2 @@ +export * from "./frozen_monitor_uptime"; +export * from "./validation"; diff --git a/packages/db/src/schema/frozen_uptime/validation.ts b/packages/db/src/schema/frozen_uptime/validation.ts new file mode 100644 index 00000000..d5ef9c6d --- /dev/null +++ b/packages/db/src/schema/frozen_uptime/validation.ts @@ -0,0 +1,41 @@ +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; + +import { frozenMonitorUptime } from "./frozen_monitor_uptime"; + +export const frozenMonitorUptimeDaySchema = z.object({ + day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "day must be YYYY-MM-DD"), // UTC + ok: z.number().int().nonnegative(), + degraded: z.number().int().nonnegative(), + error: z.number().int().nonnegative(), +}); +export type FrozenMonitorUptimeDay = z.infer< + typeof frozenMonitorUptimeDaySchema +>; + +// YYYY-MM-01, UTC first-of-month +export const frozenMonitorUptimeMonthSchema = z + .string() + .regex(/^\d{4}-\d{2}-01$/, "month must be YYYY-MM-01"); + +export const selectFrozenMonitorUptimeSchema = createSelectSchema( + frozenMonitorUptime, + { + days: frozenMonitorUptimeDaySchema.array(), + month: frozenMonitorUptimeMonthSchema, + }, +); +export type FrozenMonitorUptime = z.infer< + typeof selectFrozenMonitorUptimeSchema +>; + +export const insertFrozenMonitorUptimeSchema = createInsertSchema( + frozenMonitorUptime, + { + days: frozenMonitorUptimeDaySchema.array(), + month: frozenMonitorUptimeMonthSchema, + }, +); +export type InsertFrozenMonitorUptime = z.infer< + typeof insertFrozenMonitorUptimeSchema +>; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 82dfb559..ddc5bece 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -25,3 +25,4 @@ export * from "./feedbacks"; export * from "./audit_logs"; export * from "./external_services"; export * from "./chat_sessions"; +export * from "./frozen_uptime"; diff --git a/packages/db/src/schema/plan/config.ts b/packages/db/src/schema/plan/config.ts index 28597339..f7a3f33f 100644 --- a/packages/db/src/schema/plan/config.ts +++ b/packages/db/src/schema/plan/config.ts @@ -41,6 +41,7 @@ export const allPlans: Record = { "page-components": 3, maintenance: true, "monitor-values-visibility": true, + "uptime-history": false, "response-logs": false, screenshots: false, otel: false, @@ -124,6 +125,7 @@ export const allPlans: Record = { "page-components": 20, maintenance: true, "monitor-values-visibility": true, + "uptime-history": false, "response-logs": true, screenshots: true, otel: false, @@ -207,6 +209,7 @@ export const allPlans: Record = { "page-components": 50, maintenance: true, "monitor-values-visibility": true, + "uptime-history": true, "response-logs": true, screenshots: true, otel: true, @@ -263,6 +266,7 @@ export const allPlans: Record = { "page-components": 500, maintenance: true, "monitor-values-visibility": true, + "uptime-history": true, "response-logs": true, screenshots: true, otel: true, diff --git a/packages/db/src/schema/plan/schema.ts b/packages/db/src/schema/plan/schema.ts index bc2f8a12..3a3b1570 100644 --- a/packages/db/src/schema/plan/schema.ts +++ b/packages/db/src/schema/plan/schema.ts @@ -31,6 +31,7 @@ export const limitsSchema = z.object({ "page-components": z.number().prefault(3), maintenance: z.boolean().prefault(true), "monitor-values-visibility": z.boolean().prefault(true), + "uptime-history": z.boolean().prefault(false), "status-subscribers": z.boolean().prefault(false), "custom-domain": z.boolean().prefault(false), i18n: z.boolean().prefault(false), diff --git a/packages/services/package.json b/packages/services/package.json index 7a863399..c353cc42 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -45,6 +45,10 @@ "import": "./src/status-timeline/index.ts", "types": "./src/status-timeline/index.ts" }, + "./frozen-uptime": { + "import": "./src/frozen-uptime/index.ts", + "types": "./src/frozen-uptime/index.ts" + }, "./workspace": { "import": "./src/workspace/index.ts", "types": "./src/workspace/index.ts" diff --git a/packages/services/src/frozen-uptime/__tests__/compute.test.ts b/packages/services/src/frozen-uptime/__tests__/compute.test.ts new file mode 100644 index 00000000..c7464b51 --- /dev/null +++ b/packages/services/src/frozen-uptime/__tests__/compute.test.ts @@ -0,0 +1,134 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + type ComputeCountRow, + computeMonitorMonth, + monthDays, + monthRange, + previousMonth, +} from "../compute"; + +const MONTH = "2026-06-01"; + +const row = ( + day: string, + counts: Partial> = {}, + monitorId = "77", +): ComputeCountRow => ({ + monitorId, + day, + ok: counts.ok ?? 0, + degraded: counts.degraded ?? 0, + error: counts.error ?? 0, +}); + +describe("previousMonth", () => { + test("returns the UTC first-of-month before now", () => { + expect(previousMonth(new Date(Date.UTC(2026, 6, 10)))).toBe("2026-06-01"); + expect(previousMonth(new Date(Date.UTC(2026, 0, 10)))).toBe("2025-12-01"); + }); +}); + +describe("monthRange", () => { + test("spans [first-of-month, first-of-next-month) in UTC ms", () => { + const { start, end } = monthRange(MONTH); + expect(start).toBe(Date.UTC(2026, 5, 1)); + expect(end).toBe(Date.UTC(2026, 6, 1)); + }); +}); + +describe("monthDays", () => { + test("handles 31/30/28-day months and leap February", () => { + expect(monthDays("2026-07-01").length).toBe(31); + expect(monthDays("2026-06-01").length).toBe(30); + expect(monthDays("2026-02-01").length).toBe(28); + expect(monthDays("2028-02-01").length).toBe(29); + expect(monthDays(MONTH)[0]).toBe("2026-06-01"); + expect(monthDays(MONTH)[29]).toBe("2026-06-30"); + }); +}); + +describe("computeMonitorMonth", () => { + test("slices ISO day strings and zero-fills missing days", () => { + const computed = computeMonitorMonth({ + month: MONTH, + monitorId: 77, + counts: [ + row("2026-06-05T00:00:00.000Z", { ok: 42, degraded: 1 }), + row("2026-06-30", { ok: 7, error: 2 }), + ], + }); + + expect(computed).not.toBeNull(); + expect(computed?.days.length).toBe(30); + expect(computed?.days[4]).toEqual({ + day: "2026-06-05", + ok: 42, + degraded: 1, + error: 0, + }); + expect(computed?.days[29]).toEqual({ + day: "2026-06-30", + ok: 7, + degraded: 0, + error: 2, + }); + expect(computed?.days[0]).toEqual({ + day: "2026-06-01", + ok: 0, + degraded: 0, + error: 0, + }); + }); + + test("drops rows outside the month and rows of other monitors", () => { + const computed = computeMonitorMonth({ + month: MONTH, + monitorId: 77, + counts: [ + row("2026-05-31", { ok: 99 }), + row("2026-07-01", { ok: 99 }), + row("2026-06-10", { ok: 99 }, "88"), + row("2026-06-10", { ok: 5 }), + ], + }); + + expect(computed?.days[9].ok).toBe(5); + expect(computed?.days.every((d) => d.ok <= 5)).toBe(true); + }); + + test("sums multiple rows for the same day instead of overwriting", () => { + const computed = computeMonitorMonth({ + month: MONTH, + monitorId: 77, + counts: [ + row("2026-06-10", { ok: 10, degraded: 1 }), + row("2026-06-10T12:00:00.000Z", { ok: 5, error: 2 }), + ], + }); + + expect(computed?.days[9]).toEqual({ + day: "2026-06-10", + ok: 15, + degraded: 1, + error: 2, + }); + }); + + test("returns null when the monitor has no counts in the month", () => { + expect( + computeMonitorMonth({ month: MONTH, monitorId: 77, counts: [] }), + ).toBeNull(); + expect( + computeMonitorMonth({ + month: MONTH, + monitorId: 77, + counts: [ + row("2026-05-31", { ok: 1 }), + row("2026-06-10", { ok: 1 }, "88"), + ], + }), + ).toBeNull(); + }); +}); diff --git a/packages/services/src/frozen-uptime/__tests__/freeze.test.ts b/packages/services/src/frozen-uptime/__tests__/freeze.test.ts new file mode 100644 index 00000000..7f2416a8 --- /dev/null +++ b/packages/services/src/frozen-uptime/__tests__/freeze.test.ts @@ -0,0 +1,200 @@ +import { eq } from "@openstatus/db"; +import { + frozenMonitorUptime, + monitor, + workspace, +} from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { + loadSeededWorkspace, + makeApiKeyCtx, + makeSystemCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import { freezeMonitorMonth } from "../freeze"; +import type { FreezeMonitorMonthInput } from "../schemas"; + +let systemCtx: ServiceContext; +let readOnlyCtx: ServiceContext; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + systemCtx = makeSystemCtx(team, { job: "uptime-freeze" }); + readOnlyCtx = makeApiKeyCtx(team, { + keyId: "k", + userId: 1, + scopes: ["read"], + }); +}); + +async function insertTestMonitor(tx: NonNullable) { + return tx + .insert(monitor) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + active: true, + url: "https://example.com", + name: "svc-frozen-uptime-monitor", + method: "GET", + periodicity: "10m", + regions: "ams", + jobType: "http", + }) + .returning() + .get(); +} + +function makeInput( + monitorId: number, + overrides: Partial = {}, +): FreezeMonitorMonthInput { + return { + monitorId, + month: "2026-06-01", + days: [{ day: "2026-06-01", ok: 42, degraded: 0, error: 1 }], + ...overrides, + }; +} + +describe("freezeMonitorMonth", () => { + test("inserts a frozen month — and emits NO audit row (deliberate exception)", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...systemCtx, db: tx }; + const testMonitor = await insertTestMonitor(tx); + const input = makeInput(testMonitor.id); + + const row = await freezeMonitorMonth({ ctx, input }); + + expect(row).not.toBeNull(); + expect(row?.workspaceId).toBe(SEEDED_WORKSPACE_TEAM_ID); + expect(row?.month).toBe("2026-06-01"); + expect(row?.days).toEqual(input.days); + + const persisted = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, input.monitorId)) + .all(); + expect(persisted.length).toBe(1); + + const auditRows = await readAuditLog({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + entityType: "frozen_monitor_uptime", + db: tx, + }); + expect(auditRows.length).toBe(0); + }); + }); + + test("re-run on same (monitorId, month) returns null and writes nothing", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...systemCtx, db: tx }; + const testMonitor = await insertTestMonitor(tx); + const input = makeInput(testMonitor.id); + + const first = await freezeMonitorMonth({ ctx, input }); + expect(first).not.toBeNull(); + + // same key, different payload: the frozen month must stay untouched + const second = await freezeMonitorMonth({ + ctx, + input: { + ...input, + days: [{ day: "2026-06-01", ok: 0, degraded: 0, error: 0 }], + }, + }); + expect(second).toBeNull(); + + const persisted = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, input.monitorId)) + .all(); + expect(persisted.length).toBe(1); + expect(persisted[0].days[0].ok).toBe(42); + + const auditRows = await readAuditLog({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + entityType: "frozen_monitor_uptime", + db: tx, + }); + expect(auditRows.length).toBe(0); + }); + }); + + test("same monitor, different month inserts a second row", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...systemCtx, db: tx }; + const testMonitor = await insertTestMonitor(tx); + const input = makeInput(testMonitor.id); + + const june = await freezeMonitorMonth({ ctx, input }); + const july = await freezeMonitorMonth({ + ctx, + input: { + ...input, + month: "2026-07-01", + days: [{ ...input.days[0], day: "2026-07-01" }], + }, + }); + expect(june).not.toBeNull(); + expect(july).not.toBeNull(); + + const persisted = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, input.monitorId)) + .all(); + expect(persisted.length).toBe(2); + }); + }); + + test("rejects a monitor from another workspace", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...systemCtx, db: tx }; + const foreignWorkspace = await tx + .insert(workspace) + .values({ slug: "svc-frozen-uptime-foreign-ws" }) + .returning() + .get(); + const foreignMonitor = await tx + .insert(monitor) + .values({ + workspaceId: foreignWorkspace.id, + active: true, + url: "https://example.com", + name: "svc-frozen-uptime-foreign-monitor", + method: "GET", + periodicity: "10m", + regions: "ams", + jobType: "http", + }) + .returning() + .get(); + + await expect( + freezeMonitorMonth({ ctx, input: makeInput(foreignMonitor.id) }), + ).rejects.toThrow(NotFoundError); + + const persisted = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, foreignMonitor.id)) + .all(); + expect(persisted.length).toBe(0); + }); + }); + + test("rejects read-only actor", async () => { + // requireScope fires before any DB lookup, so fake ids are fine + await expect( + freezeMonitorMonth({ ctx: readOnlyCtx, input: makeInput(1) }), + ).rejects.toThrow(ForbiddenError); + }); +}); diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts new file mode 100644 index 00000000..d4ec0b7b --- /dev/null +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -0,0 +1,809 @@ +import { + frozenMonitorUptime, + incidentTable, + monitor, + page, + pageComponent, + statusReport, + statusReportUpdate, + statusReportUpdateToPageComponents, + statusReportsToPageComponents, +} from "@openstatus/db/src/schema"; +import type { FrozenMonitorUptimeDay } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + loadSeededWorkspace, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import type { ComputeCountRow } from "../compute"; +import { monthDays } from "../compute"; +import { getUptimeHistory } from "../get-history"; +import type { UptimeFreezePipes } from "../run"; + +const MS_PER_DAY = 86_400_000; +const noSleep = () => Promise.resolve(); + +let userCtx: ServiceContext; +let freeCtx: ServiceContext; + +beforeAll(async () => { + userCtx = makeUserCtx(await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID)); + freeCtx = makeUserCtx(await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID), { + userId: 2, + }); +}); + +// tests anchor months to the real clock: getEvents filters against `new +// Date()` internally, so an artificial `now` would skew the event window +const now = new Date(); + +/** "YYYY-MM" for `offset` months before the current month (0 = current). */ +function key(offset: number): string { + const d = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - offset, 1), + ); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; +} + +function monthStart(k: string): Date { + const [y, m] = k.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, 1)); +} + +function monthEnd(k: string): Date { + const [y, m] = k.split("-").map(Number); + return new Date(Date.UTC(y, m, 1)); +} + +/** Full zero-filled month with `counts` applied to every day. */ +function fullMonth( + k: string, + counts: { ok?: number; degraded?: number; error?: number }, +): FrozenMonitorUptimeDay[] { + return monthDays(`${k}-01`).map((day) => ({ + day, + ok: counts.ok ?? 0, + degraded: counts.degraded ?? 0, + error: counts.error ?? 0, + })); +} + +function makePipes(rows: ComputeCountRow[]): UptimeFreezePipes { + const pipe = () => Promise.resolve({ data: rows }); + return { http: pipe, tcp: pipe, dns: pipe }; +} + +function failingPipes(): UptimeFreezePipes { + const pipe = () => Promise.reject(new Error("tinybird down")); + return { http: pipe, tcp: pipe, dns: pipe }; +} + +type Tx = Parameters[0]>[0]; + +let slugCounter = 0; + +async function insertPage( + tx: Tx, + overrides: Partial = {}, +) { + return tx + .insert(page) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + title: "svc-history-page", + description: "", + slug: `svc-history-${Date.now()}-${slugCounter++}`, + customDomain: "", + ...overrides, + }) + .returning() + .get(); +} + +async function insertMonitor(tx: Tx) { + return tx + .insert(monitor) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + active: true, + url: "https://example.com", + name: "svc-history-monitor", + method: "GET", + periodicity: "10m", + regions: "ams", + jobType: "http", + }) + .returning() + .get(); +} + +async function insertComponent( + tx: Tx, + args: { + pageId: number; + monitorId?: number; + createdAt?: Date; + name?: string; + }, +) { + return tx + .insert(pageComponent) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + pageId: args.pageId, + type: args.monitorId ? "monitor" : "static", + monitorId: args.monitorId ?? null, + name: args.name ?? "svc-history-component", + createdAt: args.createdAt ?? now, + }) + .returning() + .get(); +} + +async function insertFrozen( + tx: Tx, + args: { monitorId: number; month: string; days: FrozenMonitorUptimeDay[] }, +) { + return tx + .insert(frozenMonitorUptime) + .values({ workspaceId: SEEDED_WORKSPACE_TEAM_ID, ...args }) + .returning() + .get(); +} + +/** Resolved impact report against one component: `impact` from → to. */ +async function insertImpactReport( + tx: Tx, + args: { + pageId: number; + pageComponentId: number; + impact: "major_outage" | "partial_outage"; + from: Date; + to: Date; + }, +) { + const report = await tx + .insert(statusReport) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + pageId: args.pageId, + status: "resolved", + title: "svc-history-report", + }) + .returning() + .get(); + await tx.insert(statusReportsToPageComponents).values({ + statusReportId: report.id, + pageComponentId: args.pageComponentId, + }); + const open = await tx + .insert(statusReportUpdate) + .values({ + statusReportId: report.id, + status: "identified", + date: args.from, + message: "down", + }) + .returning() + .get(); + await tx.insert(statusReportUpdateToPageComponents).values({ + statusReportUpdateId: open.id, + pageComponentId: args.pageComponentId, + impact: args.impact, + }); + const close = await tx + .insert(statusReportUpdate) + .values({ + statusReportId: report.id, + status: "resolved", + date: args.to, + message: "up", + }) + .returning() + .get(); + await tx.insert(statusReportUpdateToPageComponents).values({ + statusReportUpdateId: close.id, + pageComponentId: args.pageComponentId, + impact: "operational", + }); + return report; +} + +/** Legacy report: membership + updates but NO per-update impact rows. */ +async function insertLegacyReport( + tx: Tx, + args: { + pageId: number; + pageComponentIds: number[]; + from: Date; + to: Date; + }, +) { + const report = await tx + .insert(statusReport) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + pageId: args.pageId, + status: "resolved", + title: "svc-history-legacy-report", + }) + .returning() + .get(); + for (const pageComponentId of args.pageComponentIds) { + await tx.insert(statusReportsToPageComponents).values({ + statusReportId: report.id, + pageComponentId, + }); + } + await tx.insert(statusReportUpdate).values({ + statusReportId: report.id, + status: "identified", + date: args.from, + message: "down", + }); + await tx.insert(statusReportUpdate).values({ + statusReportId: report.id, + status: "resolved", + date: args.to, + message: "up", + }); + return report; +} + +describe("getUptimeHistory", () => { + test("legacy report (no impact rows) counts full duration for static components", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testPage = await insertPage(tx); + const createdAt = monthStart(key(1)); + const component = await insertComponent(tx, { + pageId: testPage.id, + createdAt, + }); + + const from = new Date(createdAt.getTime() + 2 * MS_PER_DAY); + const twelveHours = 12 * 3_600_000; + await insertLegacyReport(tx, { + pageId: testPage.id, + pageComponentIds: [component.id], + from, + to: new Date(from.getTime() + twelveHours), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); + const expected = + Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + expect(res.rows[0].months[key(1)]).toBe(expected); + }); + }); + + test("report member never named by impact rows still counts full duration (empty projection)", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testPage = await insertPage(tx); + const createdAt = monthStart(key(1)); + const memberOnly = await insertComponent(tx, { + pageId: testPage.id, + createdAt, + name: "member-only", + }); + const impacted = await insertComponent(tx, { + pageId: testPage.id, + createdAt, + name: "impacted", + }); + + // impact rows name only `impacted`; `memberOnly` joins as plain member, + // so its projection is an EMPTY impactIntervals array, not undefined + const from = new Date(createdAt.getTime() + 2 * MS_PER_DAY); + const twelveHours = 12 * 3_600_000; + const report = await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: impacted.id, + impact: "partial_outage", + from, + to: new Date(from.getTime() + twelveHours), + }); + await tx.insert(statusReportsToPageComponents).values({ + statusReportId: report.id, + pageComponentId: memberOnly.id, + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); + const legacyExpected = + Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + const partialExpected = + Math.floor(((totalMs - twelveHours / 2) / totalMs) * 10_000) / 100; + + const byName = new Map(res.rows.map((r) => [r.component.name, r])); + // empty projection falls through to legacy full-duration downtime + expect(byName.get("member-only")?.months[key(1)]).toBe(legacyExpected); + // the impacted component keeps its weighted (0.5) interval + expect(byName.get("impacted")?.months[key(1)]).toBe(partialExpected); + }); + }); + + test("requests mode: frozen months + live current/previous, older unfrozen months null", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(2)}-01`, + days: fullMonth(key(2), { ok: 100 }), + }); + // previous month NOT frozen (freeze runs on the 10th) → served live + const pipes = makePipes([ + { + monitorId: String(testMonitor.id), + day: `${key(1)}-02`, + ok: 99, + degraded: 0, + error: 1, + }, + { + monitorId: String(testMonitor.id), + day: `${key(0)}-01`, + ok: 50, + degraded: 0, + error: 0, + }, + ]); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes, + now, + sleep: noSleep, + }); + + expect(res.mode).toBe("requests"); + expect(res.months.length).toBe(24); + expect(res.months[res.months.length - 1]).toBe(key(0)); + const row = res.rows[0]; + expect(row.component.type).toBe("monitor"); + expect(row.months[key(2)]).toBe(100); + expect(row.months[key(1)]).toBe(99); + expect(row.months[key(0)]).toBe(100); + // aged past the TB window without a freeze → no data, never down + expect(row.months[key(3)]).toBe(null); + expect(res.createdAt?.getTime()).toBe(testPage.createdAt?.getTime()); + }); + }); + + test("tinybird failure: live months degrade to null, frozen months still served", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days: fullMonth(key(1), { ok: 10 }), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: failingPipes(), + now, + sleep: noSleep, + }); + + const row = res.rows[0]; + expect(row.months[key(1)]).toBe(100); + expect(row.months[key(0)]).toBe(null); + }); + }); + + test("a 0/0/0 month is null, not 0% — and floor rounding never shows 100.00 with a failed check", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(2)}-01`, + days: fullMonth(key(2), {}), + }); + const days = fullMonth(key(1), {}); + days[0] = { day: days[0].day, ok: 99_999, degraded: 0, error: 1 }; + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days, + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + const row = res.rows[0]; + expect(row.months[key(2)]).toBe(null); + expect(row.months[key(1)]).toBe(99.99); + // live month with zero pipe rows is also no-data + expect(row.months[key(0)]).toBe(null); + }); + }); + + test("rolling totals are additive across months, not averaged percentages", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + const up = fullMonth(key(2), {}); + up[0] = { day: up[0].day, ok: 3000, degraded: 0, error: 0 }; + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(2)}-01`, + days: up, + }); + const down = fullMonth(key(1), {}); + down[0] = { day: down[0].day, ok: 0, degraded: 0, error: 1000 }; + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days: down, + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + const row = res.rows[0]; + expect(row.months[key(2)]).toBe(100); + expect(row.months[key(1)]).toBe(0); + // additive: 3000/4000 = 75.00; a naive percentage average would say 50 + expect(row.rolling["6"]).toBe(75); + expect(row.rolling["24"]).toBe(75); + }); + }); + + test("duration mode: overlapping incident + report count once, partial weighs 0.5, no-counts month stays null", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx, { + configuration: { value: "duration" }, + }); + const component = await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days: fullMonth(key(1), { ok: 10 }), + }); + + const base = monthStart(key(1)).getTime() + 5 * MS_PER_DAY; + const twoHours = 2 * 3_600_000; + // incident and major report describe the same 2h outage → merged once + await tx.insert(incidentTable).values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + monitorId: testMonitor.id, + startedAt: new Date(base), + createdAt: new Date(base), + resolvedAt: new Date(base + twoHours), + }); + await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: component.id, + impact: "major_outage", + from: new Date(base), + to: new Date(base + twoHours), + }); + // disjoint partial outage of 2h → weighs 1h + await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: component.id, + impact: "partial_outage", + from: new Date(base + 10 * MS_PER_DAY), + to: new Date(base + 10 * MS_PER_DAY + twoHours), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + expect(res.mode).toBe("duration"); + const row = res.rows[0]; + const totalMs = monthDays(`${key(1)}-01`).length * MS_PER_DAY; + const expected = + Math.floor(((totalMs - 3 * 3_600_000) / totalMs) * 10_000) / 100; + expect(row.months[key(1)]).toBe(expected); + // events overlap key(2) not at all and it has no counts → null anyway + expect(row.months[key(2)]).toBe(null); + // reports metric counts status reports only, not checker incidents + expect(res.summary["6"].reports).toBe(2); + }); + }); + + test("duration mode: downtime during a paused stretch doesn't zero the month", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx, { + configuration: { value: "duration" }, + }); + const component = await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + // checks on days 1-5 and 25-28 only; the monitor was paused in between + const days = fullMonth(key(1), {}); + for (const i of [0, 1, 2, 3, 4, 24, 25, 26, 27]) { + days[i] = { day: days[i].day, ok: 10, degraded: 0, error: 0 }; + } + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days, + }); + + // 12-day outage report entirely inside the paused gap + const start = monthStart(key(1)).getTime(); + await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: component.id, + impact: "major_outage", + from: new Date(start + 7 * MS_PER_DAY), + to: new Date(start + 19 * MS_PER_DAY), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + // downtime is clipped to checked days: the gap outage contributes 0, + // so the month is 100%, not max(0, 9d - 12d) = 0% + expect(res.rows[0].months[key(1)]).toBe(100); + }); + }); + + test("duration mode: in-progress month clamps today to elapsed time", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx, { + configuration: { value: "duration" }, + }); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + // pretend it's noon on the 2nd of the current month, checks on both days + const start = monthStart(key(0)).getTime(); + const injectedNow = new Date(start + MS_PER_DAY + 12 * 3_600_000); + const pipes = makePipes([ + { + monitorId: String(testMonitor.id), + day: `${key(0)}-01`, + ok: 10, + degraded: 0, + error: 0, + }, + { + monitorId: String(testMonitor.id), + day: `${key(0)}-02`, + ok: 10, + degraded: 0, + error: 0, + }, + ]); + const twoHours = 2 * 3_600_000; + await tx.insert(incidentTable).values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + monitorId: testMonitor.id, + startedAt: new Date(start + twoHours), + createdAt: new Date(start + twoHours), + resolvedAt: new Date(start + 2 * twoHours), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes, + now: injectedNow, + sleep: noSleep, + }); + + // denominator = elapsed 36h (not 48h): 2h down → ~94.44, not 95.83 + const lastDayEnd = Date.parse(`${key(0)}-02T23:59:59.999Z`); + const total = 2 * MS_PER_DAY - (lastDayEnd - injectedNow.getTime()); + const expected = Math.floor(((total - twoHours) / total) * 10_000) / 100; + expect(res.rows[0].months[key(0)]).toBe(expected); + expect(expected).toBeLessThan(95); + }); + }); + + test("duration mode: sparse month uses days-with-checks as denominator", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx, { + configuration: { value: "duration" }, + }); + const component = await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + + // checks on only the first 10 days of the month + const days = fullMonth(key(1), {}); + for (let i = 0; i < 10; i++) { + days[i] = { day: days[i].day, ok: 10, degraded: 0, error: 0 }; + } + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: `${key(1)}-01`, + days, + }); + + const from = new Date(monthStart(key(1)).getTime() + 2 * MS_PER_DAY); + await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: component.id, + impact: "major_outage", + from, + to: new Date(from.getTime() + MS_PER_DAY), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + // 1 day down of 10 days with checks = 90.00 — a full-month denominator + // would dilute it to ~96.7 + expect(res.rows[0].months[key(1)]).toBe(90); + }); + }); + + test("static component: event-derived, months before creation are null", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testPage = await insertPage(tx); + const createdAt = monthStart(key(1)); + const component = await insertComponent(tx, { + pageId: testPage.id, + createdAt, + }); + + const from = new Date(createdAt.getTime() + 2 * MS_PER_DAY); + const sixHours = 6 * 3_600_000; + await insertImpactReport(tx, { + pageId: testPage.id, + pageComponentId: component.id, + impact: "major_outage", + from, + to: new Date(from.getTime() + sixHours), + }); + + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }); + + const row = res.rows[0]; + expect(row.component.type).toBe("static"); + expect(row.months[key(2)]).toBe(null); + const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); + const expected = + Math.floor(((totalMs - sixHours) / totalMs) * 10_000) / 100; + expect(row.months[key(1)]).toBe(expected); + // current month: no events → clean so far + expect(row.months[key(0)]).toBe(100); + }); + }); + + test("throws ForbiddenError when plan disables uptime-history", async () => { + await withTestTransaction(async (tx) => { + // Free plan: limits["uptime-history"] === false. Guard fires before + // any page lookup, so a fake id is fine. + await expect( + getUptimeHistory({ + ctx: { ...freeCtx, db: tx }, + input: { pageId: 999_999 }, + pipes: makePipes([]), + now, + sleep: noSleep, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("workspace scoping: another workspace's page is NotFound", async () => { + await withTestTransaction(async (tx) => { + const testPage = await insertPage(tx); + // enable the limit so the scoping check (not the plan gate) is what fires + const otherCtx = { + ...freeCtx, + workspace: { + ...freeCtx.workspace, + limits: { ...freeCtx.workspace.limits, "uptime-history": true }, + }, + db: tx, + }; + await expect( + getUptimeHistory({ + ctx: otherCtx, + input: { pageId: testPage.id }, + pipes: makePipes([]), + now, + sleep: noSleep, + }), + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/packages/services/src/frozen-uptime/__tests__/run.test.ts b/packages/services/src/frozen-uptime/__tests__/run.test.ts new file mode 100644 index 00000000..a973fb1d --- /dev/null +++ b/packages/services/src/frozen-uptime/__tests__/run.test.ts @@ -0,0 +1,483 @@ +import { eq } from "@openstatus/db"; +import { frozenMonitorUptime, monitor } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { withTestTransaction } from "../../../test/helpers"; +import type { ComputeCountRow } from "../compute"; +import { + type ChunkFailure, + type UptimeFreezePipes, + fetchFreezeCounts, + runUptimeFreeze, +} from "../run"; + +const noSleep = () => Promise.resolve(); + +function okPipe(rows: ComputeCountRow[] = []) { + const calls: string[][] = []; + const pipe = (params: { monitorIds: string[] }) => { + calls.push(params.monitorIds); + return Promise.resolve({ data: rows }); + }; + return { pipe, calls }; +} + +function makePipes( + overrides: Partial = {}, +): UptimeFreezePipes { + const fallback = okPipe().pipe; + return { + http: overrides.http ?? fallback, + tcp: overrides.tcp ?? fallback, + dns: overrides.dns ?? fallback, + }; +} + +type Tx = Parameters[0]>[0]; + +function insertTestMonitor( + tx: Tx, + overrides: Partial = {}, +) { + return tx + .insert(monitor) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + active: true, + url: "https://example.com", + name: "svc-uptime-freeze-monitor", + method: "GET", + periodicity: "10m", + regions: "ams", + jobType: "http", + ...overrides, + }) + .returning() + .get(); +} + +describe("fetchFreezeCounts", () => { + test("chunks ids and concatenates rows", async () => { + const row = (id: string): ComputeCountRow => ({ + monitorId: id, + day: "2026-06-01", + ok: 1, + degraded: 0, + error: 0, + }); + const calls: string[][] = []; + const http = (params: { monitorIds: string[] }) => { + calls.push(params.monitorIds); + return Promise.resolve({ data: params.monitorIds.map(row) }); + }; + const ids = Array.from({ length: 250 }, (_, i) => String(i + 1)); + + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType: new Map([["http", new Set(ids)]]), + pipes: makePipes({ http }), + chunkSize: 100, + sleep: noSleep, + }); + + expect(calls.map((c) => c.length)).toEqual([100, 100, 50]); + expect(counts.length).toBe(250); + expect(failedMonitorIds.size).toBe(0); + }); + + test("throttles between chunks, not before the first", async () => { + const delays: number[] = []; + const http = okPipe(); + const ids = Array.from({ length: 30 }, (_, i) => String(i + 1)); + + await fetchFreezeCounts({ + monitorIdsByJobType: new Map([["http", new Set(ids)]]), + pipes: makePipes({ http: http.pipe }), + chunkSize: 10, + throttleMs: 100, + sleep: (ms) => { + delays.push(ms); + return Promise.resolve(); + }, + }); + + expect(http.calls.length).toBe(3); + expect(delays).toEqual([100, 100]); + }); + + test("retries with backoff, then succeeds without marking failures", async () => { + let attempts = 0; + const delays: number[] = []; + const flaky = () => { + attempts++; + if (attempts < 3) return Promise.reject(new Error("tb 500")); + return Promise.resolve({ + data: [ + { monitorId: "1", day: "2026-06-01", ok: 5, degraded: 0, error: 0 }, + ], + }); + }; + + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType: new Map([["tcp", new Set(["1"])]]), + pipes: makePipes({ tcp: flaky }), + sleep: (ms) => { + delays.push(ms); + return Promise.resolve(); + }, + }); + + expect(attempts).toBe(3); + expect(delays).toEqual([1_000, 2_000]); + expect(counts.length).toBe(1); + expect(failedMonitorIds.size).toBe(0); + }); + + test("exhausted retries mark every id in the chunk failed, never zero-count rows", async () => { + const failures: ChunkFailure[] = []; + const dead = () => Promise.reject(new Error("tb down")); + + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType: new Map([["http", new Set(["1", "2"])]]), + pipes: makePipes({ http: dead }), + attempts: 3, + sleep: noSleep, + onChunkFailure: (f) => failures.push(f), + }); + + expect(counts).toEqual([]); + expect([...failedMonitorIds].sort()).toEqual(["1", "2"]); + expect(failures.length).toBe(1); + expect(failures[0].jobType).toBe("http"); + expect((failures[0].error as Error).message).toBe("tb down"); + }); + + test("job types without a status pipe are skipped, not failed", async () => { + const http = okPipe(); + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType: new Map([ + ["icmp", new Set(["9"])], + ["ssl", new Set(["10"])], + ]), + pipes: makePipes({ http: http.pipe }), + sleep: noSleep, + }); + + expect(http.calls.length).toBe(0); + expect(counts).toEqual([]); + expect(failedMonitorIds.size).toBe(0); + }); + + test("one job type failing does not block the others", async () => { + const dead = () => Promise.reject(new Error("tb down")); + const dns = okPipe([ + { monitorId: "2", day: "2026-06-01", ok: 3, degraded: 0, error: 0 }, + ]); + + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType: new Map([ + ["http", new Set(["1"])], + ["dns", new Set(["2"])], + ]), + pipes: makePipes({ http: dead, dns: dns.pipe }), + sleep: noSleep, + onChunkFailure: () => undefined, + }); + + expect(failedMonitorIds.has("1")).toBe(true); + expect(counts.map((c) => c.monitorId)).toEqual(["2"]); + }); +}); + +// requires the frozen_monitor_uptime migration (like freeze.test.ts) +describe("runUptimeFreeze", () => { + test("freezes previous month; re-run skips frozen monitors without refetching TB", async () => { + await withTestTransaction(async (tx) => { + const testMonitor = await insertTestMonitor(tx); + + const http = okPipe([ + { + monitorId: String(testMonitor.id), + day: "2026-06-05T00:00:00.000Z", + ok: 42, + degraded: 0, + error: 0, + }, + ]); + const runArgs = { + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [testMonitor.id], + sleep: noSleep, + }; + + const first = await runUptimeFreeze(runArgs); + expect(first.month).toBe("2026-06-01"); + expect(first.frozen).toBe(1); + expect(first.alreadyFrozen).toBe(0); + expect(first.failures).toEqual([]); + expect(http.calls).toEqual([[String(testMonitor.id)]]); + + const rows = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, testMonitor.id)) + .all(); + expect(rows.length).toBe(1); + expect(rows[0].workspaceId).toBe(SEEDED_WORKSPACE_TEAM_ID); + expect(rows[0].days.length).toBe(30); + expect(rows[0].days[4].ok).toBe(42); + expect(rows[0].days[0].ok).toBe(0); + + // re-run: frozen monitors are pre-skipped — no TB refetch, no insert + const second = await runUptimeFreeze(runArgs); + expect(second.frozen).toBe(0); + expect(second.alreadyFrozen).toBe(1); + expect(http.calls.length).toBe(1); + }); + }); + + test("monitor without counts in the month gets no row — silent, not a failure", async () => { + await withTestTransaction(async (tx) => { + const paused = await insertTestMonitor(tx, { active: false }); + + const res = await runUptimeFreeze({ + pipes: makePipes(), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [paused.id], + sleep: noSleep, + }); + + expect(res.frozen).toBe(0); + expect(res.skipped).toBe(0); + expect(res.failures).toEqual([]); + + const rows = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, paused.id)) + .all(); + expect(rows).toEqual([]); + }); + }); + + test("deleted monitors are excluded before any TB fetch", async () => { + await withTestTransaction(async (tx) => { + const deleted = await insertTestMonitor(tx, { deletedAt: new Date() }); + const http = okPipe(); + + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [deleted.id], + sleep: noSleep, + }); + + expect(res.frozen).toBe(0); + expect(res.alreadyFrozen).toBe(0); + expect(http.calls.length).toBe(0); + }); + }); + + test("refuses to freeze once the month is past the 45d Tinybird window", async () => { + await withTestTransaction(async (tx) => { + const testMonitor = await insertTestMonitor(tx); + const http = okPipe(); + + // freezing July on Aug 20: July 1 is 50 days back, beyond the pipes' + // 45d lookback — writing would freeze permanent zero-count days + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 7, 20)), + db: tx, + monitorIds: [testMonitor.id], + sleep: noSleep, + }); + + expect(http.calls.length).toBe(0); + expect(res.month).toBe("2026-07-01"); + expect(res.frozen).toBe(0); + expect(res.failures.length).toBe(1); + expect(res.failures[0]).toContain("45d"); + + const rows = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, testMonitor.id)) + .all(); + expect(rows).toEqual([]); + }); + }); + + test("still freezes at the end of the retry runway (day 15)", async () => { + await withTestTransaction(async (tx) => { + const testMonitor = await insertTestMonitor(tx); + const http = okPipe([ + { + monitorId: String(testMonitor.id), + day: "2026-06-05T00:00:00.000Z", + ok: 42, + degraded: 0, + error: 0, + }, + ]); + + // July 15 is 44 days after June 1 — inside the window + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 15)), + db: tx, + monitorIds: [testMonitor.id], + sleep: noSleep, + }); + + expect(res.month).toBe("2026-06-01"); + expect(res.frozen).toBe(1); + expect(res.failures).toEqual([]); + }); + }); + + test("inactive monitor untouched since before the month is skipped without a TB fetch", async () => { + await withTestTransaction(async (tx) => { + // paused in May, freezing June: provably inactive the whole month + const paused = await insertTestMonitor(tx, { + active: false, + updatedAt: new Date(Date.UTC(2026, 4, 15)), + }); + const http = okPipe(); + + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [paused.id], + sleep: noSleep, + }); + + expect(http.calls.length).toBe(0); + expect(res.frozen).toBe(0); + expect(res.failures).toEqual([]); + + const rows = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, paused.id)) + .all(); + expect(rows).toEqual([]); + }); + }); + + test("inactive monitor paused mid-month is checked and its partial counts frozen", async () => { + await withTestTransaction(async (tx) => { + // active until June 20, then paused: June 1-19 has real counts + const paused = await insertTestMonitor(tx, { + active: false, + updatedAt: new Date(Date.UTC(2026, 5, 20)), + }); + const http = okPipe([ + { + monitorId: String(paused.id), + day: "2026-06-05T00:00:00.000Z", + ok: 42, + degraded: 0, + error: 0, + }, + ]); + + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [paused.id], + sleep: noSleep, + }); + + expect(http.calls).toEqual([[String(paused.id)]]); + expect(res.frozen).toBe(1); + + const rows = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, paused.id)) + .all(); + expect(rows.length).toBe(1); + expect(rows[0].days[4].ok).toBe(42); + }); + }); + + test("inactive monitor paused after the month is still checked", async () => { + await withTestTransaction(async (tx) => { + // paused July 3 while freezing June: could have been active all June + const paused = await insertTestMonitor(tx, { + active: false, + updatedAt: new Date(Date.UTC(2026, 6, 3)), + }); + const http = okPipe(); + + const res = await runUptimeFreeze({ + pipes: makePipes({ http: http.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [paused.id], + sleep: noSleep, + }); + + // checked against TB; no counts came back, so nothing is frozen + expect(http.calls).toEqual([[String(paused.id)]]); + expect(res.frozen).toBe(0); + expect(res.failures).toEqual([]); + }); + }); + + test("failed TB chunk skips its monitors but freezes the rest", async () => { + await withTestTransaction(async (tx) => { + const httpMonitor = await insertTestMonitor(tx); + const dnsMonitor = await insertTestMonitor(tx, { jobType: "dns" }); + + const dead = () => Promise.reject(new Error("tb down")); + const dns = okPipe([ + { + monitorId: String(dnsMonitor.id), + day: "2026-06-05T00:00:00.000Z", + ok: 7, + degraded: 0, + error: 0, + }, + ]); + + const res = await runUptimeFreeze({ + pipes: makePipes({ http: dead, dns: dns.pipe }), + now: new Date(Date.UTC(2026, 6, 10)), + db: tx, + monitorIds: [httpMonitor.id, dnsMonitor.id], + sleep: noSleep, + onChunkFailure: () => undefined, + }); + + expect(res.frozen).toBe(1); + expect(res.skipped).toBe(1); + expect(res.failures).toEqual([ + `monitor ${httpMonitor.id}: tinybird counts unavailable`, + ]); + + const frozenHttp = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, httpMonitor.id)) + .all(); + expect(frozenHttp).toEqual([]); + + const frozenDns = await tx + .select() + .from(frozenMonitorUptime) + .where(eq(frozenMonitorUptime.monitorId, dnsMonitor.id)) + .all(); + expect(frozenDns.length).toBe(1); + expect(frozenDns[0].days[4].ok).toBe(7); + }); + }); +}); diff --git a/packages/services/src/frozen-uptime/__tests__/schemas.test.ts b/packages/services/src/frozen-uptime/__tests__/schemas.test.ts new file mode 100644 index 00000000..1250673d --- /dev/null +++ b/packages/services/src/frozen-uptime/__tests__/schemas.test.ts @@ -0,0 +1,46 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { FreezeMonitorMonthInput } from "../schemas"; + +const valid: FreezeMonitorMonthInput = { + monitorId: 1, + month: "2026-06-01", + days: [{ day: "2026-06-01", ok: 10, degraded: 1, error: 0 }], +}; + +describe("FreezeMonitorMonthInput", () => { + test("accepts a valid row", () => { + expect(FreezeMonitorMonthInput.parse(valid)).toEqual(valid); + }); + + test("rejects a month that is not first-of-month", () => { + expect(() => + FreezeMonitorMonthInput.parse({ ...valid, month: "2026-06-15" }), + ).toThrow(); + expect(() => + FreezeMonitorMonthInput.parse({ ...valid, month: "2026-06" }), + ).toThrow(); + }); + + test("rejects malformed days", () => { + expect(() => + FreezeMonitorMonthInput.parse({ + ...valid, + days: [{ day: "2026-06-01", ok: 0 }], + }), + ).toThrow(); + expect(() => + FreezeMonitorMonthInput.parse({ + ...valid, + days: [{ day: "2026-06-01T00:00:00Z", ok: 0, degraded: 0, error: 0 }], + }), + ).toThrow(); + expect(() => + FreezeMonitorMonthInput.parse({ + ...valid, + days: [{ day: "2026-06-01", ok: -1, degraded: 0, error: 0 }], + }), + ).toThrow(); + }); +}); diff --git a/packages/services/src/frozen-uptime/compute.ts b/packages/services/src/frozen-uptime/compute.ts new file mode 100644 index 00000000..4d74a35b --- /dev/null +++ b/packages/services/src/frozen-uptime/compute.ts @@ -0,0 +1,79 @@ +import type { FrozenMonitorUptimeDay } from "@openstatus/db/src/schema"; + +export type ComputeCountRow = { + monitorId: string; + day: string; // ISO or YYYY-MM-DD; sliced to YYYY-MM-DD here + ok: number; + degraded: number; + error: number; +}; + +// UTC first-of-month of the month before `now`, YYYY-MM-01 +export function previousMonth(now: Date): string { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + return `${d.getUTCFullYear()}-${mm}-01`; +} + +// [start of month, start of next month) in UTC ms +export function monthRange(month: string): { start: number; end: number } { + const [year, mo] = month.split("-").map(Number); + return { + start: Date.UTC(year, mo - 1, 1), + end: Date.UTC(year, mo, 1), + }; +} + +export function monthDays(month: string): string[] { + const [year, mo] = month.split("-").map(Number); + const count = new Date(Date.UTC(year, mo, 0)).getUTCDate(); + const prefix = month.slice(0, 8); // YYYY-MM- + return Array.from( + { length: count }, + (_, i) => `${prefix}${String(i + 1).padStart(2, "0")}`, + ); +} + +/** + * Pure assembly of one monitor's frozen month from already-fetched count + * rows — no I/O. Returns null when the monitor has no counts in the month + * (paused, or created later): no row is frozen rather than an all-zero month. + */ +export function computeMonitorMonth(args: { + month: string; // YYYY-MM-01 + monitorId: number; + counts: ComputeCountRow[]; +}): { days: FrozenMonitorUptimeDay[] } | null { + const { month, monitorId, counts } = args; + const prefix = month.slice(0, 8); // YYYY-MM- + + // sum rather than overwrite: the pipe aggregates per day today, but a + // silent overwrite here would freeze truncated counts permanently + const byDay = new Map< + string, + { ok: number; degraded: number; error: number } + >(); + for (const row of counts) { + if (row.monitorId !== String(monitorId)) continue; + const day = row.day.slice(0, 10); + if (!day.startsWith(prefix)) continue; + const acc = byDay.get(day) ?? { ok: 0, degraded: 0, error: 0 }; + acc.ok += row.ok; + acc.degraded += row.degraded; + acc.error += row.error; + byDay.set(day, acc); + } + if (byDay.size === 0) return null; + + const days = monthDays(month).map((day) => { + const row = byDay.get(day); + return { + day, + ok: row?.ok ?? 0, + degraded: row?.degraded ?? 0, + error: row?.error ?? 0, + }; + }); + + return { days }; +} diff --git a/packages/services/src/frozen-uptime/freeze.ts b/packages/services/src/frozen-uptime/freeze.ts new file mode 100644 index 00000000..56a55391 --- /dev/null +++ b/packages/services/src/frozen-uptime/freeze.ts @@ -0,0 +1,48 @@ +import { db as defaultDb } from "@openstatus/db"; +import { + type FrozenMonitorUptime, + frozenMonitorUptime, + selectFrozenMonitorUptimeSchema, +} from "@openstatus/db/src/schema"; + +import { requireScope } from "../auth"; +import type { ServiceContext } from "../context"; +import { getMonitorInWorkspace } from "../monitor/internal"; +import { withBusyRetry } from "../retry"; +import { FreezeMonitorMonthInput } from "./schemas"; + +/** + * Write-once freeze of one (monitor, month). Returns the inserted row, or + * null when the month was already frozen (silent re-run). Deliberately no + * audit row — system cron on a write-once table would be pure volume. + */ +export async function freezeMonitorMonth(args: { + ctx: ServiceContext; + input: FreezeMonitorMonthInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = FreezeMonitorMonthInput.parse(args.input); + + const db = ctx.db ?? defaultDb; + const owner = await withBusyRetry(() => + getMonitorInWorkspace({ + tx: db, + id: input.monitorId, + workspaceId: ctx.workspace.id, + }), + ); + // attribute the row to the monitor's own workspace, not the caller's claim; + // the scoped fetch above pins them equal (and non-null) + const rows = await withBusyRetry(() => + db + .insert(frozenMonitorUptime) + .values({ ...input, workspaceId: owner.workspaceId ?? ctx.workspace.id }) + .onConflictDoNothing({ + target: [frozenMonitorUptime.monitorId, frozenMonitorUptime.month], + }) + .returning(), + ); + const row = rows[0]; + return row ? selectFrozenMonitorUptimeSchema.parse(row) : null; +} diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts new file mode 100644 index 00000000..7411edf2 --- /dev/null +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -0,0 +1,403 @@ +import { and, eq, gte, inArray } from "@openstatus/db"; +import { + frozenMonitorUptime, + pageConfigurationSchema, +} from "@openstatus/db/src/schema"; + +import { type ServiceContext, defaultTb, getReadDb } from "../context"; +import { ForbiddenError, NotFoundError } from "../errors"; +import { + type Event, + dayCoverage, + durationDowntimeMs, + floorPct, + getEvents, + reportsOnlyDowntimeMs, + requestsTally, +} from "../status-timeline"; +import { type ComputeCountRow, monthRange } from "./compute"; +import { type UptimeFreezePipes, fetchFreezeCounts } from "./run"; +import { GetUptimeHistoryInput } from "./schemas"; + +const HISTORY_MONTHS = 24; + +const WINDOWS = [6, 12, 24] as const; +type HistoryWindowKey = "6" | "12" | "24"; + +// safe because HistoryWindowKey is exactly the string form of each WINDOWS entry +function windowKey(w: (typeof WINDOWS)[number]): HistoryWindowKey { + return String(w) as HistoryWindowKey; +} + +type DayCount = { day: string; ok: number; degraded: number; error: number }; + +// native-unit numerators kept alongside the percentage so rolling windows +// stay additive (checks for requests mode, milliseconds for event math); +// averaging monthly percentages would weigh a 2-day month like a full one +type MonthValue = { percentage: number; up: number; total: number } | null; + +type UptimeHistoryEvent = Pick< + Event, + "id" | "name" | "type" | "status" | "from" | "to" +>; + +type UptimeHistoryRow = { + component: { + id: number; + name: string; + type: "monitor" | "static"; + /** for event links, null for static components */ + monitorId: number | null; + }; + /** "YYYY-MM" → percentage; null = no data recorded, NEVER "down" */ + months: Record; + rolling: Record; + /** component events in the window; clients bucket per month by overlap */ + events: UptimeHistoryEvent[]; +}; + +type UptimeHistoryResult = { + mode: "requests" | "duration" | "manual"; + /** oldest → newest, length HISTORY_MONTHS, last entry = current month */ + months: string[]; + createdAt: Date | null; + summary: Record; + rows: UptimeHistoryRow[]; +}; + +function monthKeys(now: Date): string[] { + return Array.from({ length: HISTORY_MONTHS }, (_, i) => { + const d = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() - (HISTORY_MONTHS - 1 - i), + 1, + ), + ); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + return `${d.getUTCFullYear()}-${mm}`; + }); +} + +function requestsMonth(days: DayCount[] | null): MonthValue { + if (!days) return null; + const { up, total } = requestsTally(days); + if (total === 0) return null; + return { percentage: floorPct(up / total), up, total }; +} + +function durationMonth( + days: DayCount[] | null, + events: Event[], + nowMs: number, +): MonthValue { + // denominator = days with checks, matching getUptime's data.length; frozen + // months are zero-filled so counting all days would inflate sparse months + const withChecks = (days ?? []).filter( + (d) => d.ok + d.degraded + d.error > 0, + ); + if (withChecks.length === 0) return null; + // downtime is clipped to the checked days (a paused stretch can't exceed + // the denominator) and the in-progress day is clamped to elapsed time so + // 2h down on the 2nd isn't diluted by the rest of today + const dayStarts = withChecks.map((d) => Date.parse(`${d.day}T00:00:00.000Z`)); + const { segments, totalMs } = dayCoverage(dayStarts, nowMs); + if (segments.length === 0 || totalMs <= 0) return null; + const window = { + start: segments[0].start, + end: segments[segments.length - 1].end, + now: nowMs, + }; + const downtime = durationDowntimeMs(events, window, segments); + const up = Math.max(0, totalMs - downtime); + return { percentage: floorPct(up / totalMs), up, total: totalMs }; +} + +function eventOnlyMonth( + events: Event[], + key: string, + nowMs: number, + notBeforeMs?: number, +): MonthValue { + const { start, end } = monthRange(`${key}-01`); + // months fully before the component existed are "no data", not fake 100% + if (notBeforeMs !== undefined && end <= notBeforeMs) return null; + const clampedEnd = Math.min(end, nowMs); + if (clampedEnd <= start) return null; + const total = clampedEnd - start; + const downtime = reportsOnlyDowntimeMs(events, { + start, + end: clampedEnd, + now: nowMs, + }); + const up = Math.max(0, total - downtime); + return { percentage: floorPct(up / total), up, total }; +} + +/** + * Read-time uptime history for a page: frozen monthly counts + live Tinybird + * for months the freeze hasn't covered, percentages recomputed under the + * page's current calculation mode. + */ +export async function getUptimeHistory(args: { + ctx: ServiceContext; + input: GetUptimeHistoryInput; + pipes?: UptimeFreezePipes; + now?: Date; + sleep?: (ms: number) => Promise; +}): Promise { + const { ctx } = args; + const input = GetUptimeHistoryInput.parse(args.input); + if (!ctx.workspace.limits["uptime-history"]) { + throw new ForbiddenError("Uptime history is not enabled on this plan."); + } + const db = getReadDb(ctx); + const now = args.now ?? new Date(); + const nowMs = now.getTime(); + + const _page = await db.query.page.findFirst({ + where: (page, { and: andWhere, eq: eqWhere }) => + andWhere( + eqWhere(page.id, input.pageId), + eqWhere(page.workspaceId, ctx.workspace.id), + ), + with: { + statusReports: { + with: { + statusReportUpdates: { + orderBy: (updates, { desc }) => desc(updates.date), + with: { statusReportUpdateToPageComponents: true }, + }, + statusReportsToPageComponents: { with: { pageComponent: true } }, + }, + }, + maintenances: { + with: { + maintenancesToPageComponents: { with: { pageComponent: true } }, + }, + }, + pageComponents: { + with: { monitor: { with: { incidents: true } } }, + orderBy: (components, { asc }) => asc(components.order), + }, + }, + }); + if (!_page) throw new NotFoundError("page", input.pageId); + + const configuration = pageConfigurationSchema.safeParse( + _page.configuration ?? {}, + ); + const mode = configuration.success ? configuration.data.value : "requests"; + + const months = monthKeys(now); + const currentKey = months[months.length - 1]; + const previousKey = months[months.length - 2]; + + const components = _page.pageComponents; + const monitorIds = [ + ...new Set( + components.flatMap((c) => (c.monitorId !== null ? [c.monitorId] : [])), + ), + ]; + + // the frozen-rows read and the independent Tinybird round-trip overlap + const dbReads = (async () => { + if (monitorIds.length === 0) return { frozenRows: [] }; + const frozenRows = await db + .select({ + monitorId: frozenMonitorUptime.monitorId, + month: frozenMonitorUptime.month, + days: frozenMonitorUptime.days, + }) + .from(frozenMonitorUptime) + .where( + and( + eq(frozenMonitorUptime.workspaceId, ctx.workspace.id), + inArray(frozenMonitorUptime.monitorId, monitorIds), + gte(frozenMonitorUptime.month, `${months[0]}-01`), + ), + ); + return { frozenRows }; + })(); + + // current month is never frozen, the previous may not be yet (freeze runs + // on the 10th) — both come live from the 45d pipes; fetchFreezeCounts never + // throws, failed monitors land in failedMonitorIds and render as no-data + const liveReads = (async () => { + if (monitorIds.length === 0) { + return { + counts: [] as ComputeCountRow[], + failedMonitorIds: new Set(), + }; + } + const monitorIdsByJobType = new Map>(); + for (const c of components) { + if (c.monitorId === null || !c.monitor) continue; + const ids = monitorIdsByJobType.get(c.monitor.jobType) ?? new Set(); + ids.add(String(c.monitorId)); + monitorIdsByJobType.set(c.monitor.jobType, ids); + } + const pipes = args.pipes ?? { + http: defaultTb.httpStatus45d, + tcp: defaultTb.tcpStatus45d, + dns: defaultTb.dnsStatus45d, + }; + return fetchFreezeCounts({ + monitorIdsByJobType, + pipes, + throttleMs: 0, + sleep: args.sleep, + }); + })(); + + const [{ frozenRows }, { counts: liveCounts, failedMonitorIds: liveFailed }] = + await Promise.all([dbReads, liveReads]); + const frozenByKey = new Map( + frozenRows.map((r) => [`${r.monitorId}:${r.month.slice(0, 7)}`, r.days]), + ); + + const liveByMonitorMonth = new Map>(); + for (const row of liveCounts) { + const day = row.day.slice(0, 10); + const key = day.slice(0, 7); + if (key !== currentKey && key !== previousKey) continue; + const mapKey = `${row.monitorId}:${key}`; + const byDay = liveByMonitorMonth.get(mapKey) ?? new Map(); + const acc = byDay.get(day) ?? { day, ok: 0, degraded: 0, error: 0 }; + acc.ok += row.ok; + acc.degraded += row.degraded; + acc.error += row.error; + byDay.set(day, acc); + liveByMonitorMonth.set(mapKey, byDay); + } + + function countsFor(monitorId: number, key: string): DayCount[] | null { + const frozen = frozenByKey.get(`${monitorId}:${key}`); + if (frozen && key !== currentKey) return frozen; + // older unfrozen months are never reconstructed from the partial 45d + // overlap — backfill is the fix, not partial months + const isLive = key === currentKey || key === previousKey; + if (!isLive || liveFailed.has(String(monitorId))) return null; + const byDay = liveByMonitorMonth.get(`${monitorId}:${key}`); + if (!byDay || byDay.size === 0) return null; + return [...byDay.values()].sort((a, b) => (a.day < b.day ? -1 : 1)); + } + + const pastDays = HISTORY_MONTHS * 31 + 7; + const rows: UptimeHistoryRow[] = components.map((c) => { + const events = getEvents({ + maintenances: _page.maintenances, + incidents: c.monitor?.incidents ?? [], + reports: _page.statusReports, + pageComponentId: c.id, + monitorId: c.monitorId ?? undefined, + componentType: c.type, + pastDays, + }); + + const values = new Map(); + for (const key of months) { + let value: MonthValue = null; + if (c.type === "monitor" && c.monitorId !== null) { + const days = countsFor(c.monitorId, key); + if (mode === "requests") { + value = requestsMonth(days); + } else if (mode === "duration") { + value = durationMonth(days, events, nowMs); + } else { + // manual mode still keys "did the monitor run" off counts: a + // zero-check month has no meaningful uptime in any mode + value = days?.some((d) => d.ok + d.degraded + d.error > 0) + ? eventOnlyMonth(events, key, nowMs) + : null; + } + } else { + value = eventOnlyMonth( + events, + key, + nowMs, + c.createdAt?.getTime() ?? undefined, + ); + } + values.set(key, value); + } + + const rolling = {} as Record; + for (const w of WINDOWS) { + let up = 0; + let total = 0; + for (const key of months.slice(-w)) { + const v = values.get(key); + if (!v) continue; + up += v.up; + total += v.total; + } + rolling[windowKey(w)] = total > 0 ? floorPct(up / total) : null; + } + + return { + component: { + id: c.id, + name: c.name, + type: c.type === "static" ? ("static" as const) : ("monitor" as const), + monitorId: c.monitorId, + }, + months: Object.fromEntries( + months.map((k) => [k, values.get(k)?.percentage ?? null]), + ), + rolling, + events: events.map((e) => ({ + id: e.id, + name: e.name, + type: e.type, + status: e.status, + from: e.from, + to: e.to, + })), + }; + }); + + // page-level events (no component filter) for the report metric + const pageEvents = getEvents({ + maintenances: _page.maintenances, + incidents: [], + reports: _page.statusReports, + pastDays, + }); + + const summary = {} as UptimeHistoryResult["summary"]; + for (const w of WINDOWS) { + const windowStart = monthRange(`${months[months.length - w]}-01`).start; + const seen = new Set(); + for (const e of pageEvents) { + if (e.type !== "report") continue; + const endMs = e.to?.getTime() ?? nowMs; + if (e.from.getTime() <= nowMs && endMs >= windowStart) { + seen.add(e.id); + } + } + // components weigh equally — native units (checks vs ms) differ per + // component, so a cross-component sum would weight by unit volume + const wk = windowKey(w); + const uptimes = rows.flatMap((r) => + r.rolling[wk] !== null ? [r.rolling[wk] as number] : [], + ); + summary[wk] = { + uptime: + uptimes.length > 0 + ? Math.floor( + (uptimes.reduce((a, b) => a + b, 0) / uptimes.length) * 100, + ) / 100 + : null, + reports: seen.size, + }; + } + + return { + mode, + months, + createdAt: _page.createdAt, + summary, + rows, + }; +} diff --git a/packages/services/src/frozen-uptime/index.ts b/packages/services/src/frozen-uptime/index.ts new file mode 100644 index 00000000..23f17adf --- /dev/null +++ b/packages/services/src/frozen-uptime/index.ts @@ -0,0 +1,18 @@ +export { + type ComputeCountRow, + computeMonitorMonth, + monthDays, + monthRange, + previousMonth, +} from "./compute"; +export { freezeMonitorMonth } from "./freeze"; +export { getUptimeHistory } from "./get-history"; +export { + type ChunkFailure, + type RunUptimeFreezeResult, + type StatusPipeFn, + type UptimeFreezePipes, + fetchFreezeCounts, + runUptimeFreeze, +} from "./run"; +export { FreezeMonitorMonthInput } from "./schemas"; diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts new file mode 100644 index 00000000..767ba790 --- /dev/null +++ b/packages/services/src/frozen-uptime/run.ts @@ -0,0 +1,277 @@ +import { and, db as defaultDb, eq, inArray, isNull } from "@openstatus/db"; +import { + frozenMonitorUptime, + monitor, + selectWorkspaceSchema, + workspace, +} from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { + type ComputeCountRow, + computeMonitorMonth, + monthRange, + previousMonth, +} from "./compute"; +import { freezeMonitorMonth } from "./freeze"; + +export type StatusPipeFn = (params: { + monitorIds: string[]; +}) => Promise<{ data: ComputeCountRow[] }>; + +// only these job types have a 45d status pipe; others (icmp/udp/ssl) have no +// counts on the live status page either and are skipped +export type UptimeFreezePipes = Record<"http" | "tcp" | "dns", StatusPipeFn>; + +export type ChunkFailure = { + jobType: string; + monitorIds: string[]; + error: unknown; +}; + +// zod-bird sends monitorIds as one comma-joined GET query param — unbounded +// batches risk URL-length 400s +const TB_CHUNK_SIZE = 200; +const TB_ATTEMPTS = 3; +// spacing between chunk requests: keeps the monthly sweep from bursting TB +// (zod-bird already absorbs 429/5xx with its own internal retries) +const TB_THROTTLE_MS = 250; + +// the status pipes look back a fixed 45 days; past monthStart + 45d the +// earliest month days return no rows and would freeze as permanent zeros +const FREEZE_CUTOFF_MS = 45 * 86_400_000; + +function chunk(items: T[], size: number): T[][] { + if (size <= 0) throw new Error(`chunk size must be positive, got ${size}`); + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +function hasStatusPipe( + jobType: string | null | undefined, +): jobType is keyof UptimeFreezePipes { + return jobType === "http" || jobType === "tcp" || jobType === "dns"; +} + +/** + * Fetch daily counts for all monitors, chunked and retried. Monitors whose + * chunk still fails after retries land in `failedMonitorIds` — they are + * skipped (never frozen with silent zeros) and picked up by a re-run. + */ +export async function fetchFreezeCounts(args: { + monitorIdsByJobType: Map>; + pipes: UptimeFreezePipes; + chunkSize?: number; + attempts?: number; + throttleMs?: number; + sleep?: (ms: number) => Promise; + onChunkFailure?: (failure: ChunkFailure) => void; +}): Promise<{ counts: ComputeCountRow[]; failedMonitorIds: Set }> { + const chunkSize = args.chunkSize ?? TB_CHUNK_SIZE; + const attempts = args.attempts ?? TB_ATTEMPTS; + const throttleMs = args.throttleMs ?? TB_THROTTLE_MS; + const sleep = + args.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + + const counts: ComputeCountRow[] = []; + const failedMonitorIds = new Set(); + let firstChunk = true; + + for (const [jobType, ids] of args.monitorIdsByJobType) { + if (!hasStatusPipe(jobType)) continue; + const pipe = args.pipes[jobType]; + for (const monitorIds of chunk([...ids], chunkSize)) { + if (!firstChunk && throttleMs > 0) await sleep(throttleMs); + firstChunk = false; + let lastError: unknown; + let done = false; + for (let attempt = 0; attempt < attempts && !done; attempt++) { + try { + if (attempt > 0) await sleep(1000 * 2 ** (attempt - 1)); + const res = await pipe({ monitorIds }); + counts.push(...res.data); + done = true; + } catch (e) { + lastError = e; + } + } + if (!done) { + for (const id of monitorIds) failedMonitorIds.add(id); + args.onChunkFailure?.({ jobType, monitorIds, error: lastError }); + } + } + } + + return { counts, failedMonitorIds }; +} + +export type RunUptimeFreezeResult = { + month: string; + frozen: number; + alreadyFrozen: number; + skipped: number; + failures: string[]; +}; + +/** + * Freeze the previous calendar month for every non-deleted monitor. + * Pipes (and optionally db/now) are injected so the cron stays a thin + * env/Sentry wrapper and this orchestration is unit-testable. + */ +export async function runUptimeFreeze(args: { + pipes: UptimeFreezePipes; + now?: Date; + db?: DB; + monitorIds?: number[]; // scope a run (tests, manual retries); default all + sleep?: (ms: number) => Promise; + onChunkFailure?: (failure: ChunkFailure) => void; +}): Promise { + const db = args.db ?? defaultDb; + const now = args.now ?? new Date(); + const month = previousMonth(now); + const monthStart = new Date(monthRange(month).start); + + // refuse instead of writing wrong zeros into the write-once table; the + // failure surfaces via the cron's reportBackgroundError + if (now.getTime() - monthStart.getTime() >= FREEZE_CUTOFF_MS) { + return { + month, + frozen: 0, + alreadyFrozen: 0, + skipped: 0, + failures: [ + `month ${month}: past the 45d Tinybird window — freezing now would write permanent zero-count days`, + ], + }; + } + + // all non-deleted monitors, regardless of page attachment — months without + // counts are dropped by computeMonitorMonth anyway + const monitorRows = await db + .select({ + id: monitor.id, + workspaceId: monitor.workspaceId, + jobType: monitor.jobType, + active: monitor.active, + updatedAt: monitor.updatedAt, + }) + .from(monitor) + .where( + and( + isNull(monitor.deletedAt), + args.monitorIds ? inArray(monitor.id, args.monitorIds) : undefined, + ), + ); + // inactive + untouched since before the month ⇒ it was inactive the whole + // month (pausing bumps updatedAt), so skip the TB fetch entirely; inactive + // but updated during/after the month may have run part of it — check TB + const monitors = monitorRows.flatMap((m) => + m.workspaceId === null || + (!m.active && m.updatedAt !== null && m.updatedAt < monthStart) + ? [] + : [{ ...m, workspaceId: m.workspaceId }], + ); + + // re-runs only work the monitors that failed last time: those with a frozen + // row for this month are excluded before any TB fetch (the insert's + // onConflictDoNothing stays as the race backstop) + const frozenRows = await db + .select({ monitorId: frozenMonitorUptime.monitorId }) + .from(frozenMonitorUptime) + .where( + args.monitorIds + ? and( + eq(frozenMonitorUptime.month, month), + inArray(frozenMonitorUptime.monitorId, args.monitorIds), + ) + : eq(frozenMonitorUptime.month, month), + ); + const frozenMonitorIds = new Set(frozenRows.map((r) => r.monitorId)); + + const pending = monitors.filter((m) => !frozenMonitorIds.has(m.id)); + let alreadyFrozen = monitors.length - pending.length; + + const monitorIdsByJobType = new Map>(); + for (const m of pending) { + const ids = monitorIdsByJobType.get(m.jobType) ?? new Set(); + ids.add(String(m.id)); + monitorIdsByJobType.set(m.jobType, ids); + } + + const { counts, failedMonitorIds } = await fetchFreezeCounts({ + monitorIdsByJobType, + pipes: args.pipes, + sleep: args.sleep, + onChunkFailure: args.onChunkFailure, + }); + + // avoid an O(pending²) sweep: computeMonitorMonth scans linearly per call + const countsByMonitorId = new Map(); + for (const row of counts) { + const rows = countsByMonitorId.get(row.monitorId); + if (rows) rows.push(row); + else countsByMonitorId.set(row.monitorId, [row]); + } + + const workspaceIds = [...new Set(pending.map((m) => m.workspaceId))]; + const workspaceRows = + workspaceIds.length > 0 + ? await db + .select() + .from(workspace) + .where(inArray(workspace.id, workspaceIds)) + : []; + const workspacesById = new Map( + workspaceRows.map((w) => [w.id, selectWorkspaceSchema.parse(w)]), + ); + + let frozen = 0; + let skipped = 0; + const failures: string[] = []; + + for (const m of pending) { + if (failedMonitorIds.has(String(m.id))) { + skipped++; + failures.push(`monitor ${m.id}: tinybird counts unavailable`); + continue; + } + + // no counts in the month (paused, created later, or no status pipe for + // the job type): nothing to freeze — silent, not a failure + const computed = computeMonitorMonth({ + month, + monitorId: m.id, + counts: countsByMonitorId.get(String(m.id)) ?? [], + }); + if (!computed) continue; + + const ws = workspacesById.get(m.workspaceId); + if (!ws) { + failures.push(`monitor ${m.id}: workspace ${m.workspaceId} not found`); + continue; + } + const ctx = { + workspace: ws, + actor: { type: "system" as const, job: "uptime-freeze" }, + db, + }; + + try { + const inserted = await freezeMonitorMonth({ + ctx, + input: { monitorId: m.id, month, days: computed.days }, + }); + if (inserted) frozen++; + else alreadyFrozen++; + } catch (e) { + failures.push( + `monitor ${m.id}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + return { month, frozen, alreadyFrozen, skipped, failures }; +} diff --git a/packages/services/src/frozen-uptime/schemas.ts b/packages/services/src/frozen-uptime/schemas.ts new file mode 100644 index 00000000..2700d93d --- /dev/null +++ b/packages/services/src/frozen-uptime/schemas.ts @@ -0,0 +1,15 @@ +import { insertFrozenMonitorUptimeSchema } from "@openstatus/db/src/schema"; +import { z } from "zod"; + +// workspaceId comes from ctx; id/createdAt from the db +export const FreezeMonitorMonthInput = insertFrozenMonitorUptimeSchema.omit({ + id: true, + workspaceId: true, + createdAt: true, +}); +export type FreezeMonitorMonthInput = z.infer; + +export const GetUptimeHistoryInput = z.object({ + pageId: z.number().int().positive(), +}); +export type GetUptimeHistoryInput = z.infer; diff --git a/packages/services/src/status-timeline/__tests__/downtime.test.ts b/packages/services/src/status-timeline/__tests__/downtime.test.ts new file mode 100644 index 00000000..61300a8f --- /dev/null +++ b/packages/services/src/status-timeline/__tests__/downtime.test.ts @@ -0,0 +1,41 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { type WeightedInterval, mergedDowntimeMs } from "../downtime"; + +const HOUR = 3_600_000; + +function iv(from: number, to: number, weight = 1): WeightedInterval { + return { from, to, weight }; +} + +describe("mergedDowntimeMs", () => { + test("empty input and single interval", () => { + expect(mergedDowntimeMs([])).toBe(0); + expect(mergedDowntimeMs([iv(0, 2 * HOUR)])).toBe(2 * HOUR); + expect(mergedDowntimeMs([iv(0, 2 * HOUR, 0.5)])).toBe(1 * HOUR); + }); + + test("disjoint intervals sum, identical overlaps count once", () => { + expect(mergedDowntimeMs([iv(0, HOUR), iv(2 * HOUR, 3 * HOUR)])).toBe( + 2 * HOUR, + ); + expect(mergedDowntimeMs([iv(0, 2 * HOUR), iv(0, 2 * HOUR)])).toBe(2 * HOUR); + }); + + test("partial overlap takes the max weight per slice, never the sum", () => { + // [0,2h] w=0.5 and [1h,3h] w=1 → 1h*0.5 + 2h*1 = 2.5h + expect( + mergedDowntimeMs([iv(0, 2 * HOUR, 0.5), iv(HOUR, 3 * HOUR, 1)]), + ).toBe(2.5 * HOUR); + // full containment: heavier inner interval wins only for its span + // [0,4h] w=0.5 containing [1h,2h] w=1 → 3h*0.5 + 1h*1 = 2.5h + expect( + mergedDowntimeMs([iv(0, 4 * HOUR, 0.5), iv(HOUR, 2 * HOUR, 1)]), + ).toBe(2.5 * HOUR); + }); + + test("adjacent intervals sharing a boundary don't double-count the edge", () => { + expect(mergedDowntimeMs([iv(0, HOUR), iv(HOUR, 2 * HOUR)])).toBe(2 * HOUR); + }); +}); diff --git a/packages/services/src/status-timeline/__tests__/uptime.test.ts b/packages/services/src/status-timeline/__tests__/uptime.test.ts new file mode 100644 index 00000000..3f4c9d94 --- /dev/null +++ b/packages/services/src/status-timeline/__tests__/uptime.test.ts @@ -0,0 +1,197 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import type { Event } from "../events"; +import { + type UptimeWindow, + durationDowntimeMs, + floorPct, + reportsOnlyDowntimeMs, + requestsTally, +} from "../uptime"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +const base = Date.UTC(2026, 5, 10); +const window: UptimeWindow = { + start: base, + end: base + 10 * DAY, + now: base + 10 * DAY, +}; + +function incident(fromMs: number, toMs: number | null): Event { + return { + id: 1, + name: "incident", + from: new Date(fromMs), + to: toMs === null ? null : new Date(toMs), + type: "incident", + status: "error", + }; +} + +function impactReport( + fromMs: number, + toMs: number, + impact: "major_outage" | "partial_outage" | "degraded_performance", +): Event { + return { + id: 2, + name: "report", + from: new Date(fromMs), + to: new Date(toMs), + type: "report", + status: "error", + impactIntervals: [{ from: new Date(fromMs), to: new Date(toMs), impact }], + }; +} + +function legacyReport(fromMs: number, toMs: number): Event { + return { + id: 3, + name: "legacy", + from: new Date(fromMs), + to: new Date(toMs), + type: "report", + status: "degraded", + }; +} + +describe("floorPct", () => { + test("floors instead of rounding — one failed check never shows 100.00", () => { + expect(floorPct(99_999 / 100_000)).toBe(99.99); + expect(floorPct(1)).toBe(100); + expect(floorPct(0)).toBe(0); + }); +}); + +describe("requestsTally", () => { + test("degraded counts as up, error does not", () => { + expect( + requestsTally([ + { ok: 90, degraded: 5, error: 5 }, + { ok: 100, degraded: 0, error: 0 }, + ]), + ).toEqual({ up: 195, total: 200 }); + }); + + test("empty input is a zero tally", () => { + expect(requestsTally([])).toEqual({ up: 0, total: 0 }); + }); +}); + +describe("durationDowntimeMs", () => { + test("incident counts at full weight", () => { + expect(durationDowntimeMs([incident(base, base + 2 * HOUR)], window)).toBe( + 2 * HOUR, + ); + }); + + test("incident + major report describing the same outage count once", () => { + const events = [ + incident(base, base + 2 * HOUR), + impactReport(base, base + 2 * HOUR, "major_outage"), + ]; + expect(durationDowntimeMs(events, window)).toBe(2 * HOUR); + }); + + test("partial outage weighs 0.5; overlapping major wins per slice", () => { + expect( + durationDowntimeMs( + [impactReport(base, base + 2 * HOUR, "partial_outage")], + window, + ), + ).toBe(1 * HOUR); + // overlapping partial must not add on top of major (max, not sum) + expect( + durationDowntimeMs( + [ + impactReport(base, base + 2 * HOUR, "major_outage"), + impactReport(base, base + 2 * HOUR, "partial_outage"), + ], + window, + ), + ).toBe(2 * HOUR); + }); + + test("degraded_performance weighs 0 and legacy reports are ignored", () => { + expect( + durationDowntimeMs( + [ + impactReport(base, base + 2 * HOUR, "degraded_performance"), + legacyReport(base, base + 2 * HOUR), + ], + window, + ), + ).toBe(0); + }); + + test("coverage clips downtime inside check gaps", () => { + const coverage = [ + { start: base, end: base + 2 * DAY }, + { start: base + 8 * DAY, end: base + 10 * DAY }, + ]; + // outage entirely inside the uncovered gap (paused monitor) → no downtime + expect( + durationDowntimeMs( + [incident(base + 3 * DAY, base + 7 * DAY)], + window, + coverage, + ), + ).toBe(0); + // outage spanning covered and uncovered time counts only the covered part + expect( + durationDowntimeMs( + [incident(base + DAY, base + 9 * DAY)], + window, + coverage, + ), + ).toBe(2 * DAY); + }); + + test("events are clamped to the window; open events close at now", () => { + // straddles the window start: only the inside part counts + expect( + durationDowntimeMs([incident(base - DAY, base + HOUR)], window), + ).toBe(1 * HOUR); + // still-open incident: closed at `now` + expect(durationDowntimeMs([incident(base + 9 * DAY, null)], window)).toBe( + 1 * DAY, + ); + // fully outside the window: nothing + expect( + durationDowntimeMs([incident(base - 2 * DAY, base - DAY)], window), + ).toBe(0); + }); +}); + +describe("reportsOnlyDowntimeMs", () => { + test("empty impactIntervals falls through to the legacy path, not zero downtime", () => { + // getEvents emits [] for a report member the updates never impacted + const report: Event = { + ...legacyReport(base, base + 2 * HOUR), + impactIntervals: [], + }; + expect(reportsOnlyDowntimeMs([report], window)).toBe(2 * HOUR); + // duration math keeps ignoring legacy-shaped reports + expect(durationDowntimeMs([report], window)).toBe(0); + }); + + test("incidents are ignored, legacy reports count full duration", () => { + const events = [ + incident(base, base + 5 * HOUR), + legacyReport(base + 6 * HOUR, base + 8 * HOUR), + ]; + expect(reportsOnlyDowntimeMs(events, window)).toBe(2 * HOUR); + }); + + test("impact reports stay weighted", () => { + expect( + reportsOnlyDowntimeMs( + [impactReport(base, base + 4 * HOUR, "partial_outage")], + window, + ), + ).toBe(2 * HOUR); + }); +}); diff --git a/packages/services/src/status-timeline/downtime.ts b/packages/services/src/status-timeline/downtime.ts new file mode 100644 index 00000000..e9f13764 --- /dev/null +++ b/packages/services/src/status-timeline/downtime.ts @@ -0,0 +1,24 @@ +export type WeightedInterval = { from: number; to: number; weight: number }; + +// concurrent events describing the same outage must not double-count +// downtime: per time slice the worst (max) weight wins, mirroring +// mergeWorstImpactIntervals — summing could push uptime negative +export function mergedDowntimeMs(intervals: WeightedInterval[]): number { + const boundaries = [ + ...new Set(intervals.flatMap((iv) => [iv.from, iv.to])), + ].sort((a, b) => a - b); + + let total = 0; + for (let i = 0; i + 1 < boundaries.length; i++) { + const sliceStart = boundaries[i]; + const sliceEnd = boundaries[i + 1]; + let weight = 0; + for (const iv of intervals) { + if (iv.from <= sliceStart && iv.to >= sliceEnd) { + weight = Math.max(weight, iv.weight); + } + } + total += weight * (sliceEnd - sliceStart); + } + return total; +} diff --git a/packages/services/src/status-timeline/index.ts b/packages/services/src/status-timeline/index.ts index 1784004f..46a245c6 100644 --- a/packages/services/src/status-timeline/index.ts +++ b/packages/services/src/status-timeline/index.ts @@ -1 +1,3 @@ +export * from "./downtime"; export * from "./events"; +export * from "./uptime"; diff --git a/packages/services/src/status-timeline/uptime.ts b/packages/services/src/status-timeline/uptime.ts new file mode 100644 index 00000000..c6a205f3 --- /dev/null +++ b/packages/services/src/status-timeline/uptime.ts @@ -0,0 +1,150 @@ +import { + LEGACY_IMPACT_WEIGHT, + impactUptimeWeight, +} from "@openstatus/db/src/schema"; + +import { type WeightedInterval, mergedDowntimeMs } from "./downtime"; +import type { Event } from "./events"; + +export const MS_PER_DAY = 86_400_000; + +export type CheckCounts = { ok: number; degraded: number; error: number }; + +/** Downtime is clamped to [start, end]; `now` closes still-open events. */ +export type UptimeWindow = { start: number; end: number; now: number }; + +/** Time actually covered by checks; downtime outside it must not count. */ +export type CoverageSegment = { start: number; end: number }; + +/** + * Day-granular coverage plus its total in one place — the denominator MUST + * equal the covered time or clipped downtime is measured against the wrong + * base. `clampEndMs` cuts the in-progress day to elapsed time. + */ +export function dayCoverage( + dayStartsMs: number[], + clampEndMs?: number, +): { segments: CoverageSegment[]; totalMs: number } { + let totalMs = 0; + const segments: CoverageSegment[] = []; + for (const start of dayStartsMs) { + const end = + clampEndMs === undefined + ? start + MS_PER_DAY + : Math.min(start + MS_PER_DAY, clampEndMs); + if (end <= start) continue; + totalMs += end - start; + segments.push({ start, end }); + } + return { segments, totalMs }; +} + +// downtime during a coverage gap (paused monitor, missing data days) would +// exceed a days-with-checks denominator and fake 0% for a healthy monitor — +// clip every interval to the covered segments before merging +function clipToCoverage( + intervals: WeightedInterval[], + coverage: CoverageSegment[], +): WeightedInterval[] { + return intervals.flatMap((iv) => + coverage.flatMap((segment) => { + const from = Math.max(iv.from, segment.start); + const to = Math.min(iv.to, segment.end); + return to > from ? [{ from, to, weight: iv.weight }] : []; + }), + ); +} + +// floor so a single failed check never rounds up to 100.00 +export function floorPct(ratio: number): number { + return Math.floor(ratio * 10_000) / 100; +} + +export function requestsTally(counts: CheckCounts[]): { + up: number; + total: number; +} { + let up = 0; + let total = 0; + for (const c of counts) { + up += c.ok + c.degraded; + total += c.ok + c.degraded + c.error; + } + return { up, total }; +} + +function clampInterval( + from: Date, + to: Date | null, + weight: number, + window: UptimeWindow, +): WeightedInterval | null { + const start = Math.max(from.getTime(), window.start); + const end = Math.min(to ? to.getTime() : window.now, window.end); + if (end <= start || weight === 0) return null; + return { from: start, to: end, weight }; +} + +function downtimeIntervals( + events: Event[], + window: UptimeWindow, + reportsOnly: boolean, +): WeightedInterval[] { + return events.flatMap((e) => { + if (e.type === "incident") { + if (reportsOnly) return []; + return clampInterval(e.from, e.to, 1, window) ?? []; + } + if (e.type !== "report") return []; + // empty array falls through: getEvents emits [] for a member component + // no update ever impacted — treat like legacy, not like "no downtime" + // (mirrors eventWorstImpact's length check in events.ts) + if (e.impactIntervals?.length) { + return e.impactIntervals.flatMap( + (iv) => + clampInterval( + iv.from, + iv.to, + impactUptimeWeight(iv.impact), + window, + ) ?? [], + ); + } + // legacy report (no impact rows): counts full-duration in reports-only + // math, ignored in duration math to preserve pre-impact uptime values + return reportsOnly + ? (clampInterval(e.from, e.to, LEGACY_IMPACT_WEIGHT, window) ?? []) + : []; + }); +} + +/** + * Duration-mode downtime: incidents (weight 1) + impact-weighted reports + * share one merged timeline so an incident plus a report describing the same + * outage counts once; legacy reports are ignored. + */ +export function durationDowntimeMs( + events: Event[], + window: UptimeWindow, + coverage?: CoverageSegment[], +): number { + const intervals = downtimeIntervals(events, window, false); + return mergedDowntimeMs( + coverage ? clipToCoverage(intervals, coverage) : intervals, + ); +} + +/** + * Reports-only downtime (manual mode, static components): impact-weighted + * reports plus legacy reports at full weight; incidents are ignored. + */ +export function reportsOnlyDowntimeMs( + events: Event[], + window: UptimeWindow, + coverage?: CoverageSegment[], +): number { + const intervals = downtimeIntervals(events, window, true); + return mergedDowntimeMs( + coverage ? clipToCoverage(intervals, coverage) : intervals, + ); +} diff --git a/packages/ui/src/components/ui/sidebar.tsx b/packages/ui/src/components/ui/sidebar.tsx index 4972fbd7..385f5617 100644 --- a/packages/ui/src/components/ui/sidebar.tsx +++ b/packages/ui/src/components/ui/sidebar.tsx @@ -311,7 +311,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {