- {new Date(item.timestamp).toLocaleDateString("default", {
+ {new Date(item.day).toLocaleDateString("default", {
day: "numeric",
month: "short",
+ year: "numeric",
})}
- {(() => {
- switch (cardType) {
- case "duration":
- return ;
- case "dominant":
- return ;
- case "requests":
- return ;
- default:
- return null;
- }
- })()}
+ {/* Render processed card data from backend */}
+ {item.card.map((cardItem, cardIndex) => (
+
+ ))}
- {reports.length > 0 ? (
+ {item.events.length > 0 && (
<>
- {reports.map((report) => {
- const updates = report.updates.sort(
- (a, b) => a.date.getTime() - b.date.getTime(),
- );
- const startedAt = new Date(updates[0].date);
- const endedAt = new Date(
- updates[updates.length - 1].date,
- );
- const duration = formatDistanceStrict(
- startedAt,
- endedAt,
- );
- return (
-
-
- {/* NOTE: this is to make the text truncate based on the with of the sibling element */}
- {/* REMINDER: height needs to be equal the text height */}
-
-
-
- {formatDateRange(startedAt, endedAt)}{" "}
-
- {duration}
-
-
-
-
+ {item.events.map((event) => {
+ const eventStatus =
+ event.type === "incident"
+ ? "error"
+ : event.type === "report"
+ ? "degraded"
+ : "info";
+
+ const content = (
+
);
+
+ // Wrap reports and maintenances with links
+ if (
+ event.type === "report" ||
+ event.type === "maintenance"
+ ) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ // Incidents don't have links
+ return content;
})}
>
- ) : null}
+ )}
{isPinned && !isTouch && (
<>
@@ -210,111 +304,78 @@ export function StatusTracker({
);
}
-function StatusTrackerTriggerAbsolute({ item }: { item: ChartData }) {
- const total = item.success + item.degraded + item.info + item.error;
-
- return STATUS.map((status) => {
- const value = item[status as keyof typeof item] as number;
- if (value === 0) return null;
- const heightPercentage = (value / total) * 100;
- return (
-
- );
- });
-}
-
-function StatusTrackerTriggerDominant({ item }: { item: ChartData }) {
- const highestPriorityStatus = getHighestPriorityStatus(item);
-
+export function StatusTrackerSkeleton({
+ className,
+ ...props
+}: React.ComponentProps
) {
return (
-
);
}
-function StatusTrackerContentDuration({ item }: { item: ChartData }) {
- return STATUS.map((status) => {
- const value = item[status];
- if (value === 0) return null;
-
- // const percentage = ((value / total) * 100).toFixed(1);
-
- const now = new Date();
- const duration = formatDistanceStrict(
- now,
- new Date(now.getTime() + value * 60 * 1000),
- );
-
- return (
-
-
-
-
{messages.short[status]}
-
-
- {duration}
-
-
- );
- });
-}
-
-function StatusTrackerContentDominant({ item }: { item: ChartData }) {
- const highestPriorityStatus = getHighestPriorityStatus(item);
+function StatusTrackerContent({
+ status,
+ value,
+}: {
+ status: "success" | "degraded" | "error" | "info" | "empty";
+ value: string;
+}) {
return (
-
+
-
{messages.short[highestPriorityStatus]}
+
{requests[status]}
+
+
+ {value}
);
}
-function StatusTrackerContentRequests({ item }: { item: ChartData }) {
- return STATUS.map((status) => {
- const value = item[status];
- if (value === 0) return null;
-
- return (
-
+function StatusTrackerEvent({
+ name,
+ from,
+ to,
+ status,
+}: {
+ name: string;
+ from?: Date | null;
+ to?: Date | null;
+ status: "success" | "degraded" | "error" | "info" | "empty";
+}) {
+ if (!from) return null;
+ const duration = to ? formatDistanceStrict(from, to) : "ongoing";
+ return (
+
+ {/* NOTE: this is to make the text truncate based on the with of the sibling element */}
+ {/* REMINDER: height needs to be equal the text height */}
+
+
- );
- });
+
+ {formatDateRange(from, to ?? undefined)}{" "}
+
+ {duration}
+
+
+
+ );
}
diff --git a/apps/status-page/src/components/status-page/status-updates.tsx b/apps/status-page/src/components/status-page/status-updates.tsx
index 38687760..2036ee3e 100644
--- a/apps/status-page/src/components/status-page/status-updates.tsx
+++ b/apps/status-page/src/components/status-page/status-updates.tsx
@@ -1,4 +1,8 @@
+"use client";
+
+import { FormSubscribeEmail } from "@/components/forms/form-subscribe-email";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
@@ -6,13 +10,27 @@ import {
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
+import { usePathnamePrefix } from "@/hooks/use-pathname-prefix";
import { cn } from "@/lib/utils";
-import { Input } from "../ui/input";
+import { Inbox } from "lucide-react";
+import { useState } from "react";
+
+type StatusUpdateType = "email" | "rss" | "atom";
+
+interface StatusUpdatesProps extends React.ComponentProps
{
+ types?: StatusUpdateType[];
+ onSubscribe?: (value: string) => Promise | void;
+}
export function StatusUpdates({
className,
+ types = ["rss", "atom"],
+ onSubscribe,
...props
-}: React.ComponentProps) {
+}: StatusUpdatesProps) {
+ const [success, setSuccess] = useState(false);
+ const prefix = usePathnamePrefix();
+
return (
@@ -28,26 +46,46 @@ export function StatusUpdates({
- Email
- RSS
- Atom
+ {types.includes("email") ? (
+ Email
+ ) : null}
+ {types.includes("rss") ? (
+ RSS
+ ) : null}
+ {types.includes("atom") ? (
+ Atom
+ ) : null}
-
-
- Get email notifications whenever a report has been created or
- resolved
-
-
-
-
- Subscribe
-
+ {success ? (
+
+ ) : (
+ <>
+
+
+ Get email notifications whenever a report has been created
+ or resolved
+
+
{
+ await onSubscribe?.(values.email);
+ setSuccess(true);
+ }}
+ />
+
+
+
+ Subscribe
+
+
{" "}
+ >
+ )}
@@ -55,14 +93,14 @@ export function StatusUpdates({
@@ -70,7 +108,7 @@ export function StatusUpdates({
@@ -82,15 +120,34 @@ export function StatusUpdates({
function CopyButton({
value,
- className,
-}: {
+ onClick,
+ ...props
+}: React.ComponentProps
& {
value: string;
- className?: string;
}) {
const { copy, isCopied } = useCopyToClipboard();
return (
- copy(value, {})}>
+ {
+ copy(value, {});
+ onClick?.(e);
+ }}
+ {...props}
+ >
{isCopied ? "Copied" : "Copy link"}
);
}
+
+function SuccessMessage() {
+ return (
+
+
+
Check your inbox!
+
+ Validate your email to receive updates and you are all set.
+
+
+ );
+}
diff --git a/apps/status-page/src/components/status-page/status.tsx b/apps/status-page/src/components/status-page/status.tsx
index d4b35611..52490692 100644
--- a/apps/status-page/src/components/status-page/status.tsx
+++ b/apps/status-page/src/components/status-page/status.tsx
@@ -13,7 +13,6 @@ import {
TriangleAlertIcon,
WrenchIcon,
} from "lucide-react";
-import { messages } from "./messages";
export function Status({
children,
@@ -97,49 +96,6 @@ export function StatusContent({
return {children}
;
}
-export function StatusBanner({ className }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function StatusBannerMessage({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
-
- {messages.long.success}
-
-
- {messages.long.degraded}
-
-
- {messages.long.error}
-
-
- {messages.long.info}
-
-
- );
-}
-
export function StatusIcon({
className,
...props
@@ -172,7 +128,6 @@ export function StatusTimestamp({
return (
- {/* TODO: add outline focus */}
{
const date = new Date();
@@ -66,6 +66,10 @@ export const chartConfig = {
label: "info",
color: "var(--info)",
},
+ empty: {
+ label: "empty",
+ color: "var(--muted)",
+ },
} satisfies ChartConfig;
export const PRIORITY = {
@@ -76,9 +80,67 @@ export const PRIORITY = {
} as const; // satisfies Record;
export function getHighestPriorityStatus(item: ChartData) {
+ const total = item.success + item.degraded + item.info + item.error;
+ if (total === 0) return "empty";
return (
VARIANT.filter((status) => item[status] > 0).sort(
(a, b) => PRIORITY[b] - PRIORITY[a],
- )[0] || "success"
+ )[0] || "empty"
);
}
+
+export const PERCENTAGE_PRIORITY = {
+ info: -1,
+ error: 0,
+ degraded: 0.75,
+ success: 0.95,
+} as const;
+
+export function getPercentagePriorityStatus(item: ChartData) {
+ const total = item.success + item.degraded + item.info + item.error;
+ if (total === 0) return "empty";
+
+ const percentage = item.success / total;
+ if (percentage >= PERCENTAGE_PRIORITY.success) return "success";
+ if (percentage >= PERCENTAGE_PRIORITY.degraded) return "degraded";
+ if (percentage >= PERCENTAGE_PRIORITY.error) return "error";
+ if (percentage >= PERCENTAGE_PRIORITY.info) return "info";
+ return "info";
+}
+
+export function getHighestStatus(items: VariantType[]) {
+ if (items.some((item) => item === "error")) return "error";
+ if (items.some((item) => item === "degraded")) return "degraded";
+ if (items.some((item) => item === "info")) return "info";
+ return "success";
+}
+
+export function getTotalUptime(item: ChartData[]) {
+ const { ok, total } = item.reduce(
+ (acc, item) => ({
+ ok: acc.ok + item.success + item.degraded + item.info,
+ total: acc.total + item.success + item.degraded + item.info + item.error,
+ }),
+ {
+ ok: 0,
+ total: 0,
+ },
+ );
+
+ if (total === 0) return 100;
+ return Math.round((ok / total) * 10000) / 100;
+}
+
+export function getManualUptime(
+ items: { from: Date | null; to: Date | null }[],
+ days: number,
+) {
+ const duration = items.reduce((acc, item) => {
+ if (!item.from) return acc;
+ return acc + ((item.to || new Date()).getTime() - item.from.getTime());
+ }, 0);
+
+ const total = days * 24 * 60 * 60 * 1000;
+
+ return Math.round(((total - duration) / total) * 10000) / 100;
+}
diff --git a/apps/status-page/src/hooks/use-pathname-prefix.ts b/apps/status-page/src/hooks/use-pathname-prefix.ts
new file mode 100644
index 00000000..81efeafd
--- /dev/null
+++ b/apps/status-page/src/hooks/use-pathname-prefix.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function usePathnamePrefix() {
+ const [prefix, setPrefix] = useState("");
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ const hostnames = window.location.hostname.split(".");
+ const pathnames = window.location.pathname.split("/");
+ if (
+ hostnames.length > 2 &&
+ hostnames[0] !== "www" &&
+ !window.location.hostname.endsWith(".vercel.app")
+ ) {
+ setPrefix(hostnames[0]);
+ } else {
+ setPrefix(pathnames[1]);
+ }
+ }
+ }, []);
+
+ return prefix;
+}
diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts
index 781fa29e..f091532a 100644
--- a/apps/status-page/src/lib/formatter.ts
+++ b/apps/status-page/src/lib/formatter.ts
@@ -15,6 +15,14 @@ export function formatMilliseconds(ms: number) {
}).format(ms)}`;
}
+export function formatMillisecondsRange(min: number, max: number) {
+ if ((min > 1000 && max > 1000) || (min < 1000 && max < 1000)) {
+ return `${formatNumber(min / 1000)} - ${formatMilliseconds(max)}`;
+ }
+
+ return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`;
+}
+
export function formatPercentage(value: number) {
if (Number.isNaN(value)) return "100%";
return `${Intl.NumberFormat("en-US", {
diff --git a/apps/status-page/src/lib/protected.ts b/apps/status-page/src/lib/protected.ts
new file mode 100644
index 00000000..9f7750c6
--- /dev/null
+++ b/apps/status-page/src/lib/protected.ts
@@ -0,0 +1,3 @@
+export function createProtectedCookieKey(value: string) {
+ return `secured-${value}`;
+}
diff --git a/apps/status-page/src/lib/trpc/shared.ts b/apps/status-page/src/lib/trpc/shared.ts
index a3290046..dfa3ca8f 100644
--- a/apps/status-page/src/lib/trpc/shared.ts
+++ b/apps/status-page/src/lib/trpc/shared.ts
@@ -7,8 +7,8 @@ import superjson from "superjson";
const getBaseUrl = () => {
if (typeof window !== "undefined") return "";
const vc = process.env.VERCEL_URL;
- // if (vc) return `https://${vc}`;
- if (vc) return "https://app.openstatus.dev";
+ if (vc) return `https://${vc}`;
+ // if (vc) return "https://app.openstatus.dev";
return "http://localhost:3000";
};
diff --git a/apps/status-page/src/middleware.ts b/apps/status-page/src/middleware.ts
new file mode 100644
index 00000000..32b53a9e
--- /dev/null
+++ b/apps/status-page/src/middleware.ts
@@ -0,0 +1,69 @@
+import { type NextRequest, NextResponse } from "next/server";
+
+import { db, eq } from "@openstatus/db";
+import { page } from "@openstatus/db/src/schema";
+import { createProtectedCookieKey } from "./lib/protected";
+
+export default async function middleware(req: NextRequest) {
+ const url = req.nextUrl.clone();
+ const response = NextResponse.next();
+ const cookies = req.cookies;
+
+ let prefix = "";
+ let type: "hostname" | "pathname";
+
+ const hostnames = url.host.split(".");
+ const pathnames = url.pathname.split("/");
+ if (
+ hostnames.length > 2 &&
+ hostnames[0] !== "www" &&
+ !url.host.endsWith(".vercel.app")
+ ) {
+ prefix = hostnames[0].toLowerCase();
+ type = "hostname";
+ } else {
+ prefix = pathnames[1].toLowerCase();
+ type = "pathname";
+ }
+
+ if (url.pathname === "/") {
+ return response;
+ }
+
+ const _page = await db.select().from(page).where(eq(page.slug, prefix)).get();
+
+ if (!_page) {
+ return NextResponse.redirect(new URL("https://openstatus.dev"));
+ }
+
+ if (_page?.passwordProtected) {
+ const protectedCookie = cookies.get(createProtectedCookieKey(prefix));
+ const password = protectedCookie ? protectedCookie.value : undefined;
+ if (password !== _page.password && !url.pathname.endsWith("/protected")) {
+ const url = new URL(
+ `${req.nextUrl.origin}${
+ type === "pathname" ? `/${prefix}` : ""
+ }/protected?redirect=${encodeURIComponent(req.url)}`,
+ );
+ return NextResponse.redirect(url);
+ }
+ if (password === _page.password && url.pathname.endsWith("/protected")) {
+ const redirect = url.searchParams.get("redirect");
+ return NextResponse.redirect(
+ new URL(
+ `${req.nextUrl.origin}${
+ redirect ?? type === "pathname" ? `/${prefix}` : "/"
+ }`,
+ ),
+ );
+ }
+ }
+
+ return response;
+}
+
+export const config = {
+ matcher: [
+ "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
+ ],
+};
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 1b3be084..3cd7048e 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/src/app/api/og/monitor/route.tsx b/apps/web/src/app/api/og/monitor/route.tsx
index d4be6f6a..96c0f082 100644
--- a/apps/web/src/app/api/og/monitor/route.tsx
+++ b/apps/web/src/app/api/og/monitor/route.tsx
@@ -32,7 +32,7 @@ export async function GET(req: Request) {
// TODO: we need to pass the monitor type here
const res = (monitorId &&
- (await tb.httpStatus45d({
+ (await tb.legacy_httpStatus45d({
monitorId,
}))) || { data: [] };
diff --git a/apps/web/src/components/data-table/status-page/columns.tsx b/apps/web/src/components/data-table/status-page/columns.tsx
index 392764f2..ab951816 100644
--- a/apps/web/src/components/data-table/status-page/columns.tsx
+++ b/apps/web/src/components/data-table/status-page/columns.tsx
@@ -26,7 +26,7 @@ import { DataTableRowActions } from "./data-table-row-actions";
export const columns: ColumnDef<
Page & {
monitorsToPages: { monitor: { name: string } }[];
- maintenancesToPages: Maintenance[]; // we get only the active maintenances!
+ maintenances: Maintenance[]; // we get only the active maintenances!
statusReports: (StatusReport & {
statusReportUpdates: StatusReportUpdate[];
})[];
diff --git a/apps/web/src/lib/tb.ts b/apps/web/src/lib/tb.ts
index 64dd91de..371c5e02 100644
--- a/apps/web/src/lib/tb.ts
+++ b/apps/web/src/lib/tb.ts
@@ -167,8 +167,8 @@ export function prepareStatusByPeriod(
}
case "45d": {
const getData = {
- http: tb.httpStatus45d,
- tcp: tb.tcpStatus45d,
+ http: tb.legacy_httpStatus45d,
+ tcp: tb.legacy_tcpStatus45d,
} as const;
return { getData: getData[type] };
}
diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts
index 8010ae94..909b70ef 100644
--- a/packages/api/src/edge.ts
+++ b/packages/api/src/edge.ts
@@ -13,6 +13,7 @@ import { monitorTagRouter } from "./router/monitorTag";
import { notificationRouter } from "./router/notification";
import { pageRouter } from "./router/page";
import { pageSubscriberRouter } from "./router/pageSubscriber";
+import { statusPageRouter } from "./router/statusPage";
import { statusReportRouter } from "./router/statusReport";
import { tinybirdRouter } from "./router/tinybird";
import { userRouter } from "./router/user";
@@ -40,4 +41,5 @@ export const edgeRouter = createTRPCRouter({
checker: checkerRouter,
blob: blobRouter,
feedback: feedbackRouter,
+ statusPage: statusPageRouter,
});
diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts
index ce2943b1..dfbb42ff 100644
--- a/packages/api/src/router/email/index.ts
+++ b/packages/api/src/router/email/index.ts
@@ -92,4 +92,30 @@ export const emailRouter = createTRPCRouter({
});
}
}),
+
+ sendPageSubscription: protectedProcedure
+ .input(z.object({ id: z.number() }))
+ .mutation(async (opts) => {
+ const limits = opts.ctx.workspace.limits;
+
+ if (limits["status-subscribers"]) {
+ const _pageSubscriber =
+ await opts.ctx.db.query.pageSubscriber.findFirst({
+ where: eq(pageSubscriber.id, opts.input.id),
+ with: {
+ page: true,
+ },
+ });
+
+ if (!_pageSubscriber || !_pageSubscriber.token) return;
+
+ await emailClient.sendPageSubscription({
+ to: _pageSubscriber.email,
+ token: _pageSubscriber.token,
+ page: _pageSubscriber.page.title,
+ // TODO: or use custom domain
+ domain: _pageSubscriber.page.slug,
+ });
+ }
+ }),
});
diff --git a/packages/api/src/router/page.test.ts b/packages/api/src/router/page.test.ts
index 3caccaae..75ed681f 100644
--- a/packages/api/src/router/page.test.ts
+++ b/packages/api/src/router/page.test.ts
@@ -23,6 +23,7 @@ test("Get Test Page", async () => {
statusReports: expect.any(Array),
monitors: expect.any(Array),
incidents: expect.any(Array),
+ maintenances: expect.any(Array),
published: expect.any(Boolean),
slug: expect.any(String),
title: expect.any(String),
diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts
index 4eb59e0b..6d659ed7 100644
--- a/packages/api/src/router/page.ts
+++ b/packages/api/src/router/page.ts
@@ -15,6 +15,7 @@ import {
import {
incidentTable,
insertPageSchema,
+ legacy_selectPublicPageSchemaWithRelation,
maintenance,
monitor,
monitorsToPages,
@@ -23,7 +24,6 @@ import {
selectMonitorSchema,
selectPageSchema,
selectPageSchemaWithMonitorsRelation,
- selectPublicPageSchemaWithRelation,
statusReport,
subdomainSafeList,
workspace,
@@ -262,7 +262,7 @@ export const pageRouter = createTRPCRouter({
where: and(eq(page.workspaceId, opts.ctx.workspace.id)),
with: {
monitorsToPages: { with: { monitor: true } },
- maintenancesToPages: {
+ maintenances: {
where: and(
lte(maintenance.from, new Date()),
gte(maintenance.to, new Date()),
@@ -278,14 +278,13 @@ export const pageRouter = createTRPCRouter({
},
},
});
- console.log(allPages.map((page) => page.statusReports));
return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages);
}),
// public if we use trpc hooks to get the page from the url
getPageBySlug: publicProcedure
.input(z.object({ slug: z.string().toLowerCase() }))
- .output(selectPublicPageSchemaWithRelation.optional())
+ .output(legacy_selectPublicPageSchemaWithRelation.nullish())
.query(async (opts) => {
if (!opts.input.slug) return;
@@ -297,9 +296,7 @@ export const pageRouter = createTRPCRouter({
)
.get();
- if (!result) {
- return;
- }
+ if (!result) return;
const [workspaceResult, monitorsToPagesResult] = await Promise.all([
opts.ctx.db
@@ -354,7 +351,7 @@ export const pageRouter = createTRPCRouter({
const maintenancesQuery = opts.ctx.db.query.maintenance.findMany({
where: eq(maintenance.pageId, result.id),
- with: { maintenancesToMonitors: true },
+ with: { maintenancesToMonitors: { with: { monitor: true } } },
orderBy: (maintenances, { desc }) => desc(maintenances.from),
});
@@ -373,7 +370,7 @@ export const pageRouter = createTRPCRouter({
incidentsQuery,
]);
- return selectPublicPageSchemaWithRelation.parse({
+ return legacy_selectPublicPageSchemaWithRelation.parse({
...result,
// TODO: improve performance and move into SQLite query
monitors: monitors.sort((a, b) => {
@@ -482,7 +479,7 @@ export const pageRouter = createTRPCRouter({
where: and(...whereConditions),
with: {
monitorsToPages: { with: { monitor: true } },
- maintenancesToPages: true,
+ maintenances: true,
},
});
@@ -499,7 +496,7 @@ export const pageRouter = createTRPCRouter({
...m.monitor,
order: m.order,
})),
- maintenances: data?.maintenancesToPages,
+ maintenances: data?.maintenances,
});
}),
diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts
new file mode 100644
index 00000000..1db5a747
--- /dev/null
+++ b/packages/api/src/router/statusPage.ts
@@ -0,0 +1,637 @@
+import { z } from "zod";
+
+import { and, eq, inArray, sql } from "@openstatus/db";
+import {
+ maintenance,
+ monitorsToPages,
+ page,
+ pageSubscriber,
+ selectPublicMonitorSchema,
+ selectPublicPageSchemaWithRelation,
+ statusReport,
+} from "@openstatus/db/src/schema";
+
+import { TRPCError } from "@trpc/server";
+import { createTRPCRouter, publicProcedure } from "../trpc";
+import {
+ fillStatusDataFor45Days,
+ fillStatusDataFor45DaysNoop,
+ getEvents,
+ getUptime,
+ setDataByType,
+} from "./statusPage.utils";
+import {
+ getMetricsLatencyMultiProcedure,
+ getMetricsLatencyProcedure,
+ getMetricsRegionsProcedure,
+ getStatusProcedure,
+ getUptimeProcedure,
+} from "./tinybird";
+
+// NOTE: publicProcedure is used to get the status page
+// TODO: improve performance of SQL query (make a single query with joins)
+
+// IMPORTANT: we cannot use the tinybird procedure because it has protectedProcedure
+// instead, we should add TB logic in here!!!!
+
+// NOTE: this router is used on status pages only - do not confuse with the page router which is used in the dashboard for the config
+
+export const statusPageRouter = createTRPCRouter({
+ get: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase() }))
+ .output(selectPublicPageSchemaWithRelation.nullish())
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ with: {
+ workspace: true,
+ statusReports: {
+ orderBy: (reports, { desc }) => desc(reports.createdAt),
+ with: {
+ statusReportUpdates: {
+ orderBy: (reports, { desc }) => desc(reports.date),
+ },
+ monitorsToStatusReports: { with: { monitor: true } },
+ },
+ },
+ maintenances: {
+ with: {
+ maintenancesToMonitors: { with: { monitor: true } },
+ },
+ orderBy: (maintenances, { desc }) => desc(maintenances.from),
+ },
+ monitorsToPages: {
+ with: {
+ monitor: {
+ with: {
+ incidents: true,
+ },
+ },
+ },
+ orderBy: (monitorsToPages, { asc }) => asc(monitorsToPages.order),
+ },
+ },
+ });
+
+ if (!_page) return null;
+
+ const monitors = _page.monitorsToPages
+ // NOTE: we cannot nested `where` in drizzle to filter active monitors
+ .filter((m) => m.monitor.active && !m.monitor.deletedAt)
+ .map((m) => {
+ const events = getEvents({
+ maintenances: _page.maintenances,
+ incidents: m.monitor.incidents,
+ reports: _page.statusReports,
+ monitorId: m.monitor.id,
+ });
+ const status = events.some((e) => e.type === "incident" && !e.to)
+ ? "error"
+ : events.some((e) => e.type === "report" && !e.to)
+ ? "degraded"
+ : events.some(
+ (e) =>
+ e.type === "maintenance" &&
+ e.to &&
+ e.from.getTime() <= new Date().getTime() &&
+ e.to.getTime() >= new Date().getTime(),
+ )
+ ? "info"
+ : "success";
+ return { ...m.monitor, status, events };
+ });
+
+ const status = monitors.some((m) => m.status === "error")
+ ? "error"
+ : monitors.some((m) => m.status === "degraded")
+ ? "degraded"
+ : monitors.some((m) => m.status === "info")
+ ? "info"
+ : "success";
+
+ // Get page-wide events (not tied to specific monitors)
+ const pageEvents = getEvents({
+ maintenances: _page.maintenances,
+ incidents:
+ _page.monitorsToPages.flatMap((m) => m.monitor.incidents) ?? [],
+ reports: _page.statusReports,
+ // No monitorId provided, so we get all events for the page
+ });
+
+ const threshold = new Date().getTime() - 7 * 24 * 60 * 60 * 1000;
+ const lastEvents = pageEvents
+ .filter((e) => {
+ if (e.type !== "incident") return false;
+ if (!e.to || e.to.getTime() >= threshold) return true;
+ return false;
+ })
+ .sort((a, b) => a.from.getTime() - b.from.getTime());
+
+ const openEvents = pageEvents.filter((event) => {
+ console.log(event.type, event.from, event.to);
+ if (event.type === "incident" || event.type === "report") {
+ if (!event.to) return true;
+ if (event.to < new Date()) return false;
+ return false;
+ }
+ if (event.type === "maintenance") {
+ if (!event.to) return false; // NOTE: this never happens
+ if (event.from <= new Date() && event.to >= new Date()) return true;
+ return false;
+ }
+ return false;
+ });
+
+ return selectPublicPageSchemaWithRelation.parse({
+ ..._page,
+ monitors,
+ incidents: monitors.flatMap((m) => m.incidents) ?? [],
+ statusReports: _page.statusReports ?? [],
+ maintenances: _page.maintenances ?? [],
+ workspacePlan: _page.workspace.plan,
+ status,
+ lastEvents,
+ openEvents,
+ });
+ }),
+
+ getMaintenance: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase(), id: z.number() }))
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db
+ .select()
+ .from(page)
+ .where(
+ sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ )
+ .get();
+
+ if (!_page) return null;
+
+ const _maintenance = await opts.ctx.db.query.maintenance.findFirst({
+ where: and(
+ eq(maintenance.id, opts.input.id),
+ eq(maintenance.pageId, _page.id),
+ ),
+ with: { maintenancesToMonitors: { with: { monitor: true } } },
+ });
+
+ if (!_maintenance) return null;
+
+ return _maintenance;
+ }),
+
+ getUptime: publicProcedure
+ .input(
+ z.object({
+ slug: z.string().toLowerCase(),
+ monitorIds: z.string().array(),
+ cardType: z
+ .enum(["requests", "duration", "dominant", "manual"])
+ .default("requests"),
+ barType: z.enum(["absolute", "dominant", "manual"]).default("dominant"),
+ }),
+ )
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ with: {
+ maintenances: {
+ with: {
+ maintenancesToMonitors: true,
+ },
+ },
+ statusReports: {
+ with: {
+ monitorsToStatusReports: true,
+ statusReportUpdates: true,
+ },
+ },
+ monitorsToPages: {
+ where: inArray(
+ monitorsToPages.monitorId,
+ opts.input.monitorIds.map(Number),
+ ),
+ with: {
+ monitor: {
+ with: {
+ incidents: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!_page) return null;
+
+ const monitors = _page.monitorsToPages.filter(
+ (m) => m.monitor.active && !m.monitor.deletedAt,
+ );
+
+ if (monitors.length !== opts.input.monitorIds.length) return null;
+
+ const monitorsByType = {
+ http: monitors.filter((m) => m.monitor.jobType === "http"),
+ tcp: monitors.filter((m) => m.monitor.jobType === "tcp"),
+ };
+
+ const proceduresByType = {
+ http: getStatusProcedure("45d", "http"),
+ tcp: getStatusProcedure("45d", "tcp"),
+ };
+
+ const [statusHttp, statusTcp] = await Promise.all(
+ Object.entries(proceduresByType).map(([type, procedure]) => {
+ const monitorIds = monitorsByType[
+ type as keyof typeof proceduresByType
+ ].map((m) => m.monitor.id.toString());
+ if (monitorIds.length === 0) return null;
+ // NOTE: if manual mode, don't fetch data from tinybird
+ return opts.input.barType === "manual"
+ ? null
+ : procedure({ monitorIds });
+ }),
+ );
+
+ const statusDataByMonitorId = new Map<
+ string,
+ | Awaited>["data"]
+ | Awaited>["data"]
+ >();
+
+ if (statusHttp?.data) {
+ statusHttp.data.forEach((status) => {
+ const monitorId = status.monitorId;
+ if (!statusDataByMonitorId.has(monitorId)) {
+ statusDataByMonitorId.set(monitorId, []);
+ }
+ statusDataByMonitorId.get(monitorId)?.push(status);
+ });
+ }
+
+ if (statusTcp?.data) {
+ statusTcp.data.forEach((status) => {
+ const monitorId = status.monitorId;
+ if (!statusDataByMonitorId.has(monitorId)) {
+ statusDataByMonitorId.set(monitorId, []);
+ }
+ statusDataByMonitorId.get(monitorId)?.push(status);
+ });
+ }
+
+ return monitors.map((m) => {
+ const monitorId = m.monitor.id.toString();
+ const events = getEvents({
+ maintenances: _page.maintenances,
+ incidents: m.monitor.incidents,
+ reports: _page.statusReports,
+ monitorId: m.monitor.id,
+ });
+ const rawData = statusDataByMonitorId.get(monitorId) || [];
+ const filledData = fillStatusDataFor45Days(rawData, monitorId);
+ const processedData = setDataByType({
+ events,
+ data: filledData,
+ cardType: opts.input.cardType,
+ barType: opts.input.barType,
+ });
+ const uptime = getUptime({
+ data: filledData,
+ events,
+ barType: opts.input.barType,
+ });
+
+ return {
+ ...selectPublicMonitorSchema.parse(m.monitor),
+ data: processedData,
+ uptime,
+ };
+ });
+ }),
+
+ // NOTE: used for the theme store
+ getNoopUptime: publicProcedure.query(async () => {
+ const data = fillStatusDataFor45DaysNoop();
+ const processedData = setDataByType({
+ events: [
+ {
+ type: "maintenance",
+ from: new Date(new Date().setDate(new Date().getDate() - 10)),
+ to: new Date(new Date().setDate(new Date().getDate() - 10)),
+ name: "",
+ id: 1,
+ status: "info",
+ },
+ ],
+ data,
+ cardType: "requests",
+ barType: "dominant",
+ });
+ return {
+ data: processedData,
+ uptime: "100%",
+ };
+ }),
+
+ getReport: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase(), id: z.number() }))
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db
+ .select()
+ .from(page)
+ .where(
+ sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ )
+ .get();
+
+ if (!_page) return null;
+
+ const _report = await opts.ctx.db.query.statusReport.findFirst({
+ where: and(
+ eq(statusReport.id, opts.input.id),
+ eq(statusReport.pageId, _page.id),
+ ),
+ with: {
+ monitorsToStatusReports: { with: { monitor: true } },
+ statusReportUpdates: {
+ orderBy: (reports, { desc }) => desc(reports.date),
+ },
+ },
+ });
+
+ if (!_report) return null;
+
+ return _report;
+ }),
+
+ getMonitors: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase() }))
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ // NOTE: revalidate the public monitors first
+ const data = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ with: {
+ monitorsToPages: {
+ with: {
+ monitor: true,
+ },
+ },
+ },
+ });
+
+ if (!data) return null;
+
+ const publicMonitors = data.monitorsToPages.filter(
+ (m) => m.monitor.public,
+ );
+
+ const monitorsByType = {
+ http: publicMonitors.filter((m) => m.monitor.jobType === "http"),
+ tcp: publicMonitors.filter((m) => m.monitor.jobType === "tcp"),
+ };
+
+ const proceduresByType = {
+ http: getMetricsLatencyMultiProcedure("1d", "http"),
+ tcp: getMetricsLatencyMultiProcedure("1d", "tcp"),
+ };
+
+ const [metricsLatencyMultiHttp, metricsLatencyMultiTcp] =
+ await Promise.all(
+ Object.entries(proceduresByType).map(([type, procedure]) => {
+ const monitorIds = monitorsByType[
+ type as keyof typeof proceduresByType
+ ].map((m) => m.monitor.id.toString());
+ if (monitorIds.length === 0) return null;
+ return procedure({ monitorIds });
+ }),
+ );
+
+ const metricsDataByMonitorId = new Map<
+ string,
+ | Awaited>["data"]
+ | Awaited>["data"]
+ >();
+
+ if (metricsLatencyMultiHttp?.data) {
+ metricsLatencyMultiHttp.data.forEach((metric) => {
+ const monitorId = metric.monitorId;
+ if (!metricsDataByMonitorId.has(monitorId)) {
+ metricsDataByMonitorId.set(monitorId, []);
+ }
+ metricsDataByMonitorId.get(monitorId)?.push(metric);
+ });
+ }
+
+ if (metricsLatencyMultiTcp?.data) {
+ metricsLatencyMultiTcp.data.forEach((metric) => {
+ const monitorId = metric.monitorId;
+ if (!metricsDataByMonitorId.has(monitorId)) {
+ metricsDataByMonitorId.set(monitorId, []);
+ }
+ metricsDataByMonitorId.get(monitorId)?.push(metric);
+ });
+ }
+
+ return publicMonitors.map((m) => {
+ const monitorId = m.monitor.id.toString();
+ const data = metricsDataByMonitorId.get(monitorId) || [];
+
+ return {
+ ...selectPublicMonitorSchema.parse(m.monitor),
+ data,
+ };
+ });
+ }),
+
+ getMonitor: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase(), id: z.number() }))
+ .query(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ with: {
+ monitorsToPages: {
+ where: eq(monitorsToPages.monitorId, opts.input.id),
+ with: {
+ monitor: true,
+ },
+ },
+ },
+ });
+
+ if (!_page) return null;
+
+ const _monitor = _page.monitorsToPages.find(
+ (m) => m.monitorId === opts.input.id,
+ )?.monitor;
+
+ if (!_monitor) return null;
+ if (!_monitor.public) return null;
+ if (_monitor.deletedAt) return null;
+
+ const type = _monitor.jobType as "http" | "tcp";
+
+ const proceduresByType = {
+ http: {
+ latency: getMetricsLatencyProcedure("7d", "http"),
+ regions: getMetricsRegionsProcedure("7d", "http"),
+ uptime: getUptimeProcedure("7d", "http"),
+ },
+ tcp: {
+ latency: getMetricsLatencyProcedure("7d", "tcp"),
+ regions: getMetricsRegionsProcedure("7d", "tcp"),
+ uptime: getUptimeProcedure("7d", "tcp"),
+ },
+ };
+
+ const [latency, regions, uptime] = await Promise.all([
+ await proceduresByType[type].latency({
+ monitorId: _monitor.id.toString(),
+ }),
+ await proceduresByType[type].regions({
+ monitorId: _monitor.id.toString(),
+ }),
+ await proceduresByType[type].uptime({
+ monitorId: _monitor.id.toString(),
+ interval: 240,
+ }),
+ ]);
+
+ return {
+ ...selectPublicMonitorSchema.parse(_monitor),
+ data: {
+ latency,
+ regions,
+ uptime,
+ },
+ };
+ }),
+
+ subscribe: publicProcedure
+ .input(
+ z.object({ slug: z.string().toLowerCase(), email: z.string().email() }),
+ )
+ .mutation(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ with: {
+ workspace: true,
+ },
+ });
+
+ if (!_page) return null;
+
+ if (_page.workspace.plan === "free") return null;
+
+ const _alreadySubscribed =
+ await opts.ctx.db.query.pageSubscriber.findFirst({
+ where: and(
+ eq(pageSubscriber.pageId, _page.id),
+ eq(pageSubscriber.email, opts.input.email),
+ ),
+ });
+
+ if (_alreadySubscribed) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email already subscribed",
+ });
+ }
+
+ const _pageSubscriber = await opts.ctx.db
+ .insert(pageSubscriber)
+ .values({
+ pageId: _page.id,
+ email: opts.input.email,
+ token: crypto.randomUUID(),
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
+ })
+ .returning()
+ .get();
+
+ return _pageSubscriber.id;
+ }),
+
+ verifyEmail: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase(), token: z.string() }))
+ .mutation(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ });
+
+ if (!_page) return null;
+
+ const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({
+ where: and(
+ eq(pageSubscriber.token, opts.input.token),
+ eq(pageSubscriber.pageId, _page.id),
+ ),
+ });
+
+ if (_pageSubscriber?.acceptedAt) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email already verified",
+ });
+ }
+
+ if (!_pageSubscriber) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Subscription not found",
+ });
+ }
+
+ await opts.ctx.db
+ .update(pageSubscriber)
+ .set({
+ acceptedAt: new Date(),
+ })
+ .where(eq(pageSubscriber.id, _pageSubscriber.id))
+ .execute();
+
+ return _pageSubscriber;
+ }),
+
+ verifyPassword: publicProcedure
+ .input(z.object({ slug: z.string().toLowerCase(), password: z.string() }))
+ .mutation(async (opts) => {
+ if (!opts.input.slug) return null;
+
+ const _page = await opts.ctx.db.query.page.findFirst({
+ where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
+ });
+
+ if (!_page) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Page not found",
+ });
+ }
+
+ if (_page.password !== opts.input.password) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid password",
+ });
+ }
+
+ return true;
+ }),
+});
diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/api/src/router/statusPage.utils.ts
new file mode 100644
index 00000000..2452e01b
--- /dev/null
+++ b/packages/api/src/router/statusPage.utils.ts
@@ -0,0 +1,581 @@
+import type {
+ Incident,
+ Maintenance,
+ StatusReport,
+ StatusReportUpdate,
+} from "@openstatus/db/src/schema";
+
+type StatusData = {
+ day: string;
+ count: number;
+ ok: number;
+ degraded: number;
+ error: number;
+ monitorId: string;
+};
+
+export function fillStatusDataFor45Days(
+ data: Array,
+ monitorId: string,
+): Array {
+ const result = [];
+ const dataByDay = new Map();
+
+ // Index existing data by day
+ data.forEach((item) => {
+ const dayKey = new Date(item.day).toISOString().split("T")[0]; // YYYY-MM-DD format
+ dataByDay.set(dayKey, item);
+ });
+
+ // Generate all 45 days from today backwards
+ const now = new Date();
+ for (let i = 0; i < 45; i++) {
+ const date = new Date(now);
+ date.setUTCDate(date.getUTCDate() - i);
+ date.setUTCHours(0, 0, 0, 0); // Set to start of day in UTC
+
+ const dayKey = date.toISOString().split("T")[0]; // YYYY-MM-DD format
+ const isoString = date.toISOString();
+
+ if (dataByDay.has(dayKey)) {
+ // Use existing data but ensure the day is properly formatted
+ const existingData = dataByDay.get(dayKey);
+ result.push({
+ ...existingData,
+ day: isoString,
+ });
+ } else {
+ // Fill missing day with default values
+ result.push({
+ day: isoString,
+ count: 0,
+ ok: 0,
+ degraded: 0,
+ error: 0,
+ monitorId,
+ });
+ }
+ }
+
+ // Sort by day (oldest first)
+ return result.sort(
+ (a, b) => new Date(a.day).getTime() - new Date(b.day).getTime(),
+ );
+}
+
+export function fillStatusDataFor45DaysNoop(): Array {
+ const data: StatusData[] = Array.from({ length: 45 }, (_, i) => ({
+ day: new Date(new Date().setDate(new Date().getDate() - i)).toISOString(),
+ count: 1,
+ ok: [4, 40].includes(i) ? 0 : 1,
+ degraded: i === 40 ? 1 : 0,
+ error: i === 4 ? 1 : 0,
+ monitorId: "1",
+ }));
+ return fillStatusDataFor45Days(data, "1");
+}
+
+type Event = {
+ id: number;
+ name: string;
+ from: Date;
+ to: Date | null;
+ type: "maintenance" | "incident" | "report";
+ status: "success" | "degraded" | "error" | "info";
+};
+
+export function getEvents({
+ maintenances,
+ incidents,
+ reports,
+ monitorId,
+ pastDays = 45,
+}: {
+ maintenances: (Maintenance & {
+ maintenancesToMonitors: { monitorId: number }[];
+ })[];
+ incidents: Incident[];
+ reports: (StatusReport & {
+ monitorsToStatusReports: { monitorId: number }[];
+ statusReportUpdates: StatusReportUpdate[];
+ })[];
+ monitorId?: number;
+ pastDays?: number;
+}): Event[] {
+ const events: Event[] = [];
+ const pastThreshod = new Date();
+ pastThreshod.setDate(pastThreshod.getDate() - pastDays);
+
+ // Filter maintenances - if monitorId is provided, filter by monitor, otherwise include all
+ maintenances
+ .filter((maintenance) =>
+ monitorId
+ ? maintenance.maintenancesToMonitors.some(
+ (m) => m.monitorId === monitorId,
+ )
+ : true,
+ )
+ .forEach((maintenance) => {
+ if (maintenance.from < pastThreshod) return;
+ events.push({
+ id: maintenance.id,
+ name: maintenance.title,
+ from: maintenance.from,
+ to: maintenance.to,
+ type: "maintenance",
+ status: "info" as const,
+ });
+ });
+
+ // Filter incidents - if monitorId is provided, filter by monitor, otherwise include all
+ incidents
+ .filter((incident) => (monitorId ? incident.monitorId === monitorId : true))
+ .forEach((incident) => {
+ if (!incident.createdAt || incident.createdAt < pastThreshod) return;
+ events.push({
+ id: incident.id,
+ name: incident.title,
+ from: incident.createdAt,
+ to: incident.resolvedAt,
+ type: "incident",
+ status: "error" as const,
+ });
+ });
+
+ // Filter reports - if monitorId is provided, filter by monitor, otherwise include all
+ reports
+ .filter((report) =>
+ monitorId
+ ? report.monitorsToStatusReports.some((m) => m.monitorId === monitorId)
+ : true,
+ )
+ .map((report) => {
+ const updates = report.statusReportUpdates.sort(
+ (a, b) => a.date.getTime() - b.date.getTime(),
+ );
+ const firstUpdate = updates[0];
+ const lastUpdate = updates[updates.length - 1];
+ if (!firstUpdate?.date || firstUpdate.date < pastThreshod) return;
+ events.push({
+ id: report.id,
+ name: report.title,
+ from: firstUpdate?.date,
+ to:
+ lastUpdate?.status === "resolved" ||
+ lastUpdate?.status === "monitoring"
+ ? lastUpdate?.date
+ : null,
+ type: "report",
+ status: "degraded" as const,
+ });
+ });
+
+ return events;
+}
+
+// Keep the old function name for backward compatibility
+export const getEventsByMonitorId = getEvents;
+
+type UptimeData = {
+ day: string;
+ events: Event[];
+ bar: {
+ status: "success" | "degraded" | "error" | "info" | "empty";
+ height: number; // percentage
+ }[];
+ card: {
+ status: "success" | "degraded" | "error" | "info" | "empty";
+ value: string;
+ }[];
+};
+
+// Priority mapping for status types (higher number = higher priority)
+const STATUS_PRIORITY = {
+ error: 3,
+ degraded: 2,
+ info: 1,
+ success: 0,
+ empty: -1,
+} as const;
+
+// Helper to get highest priority status from data
+function getHighestPriorityStatus(
+ item: StatusData,
+): keyof typeof STATUS_PRIORITY {
+ if (item.error > 0) return "error";
+ if (item.degraded > 0) return "degraded";
+ if (item.ok > 0) return "success";
+
+ return "empty";
+}
+
+// Helper to format numbers
+function formatNumber(num: number): string {
+ if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
+ if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
+ return num.toString();
+}
+
+// Helper to check if date is today
+function isToday(date: Date): boolean {
+ const today = new Date();
+ return (
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear()
+ );
+}
+
+// Helper to format duration from minutes
+function formatDuration(minutes: number): string {
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ if (remainingMinutes === 0) return `${hours}h`;
+ return `${hours}h ${remainingMinutes}m`;
+}
+
+// Helper to check if date is within event range
+function isDateWithinEvent(date: Date, event: Event): boolean {
+ const startOfDay = new Date(date);
+ startOfDay.setUTCHours(0, 0, 0, 0);
+
+ const endOfDay = new Date(date);
+ endOfDay.setUTCHours(23, 59, 59, 999);
+
+ const eventStart = new Date(event.from);
+ const eventEnd = event.to ? new Date(event.to) : new Date();
+
+ return (
+ eventStart.getTime() <= endOfDay.getTime() &&
+ eventEnd.getTime() >= startOfDay.getTime()
+ );
+}
+
+function getTotalEventsDurationMs(events: Event[], date: Date): number {
+ if (events.length === 0) return 0;
+
+ const startOfDay = new Date(date);
+ startOfDay.setUTCHours(0, 0, 0, 0);
+
+ const endOfDay = new Date(date);
+ endOfDay.setUTCHours(23, 59, 59, 999);
+
+ const total = events.reduce((acc, curr) => {
+ if (!curr.from) return acc;
+
+ const eventStart = new Date(curr.from);
+ const eventEnd = curr.to ? new Date(curr.to) : new Date();
+
+ // Only count events that overlap with this date
+ if (
+ eventEnd.getTime() < startOfDay.getTime() ||
+ eventStart.getTime() > endOfDay.getTime()
+ ) {
+ return acc;
+ }
+
+ // Calculate the overlapping duration within the date boundaries
+ const overlapStart = Math.max(eventStart.getTime(), startOfDay.getTime());
+ const overlapEnd = Math.min(eventEnd.getTime(), endOfDay.getTime());
+
+ const duration = overlapEnd - overlapStart;
+ return acc + Math.max(0, duration);
+ }, 0);
+
+ // Cap at 24 hours (86400000 milliseconds) per day
+ return Math.min(total, 24 * 60 * 60 * 1000);
+}
+
+export function setDataByType({
+ events,
+ data,
+ cardType,
+ barType,
+}: {
+ events: Event[];
+ data: StatusData[];
+ cardType: "requests" | "duration" | "dominant" | "manual";
+ barType: "absolute" | "dominant" | "manual";
+}): UptimeData[] {
+ return data.map((dayData) => {
+ const date = new Date(dayData.day);
+
+ // Find events for this day
+ const dayEvents = events.filter((event) => isDateWithinEvent(date, event));
+
+ // Determine status override based on events
+ const incidents = dayEvents.filter((e) => e.type === "incident");
+ const reports = dayEvents.filter((e) => e.type === "report");
+ const maintenances = dayEvents.filter((e) => e.type === "maintenance");
+
+ const hasIncidents = incidents.length > 0;
+ const hasReports = reports.length > 0;
+ const hasMaintenances = maintenances.length > 0;
+
+ const eventStatus = hasIncidents
+ ? "error"
+ : hasReports
+ ? "degraded"
+ : hasMaintenances
+ ? "info"
+ : undefined;
+
+ // Calculate bar data based on barType
+ // TODO: transform into a new Map();
+ let barData: UptimeData["bar"];
+
+ const total = dayData.ok + dayData.degraded + dayData.error;
+ const dataStatus = getHighestPriorityStatus(dayData);
+
+ switch (barType) {
+ case "absolute":
+ if (eventStatus) {
+ // If there's an event override, show single status
+ barData = [
+ {
+ status: eventStatus,
+ height: 100,
+ },
+ ];
+ } else if (total === 0) {
+ // Empty day
+ barData = [
+ {
+ status: "empty",
+ height: 100,
+ },
+ ];
+ } else {
+ // Multiple segments for absolute view
+ const segments = [
+ { status: "success" as const, count: dayData.ok },
+ { status: "degraded" as const, count: dayData.degraded },
+ { status: "error" as const, count: dayData.error },
+ ]
+ .filter((segment) => segment.count > 0)
+ .map((segment) => ({
+ status: segment.status,
+ height: (segment.count / total) * 100,
+ }));
+
+ barData = segments;
+ }
+ break;
+ case "dominant":
+ barData = [
+ {
+ status: eventStatus ?? dataStatus,
+ height: 100,
+ },
+ ];
+ break;
+ case "manual":
+ const manualEventStatus = hasReports
+ ? "degraded"
+ : hasMaintenances
+ ? "info"
+ : undefined;
+ barData = [
+ {
+ status: manualEventStatus || "success",
+ height: 100,
+ },
+ ];
+ break;
+ default:
+ // Default to dominant behavior
+ barData = [
+ {
+ status: eventStatus ?? dataStatus,
+ height: 100,
+ },
+ ];
+ break;
+ }
+
+ // Calculate card data based on cardType
+ // TODO: transform into a new Map();
+ let cardData: UptimeData["card"] = [];
+
+ switch (cardType) {
+ case "requests":
+ if (total === 0) {
+ cardData = [{ status: eventStatus ?? "empty", value: "1 day" }];
+ } else {
+ const entries = [
+ { status: "success" as const, count: dayData.ok },
+ { status: "degraded" as const, count: dayData.degraded },
+ { status: "error" as const, count: dayData.error },
+ { status: "info" as const, count: 0 },
+ ];
+
+ cardData = entries
+ .filter((entry) => entry.count > 0)
+ .map((entry) => ({
+ status: entry.status,
+ value: `${formatNumber(entry.count)} reqs`,
+ }));
+ }
+ break;
+
+ case "duration":
+ if (total === 0) {
+ cardData = [{ status: eventStatus ?? "empty", value: "1 day" }];
+ } else {
+ const entries = [
+ { status: "error" as const, count: dayData.error },
+ { status: "degraded" as const, count: dayData.degraded },
+ { status: "success" as const, count: dayData.ok },
+ { status: "info" as const, count: 0 },
+ ];
+
+ const map = new Map<
+ "error" | "degraded" | "success" | "info",
+ number
+ >();
+
+ cardData = entries
+ .map((entry) => {
+ if (entry.status === "error") {
+ const totalDuration = getTotalEventsDurationMs(incidents, date);
+ const minutes = Math.round(totalDuration / (1000 * 60));
+ map.set("error", minutes);
+ if (minutes === 0) return null;
+ return {
+ status: entry.status,
+ value: formatDuration(minutes),
+ };
+ }
+
+ if (entry.status === "degraded") {
+ const totalDuration = getTotalEventsDurationMs(reports, date);
+ const minutes = Math.round(totalDuration / (1000 * 60));
+ map.set("degraded", minutes);
+ if (minutes === 0) return null;
+ return {
+ status: entry.status,
+ value: formatDuration(minutes),
+ };
+ }
+
+ if (entry.status === "info") {
+ const totalDuration = getTotalEventsDurationMs(
+ maintenances,
+ date,
+ );
+ const minutes = Math.round(totalDuration / (1000 * 60));
+ map.set("info", minutes);
+ if (minutes === 0) return null;
+ return {
+ status: entry.status,
+ value: formatDuration(minutes),
+ };
+ }
+
+ if (entry.status === "success") {
+ let total = 0;
+ // biome-ignore lint/suspicious/noAssignInExpressions:
+ map.forEach((d) => (total += d));
+ const day = 24 * 60;
+ const minutes = Math.max(day - total, 0);
+ if (minutes === 0) return null;
+ return {
+ status: entry.status,
+ value: formatDuration(minutes),
+ };
+ }
+ })
+ .filter((item): item is NonNullable => item !== null);
+ }
+ break;
+
+ case "dominant":
+ cardData = [
+ {
+ status: eventStatus ?? dataStatus,
+ value: "",
+ },
+ ];
+ break;
+
+ case "manual":
+ const manualEventStatus = hasReports
+ ? "degraded"
+ : hasMaintenances
+ ? "info"
+ : undefined;
+ cardData = [
+ {
+ status: manualEventStatus || "success",
+ value: "",
+ },
+ ];
+ break;
+ default:
+ // Default to requests behavior
+ if (total === 0) {
+ cardData = [{ status: eventStatus ?? "empty", value: "1 day" }];
+ } else {
+ const entries = [
+ { status: "error" as const, count: dayData.error },
+ { status: "degraded" as const, count: dayData.degraded },
+ { status: "success" as const, count: dayData.ok },
+ ];
+
+ cardData = entries
+ .filter((entry) => entry.count > 0)
+ .map((entry) => ({
+ status: entry.status,
+ value: `${formatNumber(entry.count)} reqs`,
+ }));
+ }
+ break;
+ }
+
+ return {
+ day: dayData.day,
+ events: [...reports, ...maintenances],
+ bar: barData,
+ card: cardData,
+ };
+ });
+}
+
+export function getUptime({
+ data,
+ events,
+ barType,
+}: {
+ data: StatusData[];
+ events: Event[];
+ barType: "absolute" | "dominant" | "manual";
+}): string {
+ if (barType === "manual") {
+ const duration = events
+ // NOTE: we want only user events
+ .filter((e) => e.type === "report")
+ .reduce((acc, item) => {
+ if (!item.from) return acc;
+ return acc + ((item.to || new Date()).getTime() - item.from.getTime());
+ }, 0);
+
+ const total = data.length * 24 * 60 * 60 * 1000;
+
+ return `${Math.round(((total - duration) / total) * 10000) / 100}%`;
+ }
+
+ 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,
+ },
+ );
+
+ if (total === 0) return "100%";
+ return `${Math.round((ok / total) * 10000) / 100}%`;
+}
diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts
index a91601f1..c312d5c7 100644
--- a/packages/api/src/router/tinybird/index.ts
+++ b/packages/api/src/router/tinybird/index.ts
@@ -18,11 +18,11 @@ type Period = (typeof periods)[number];
type Type = (typeof types)[number];
// NEW: workspace-level counters helper
-function getWorkspace30dProcedure(type: Type) {
+export function getWorkspace30dProcedure(type: Type) {
return type === "http" ? tb.httpWorkspace30d : tb.tcpWorkspace30d;
}
// Helper functions to get the right procedure based on period and type
-function getListProcedure(period: Period, type: Type) {
+export function getListProcedure(period: Period, type: Type) {
switch (period) {
case "1d":
return type === "http" ? tb.httpListDaily : tb.tcpListDaily;
@@ -35,7 +35,7 @@ function getListProcedure(period: Period, type: Type) {
}
}
-function getMetricsProcedure(period: Period, type: Type) {
+export function getMetricsProcedure(period: Period, type: Type) {
switch (period) {
case "1d":
return type === "http" ? tb.httpMetricsDaily : tb.tcpMetricsDaily;
@@ -48,7 +48,7 @@ function getMetricsProcedure(period: Period, type: Type) {
}
}
-function getMetricsByRegionProcedure(period: Period, type: Type) {
+export function getMetricsByRegionProcedure(period: Period, type: Type) {
switch (period) {
case "1d":
return type === "http"
@@ -69,7 +69,7 @@ function getMetricsByRegionProcedure(period: Period, type: Type) {
}
}
-function getMetricsByIntervalProcedure(period: Period, type: Type) {
+export function getMetricsByIntervalProcedure(period: Period, type: Type) {
switch (period) {
case "1d":
return type === "http"
@@ -91,7 +91,7 @@ function getMetricsByIntervalProcedure(period: Period, type: Type) {
}
// FIXME: tb pipes are deprecated, we need new ones
-function getMetricsRegionsProcedure(period: Period, type: Type) {
+export function getMetricsRegionsProcedure(period: Period, type: Type) {
switch (period) {
case "1d":
return type === "http"
@@ -112,18 +112,11 @@ function getMetricsRegionsProcedure(period: Period, type: Type) {
}
}
-function getStatusProcedure(period: "7d" | "45d", type: Type) {
- switch (period) {
- case "7d":
- return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly;
- case "45d":
- return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d;
- default:
- return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly;
- }
+export function getStatusProcedure(_period: "45d", type: Type) {
+ return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d;
}
-function getGetProcedure(period: "14d", type: Type) {
+export function getGetProcedure(period: "14d", type: Type) {
switch (period) {
case "14d":
return type === "http" ? tb.httpGetBiweekly : tb.tcpGetBiweekly;
@@ -132,11 +125,11 @@ function getGetProcedure(period: "14d", type: Type) {
}
}
-function getGlobalMetricsProcedure(type: Type) {
+export function getGlobalMetricsProcedure(type: Type) {
return type === "http" ? tb.httpGlobalMetricsDaily : tb.tcpGlobalMetricsDaily;
}
-function getUptimeProcedure(period: "7d" | "30d", type: Type) {
+export function getUptimeProcedure(period: "7d" | "30d", type: Type) {
switch (period) {
case "7d":
return type === "http" ? tb.httpUptimeWeekly : tb.tcpUptimeWeekly;
@@ -148,11 +141,24 @@ function getUptimeProcedure(period: "7d" | "30d", type: Type) {
}
// TODO: missing pipes for other periods
-function getMetricsLatencyProcedure(_period: Period, type: Type) {
- return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d;
+export function getMetricsLatencyProcedure(_period: Period, type: Type) {
+ switch (_period) {
+ case "1d":
+ return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d;
+ case "7d":
+ return type === "http" ? tb.httpMetricsLatency7d : tb.tcpMetricsLatency7d;
+ default:
+ return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d;
+ }
}
-function getTimingPhasesProcedure(type: Type) {
+export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) {
+ return type === "http"
+ ? tb.httpMetricsLatency1dMulti
+ : tb.tcpMetricsLatency1dMulti;
+}
+
+export function getTimingPhasesProcedure(type: Type) {
return type === "http" ? tb.httpTimingPhases14d : null;
}
@@ -420,8 +426,8 @@ export const tinybirdRouter = createTRPCRouter({
status: protectedProcedure
.input(
z.object({
- monitorId: z.string(),
- period: z.enum(["7d", "45d"]),
+ monitorIds: z.string().array(),
+ period: z.enum(["45d"]),
type: z.enum(types).default("http"),
region: z.enum(flyRegions).optional(),
cronTimestamp: z.number().int().optional(),
@@ -429,18 +435,18 @@ export const tinybirdRouter = createTRPCRouter({
)
.query(async (opts) => {
const whereConditions: SQL[] = [
- eq(monitor.id, Number.parseInt(opts.input.monitorId)),
+ inArray(monitor.id, opts.input.monitorIds.map(Number)),
eq(monitor.workspaceId, opts.ctx.workspace.id),
];
- const _monitor = await db.query.monitor.findFirst({
+ const _monitors = await db.query.monitor.findMany({
where: and(...whereConditions),
});
- if (!_monitor) {
+ if (_monitors.length !== opts.input.monitorIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "Monitor not found",
+ message: "Some monitors not found",
});
}
@@ -556,6 +562,22 @@ export const tinybirdRouter = createTRPCRouter({
return await procedure(opts.input);
}),
+ metricsLatencyMulti: protectedProcedure
+ .input(
+ z.object({
+ monitorIds: z.string().array(),
+ period: z.enum(["1d"]).default("1d"),
+ type: z.enum(types).default("http"),
+ }),
+ )
+ .query(async (opts) => {
+ const procedure = getMetricsLatencyMultiProcedure(
+ opts.input.period,
+ opts.input.type,
+ );
+ return await procedure(opts.input);
+ }),
+
workspace30d: protectedProcedure
.input(
z.object({
diff --git a/packages/db/src/schema/pages/page.ts b/packages/db/src/schema/pages/page.ts
index 94df8d37..8692fa70 100644
--- a/packages/db/src/schema/pages/page.ts
+++ b/packages/db/src/schema/pages/page.ts
@@ -53,7 +53,7 @@ export const page = sqliteTable("page", {
export const pageRelations = relations(page, ({ many, one }) => ({
monitorsToPages: many(monitorsToPages),
- maintenancesToPages: many(maintenance),
+ maintenances: many(maintenance),
statusReports: many(statusReport),
workspace: one(workspace, {
fields: [page.workspaceId],
diff --git a/packages/db/src/schema/shared.ts b/packages/db/src/schema/shared.ts
index c9225a98..712e3ab7 100644
--- a/packages/db/src/schema/shared.ts
+++ b/packages/db/src/schema/shared.ts
@@ -39,6 +39,7 @@ export const selectMaintenancePageSchema = selectMaintenanceSchema.extend({
z.object({
monitorId: z.number(),
maintenanceId: z.number(),
+ monitor: selectPublicMonitorSchema,
}),
)
.default([]),
@@ -60,19 +61,67 @@ export const selectPageSchemaWithMonitorsRelation = selectPageSchema.extend({
monitor: selectMonitorSchema,
}),
),
- maintenancesToPages: selectMaintenanceSchema.array().default([]),
+ maintenances: selectMaintenanceSchema.array().default([]),
statusReports: selectStatusReportSchema
.extend({ statusReportUpdates: selectStatusReportUpdateSchema.array() })
.array()
.default([]),
});
+export const legacy_selectPublicPageSchemaWithRelation = selectPageSchema
+ .extend({
+ monitors: z.array(selectPublicMonitorSchema).default([]),
+ statusReports: z.array(selectStatusReportPageSchema).default([]),
+ incidents: z.array(selectIncidentSchema).default([]),
+ maintenances: z.array(selectMaintenancePageSchema).default([]),
+ workspacePlan: workspacePlanSchema
+ .nullable()
+ .default("free")
+ .transform((val) => val ?? "free"),
+ })
+ .omit({
+ // workspaceId: true,
+ id: true,
+ });
+
export const selectPublicPageSchemaWithRelation = selectPageSchema
.extend({
- monitors: z.array(selectPublicMonitorSchema),
+ // TODO: include status of the monitor
+ monitors: selectPublicMonitorSchema
+ .extend({
+ status: z
+ .enum(["success", "degraded", "error", "info"])
+ .default("success"),
+ })
+ .array(),
+ lastEvents: z.array(
+ z.object({
+ id: z.number(),
+ name: z.string(),
+ from: z.date(),
+ to: z.date().nullable(),
+ status: z
+ .enum(["success", "degraded", "error", "info"])
+ .default("success"),
+ type: z.enum(["maintenance", "incident", "report"]),
+ }),
+ ),
+ openEvents: z.array(
+ z.object({
+ id: z.number(),
+ name: z.string(),
+ from: z.date(),
+ to: z.date().nullable(),
+ status: z
+ .enum(["success", "degraded", "error", "info"])
+ .default("success"),
+ type: z.enum(["maintenance", "incident", "report"]),
+ }),
+ ),
statusReports: z.array(selectStatusReportPageSchema),
incidents: z.array(selectIncidentSchema),
maintenances: z.array(selectMaintenancePageSchema),
+ status: z.enum(["success", "degraded", "error", "info"]).default("success"),
workspacePlan: workspacePlanSchema
.nullable()
.default("free")
@@ -81,6 +130,7 @@ export const selectPublicPageSchemaWithRelation = selectPageSchema
.omit({
// workspaceId: true,
id: true,
+ password: true,
});
export const selectPublicStatusReportSchemaWithRelation =
@@ -101,4 +151,6 @@ export type StatusReportWithUpdates = z.infer<
typeof selectStatusReportPageSchema
>;
export type PublicMonitor = z.infer;
-export type PublicPage = z.infer;
+export type PublicPage = z.infer<
+ typeof legacy_selectPublicPageSchemaWithRelation
+>;
diff --git a/packages/tinybird/datasources/mv__http_status_45d__v1.datasource b/packages/tinybird/datasources/mv__http_status_45d__v1.datasource
new file mode 100644
index 00000000..627fae3c
--- /dev/null
+++ b/packages/tinybird/datasources/mv__http_status_45d__v1.datasource
@@ -0,0 +1,14 @@
+# Data Source created from Pipe 'aggregate__http_status_45d__v1'
+
+SCHEMA >
+ `time` DateTime('UTC'),
+ `monitorId` String,
+ `count` AggregateFunction(count),
+ `success` AggregateFunction(count, Nullable(UInt8)),
+ `error` AggregateFunction(count, Nullable(UInt8)),
+ `degraded` AggregateFunction(count, Nullable(UInt8))
+
+ENGINE "AggregatingMergeTree"
+ENGINE_PARTITION_KEY "toYYYYMM(time)"
+ENGINE_SORTING_KEY "monitorId, time"
+ENGINE_TTL "time + toIntervalDay(46)"
diff --git a/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource b/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource
new file mode 100644
index 00000000..3729876a
--- /dev/null
+++ b/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource
@@ -0,0 +1,14 @@
+# Data Source created from Pipe 'aggregate__tcp_status_45d__v1'
+
+SCHEMA >
+ `time` DateTime('UTC'),
+ `monitorId` Int32,
+ `count` AggregateFunction(count),
+ `success` AggregateFunction(count, Nullable(UInt8)),
+ `error` AggregateFunction(count, Nullable(UInt8)),
+ `degraded` AggregateFunction(count, Nullable(UInt8))
+
+ENGINE "AggregatingMergeTree"
+ENGINE_PARTITION_KEY "toYYYYMM(time)"
+ENGINE_SORTING_KEY "monitorId, time"
+ENGINE_TTL "time + toIntervalDay(46)"
diff --git a/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe b/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe
new file mode 100644
index 00000000..3b3905ac
--- /dev/null
+++ b/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe
@@ -0,0 +1,21 @@
+TAGS "http, statuspage"
+
+NODE aggregate
+SQL >
+
+ SELECT
+ toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time,
+ monitorId,
+ countState() AS count,
+ countState(if(requestStatus = 'success', 1, NULL)) AS success,
+ countState(if(requestStatus = 'error', 1, NULL)) AS error,
+ countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded
+ FROM ping_response__v8
+ GROUP BY
+ time,
+ monitorId
+
+TYPE materialized
+DATASOURCE mv__http_status_45d__v1
+
+
diff --git a/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe b/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe
new file mode 100644
index 00000000..2b2107c8
--- /dev/null
+++ b/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe
@@ -0,0 +1,21 @@
+TAGS "tcp, statuspage"
+
+NODE aggregate
+SQL >
+
+ SELECT
+ toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time,
+ monitorId,
+ countState() AS count,
+ countState(if(requestStatus = 'success', 1, NULL)) AS success,
+ countState(if(requestStatus = 'error', 1, NULL)) AS error,
+ countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded
+ FROM tcp_response__v0
+ GROUP BY
+ time,
+ monitorId
+
+TYPE materialized
+DATASOURCE mv__tcp_status_45d__v1
+
+
diff --git a/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe
new file mode 100644
index 00000000..f74f0ea5
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe
@@ -0,0 +1,24 @@
+TAGS "http"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ toStartOfInterval(
+ toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE
+ ) as h,
+ monitorId,
+ toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp,
+ round(quantile(0.50)(latency)) as p50Latency,
+ round(quantile(0.75)(latency)) as p75Latency,
+ round(quantile(0.90)(latency)) as p90Latency,
+ round(quantile(0.95)(latency)) as p95Latency,
+ round(quantile(0.99)(latency)) as p99Latency
+ FROM mv__http_1d__v0
+ WHERE
+ monitorId IN {{ Array(monitorIds, 'String', '1,666') }}
+ GROUP BY h, monitorId
+ ORDER BY h DESC
+
+
diff --git a/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe b/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe
new file mode 100644
index 00000000..2cd927ee
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe
@@ -0,0 +1,23 @@
+TAGS "tcp"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ toStartOfInterval(
+ toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE
+ ) as h,
+ toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp,
+ round(quantile(0.50)(latency)) as p50Latency,
+ round(quantile(0.75)(latency)) as p75Latency,
+ round(quantile(0.90)(latency)) as p90Latency,
+ round(quantile(0.95)(latency)) as p95Latency,
+ round(quantile(0.99)(latency)) as p99Latency
+ FROM mv__http_7d__v0
+ WHERE
+ monitorId = {{ String(monitorId, '1', required=True) }}
+ GROUP BY h
+ ORDER BY h DESC
+
+
diff --git a/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe b/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe
new file mode 100644
index 00000000..6a73ac5f
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe
@@ -0,0 +1,19 @@
+TAGS "http"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ time as day,
+ monitorId,
+ countMerge(count) as count,
+ countMerge(success) as ok,
+ countMerge(error) as error,
+ countMerge(degraded) as degraded
+ FROM mv__http_status_45d__v1
+ WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }}
+ GROUP BY day, monitorId
+ ORDER BY day DESC
+
+
diff --git a/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe
new file mode 100644
index 00000000..791d9897
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe
@@ -0,0 +1,24 @@
+TAGS "tcp"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ toStartOfInterval(
+ toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE
+ ) as h,
+ monitorId,
+ toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp,
+ round(quantile(0.50)(latency)) as p50Latency,
+ round(quantile(0.75)(latency)) as p75Latency,
+ round(quantile(0.90)(latency)) as p90Latency,
+ round(quantile(0.95)(latency)) as p95Latency,
+ round(quantile(0.99)(latency)) as p99Latency
+ FROM mv__tcp_1d__v0
+ WHERE
+ monitorId IN {{ Array(monitorIds, 'String', '4433') }}
+ GROUP BY h, monitorId
+ ORDER BY h DESC
+
+
diff --git a/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe
new file mode 100644
index 00000000..108955e7
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe
@@ -0,0 +1,23 @@
+TAGS "tcp"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ toStartOfInterval(
+ toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE
+ ) as h,
+ toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp,
+ round(quantile(0.50)(latency)) as p50Latency,
+ round(quantile(0.75)(latency)) as p75Latency,
+ round(quantile(0.90)(latency)) as p90Latency,
+ round(quantile(0.95)(latency)) as p95Latency,
+ round(quantile(0.99)(latency)) as p99Latency
+ FROM mv__tcp_7d__v1
+ WHERE
+ monitorId = {{ String(monitorId, '4433', required=True) }}
+ GROUP BY h
+ ORDER BY h DESC
+
+
diff --git a/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe
new file mode 100644
index 00000000..846d52a4
--- /dev/null
+++ b/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe
@@ -0,0 +1,19 @@
+TAGS "tcp"
+
+NODE endpoint
+SQL >
+
+ %
+ SELECT
+ time as day,
+ monitorId,
+ countMerge(count) as count,
+ countMerge(success) as ok,
+ countMerge(error) as error,
+ countMerge(degraded) as degraded
+ FROM mv__tcp_status_45d__v1
+ WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }}
+ GROUP BY day, monitorId
+ ORDER BY day DESC
+
+
diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts
index af130f3d..e210e981 100644
--- a/packages/tinybird/src/client.ts
+++ b/packages/tinybird/src/client.ts
@@ -16,12 +16,12 @@ export class OSTinybird {
private readonly tb: Client;
constructor(token: string) {
- // this.tb = new Client({ token });
if (process.env.NODE_ENV === "development") {
this.tb = new NoopTinybird();
} else {
this.tb = new Client({ token });
}
+ // this.tb = new Client({ token });
}
public get homeStats() {
@@ -459,7 +459,7 @@ export class OSTinybird {
});
}
- public get httpStatus45d() {
+ public get legacy_httpStatus45d() {
return this.tb.buildPipe({
pipe: "endpoint__http_status_45d__v0",
parameters: z.object({
@@ -482,6 +482,27 @@ export class OSTinybird {
});
}
+ public get httpStatus45d() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__http_status_45d__v1",
+ parameters: z.object({
+ monitorIds: z.string().array(),
+ }),
+ data: z.object({
+ day: z.string().transform((val) => {
+ // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00)
+ return new Date(`${val} GMT`).toISOString();
+ }),
+ count: z.number().default(0),
+ ok: z.number().default(0),
+ degraded: z.number().default(0),
+ error: z.number().default(0),
+ monitorId: z.string(),
+ }),
+ opts: { next: { revalidate: REVALIDATE } },
+ });
+ }
+
public get httpGetBiweekly() {
return this.tb.buildPipe({
pipe: "endpoint__http_get_14d__v0",
@@ -987,7 +1008,7 @@ export class OSTinybird {
});
}
- public get tcpStatus45d() {
+ public get legacy_tcpStatus45d() {
return this.tb.buildPipe({
pipe: "endpoint__tcp_status_45d__v0",
parameters: z.object({
@@ -1010,6 +1031,32 @@ export class OSTinybird {
});
}
+ public get tcpStatus45d() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__tcp_status_45d__v1",
+ parameters: z.object({
+ monitorIds: z.string().array(),
+ days: z.number().int().max(45).optional(),
+ }),
+ data: z.object({
+ day: z.string().transform((val) => {
+ // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00)
+ return new Date(`${val} GMT`).toISOString();
+ }),
+ count: z.number().default(0),
+ ok: z.number().default(0),
+ degraded: z.number().default(0),
+ error: z.number().default(0),
+ monitorId: z.coerce.string(),
+ }),
+ opts: {
+ next: {
+ revalidate: PUBLIC_CACHE,
+ },
+ },
+ });
+ }
+
public get httpWorkspace30d() {
return this.tb.buildPipe({
pipe: "endpoint__http_workspace_30d__v0",
@@ -1360,6 +1407,42 @@ export class OSTinybird {
});
}
+ public get httpMetricsLatency7d() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__http_metrics_latency_7d__v1",
+ parameters: z.object({
+ monitorId: z.string(),
+ }),
+ data: z.object({
+ timestamp: z.number().int(),
+ p50Latency: z.number().int(),
+ p75Latency: z.number().int(),
+ p90Latency: z.number().int(),
+ p95Latency: z.number().int(),
+ p99Latency: z.number().int(),
+ }),
+ });
+ }
+
+ public get httpMetricsLatency1dMulti() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__http_metrics_latency_1d_multi__v1",
+ parameters: z.object({
+ monitorIds: z.string().array().min(1),
+ }),
+ data: z.object({
+ timestamp: z.number().int(),
+ monitorId: z.string(),
+ p50Latency: z.number().int(),
+ p75Latency: z.number().int(),
+ p90Latency: z.number().int(),
+ p95Latency: z.number().int(),
+ p99Latency: z.number().int(),
+ }),
+ opts: { next: { revalidate: REVALIDATE } },
+ });
+ }
+
public get tcpMetricsLatency1d() {
return this.tb.buildPipe({
pipe: "endpoint__tcp_metrics_latency_1d__v1",
@@ -1377,4 +1460,40 @@ export class OSTinybird {
}),
});
}
+
+ public get tcpMetricsLatency7d() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__tcp_metrics_latency_7d__v1",
+ parameters: z.object({
+ monitorId: z.string(),
+ }),
+ data: z.object({
+ timestamp: z.number().int(),
+ p50Latency: z.number().int(),
+ p75Latency: z.number().int(),
+ p90Latency: z.number().int(),
+ p95Latency: z.number().int(),
+ p99Latency: z.number().int(),
+ }),
+ });
+ }
+
+ public get tcpMetricsLatency1dMulti() {
+ return this.tb.buildPipe({
+ pipe: "endpoint__tcp_metrics_latency_1d_multi__v1",
+ parameters: z.object({
+ monitorIds: z.string().array().min(1),
+ }),
+ data: z.object({
+ timestamp: z.number().int(),
+ monitorId: z.coerce.string(),
+ p50Latency: z.number().int(),
+ p75Latency: z.number().int(),
+ p90Latency: z.number().int(),
+ p95Latency: z.number().int(),
+ p99Latency: z.number().int(),
+ }),
+ opts: { next: { revalidate: REVALIDATE } },
+ });
+ }
}