diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 3efea32733..9bce601751 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -17,7 +17,7 @@ import {
ServerStackIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
-import { useLocation, useNavigation } from "@remix-run/react";
+import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
@@ -31,6 +31,7 @@ import { type MatchedProject } from "~/hooks/useProject";
import { type User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
+import { IncidentStatusPanel } from "~/routes/resources.incidents";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -279,16 +280,19 @@ export function SideMenu({
-
-
-
+
+
+
+
+
+
+ {isFreeUser && (
+
+ )}
- {isFreeUser && (
-
- )}
);
diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts
index 589c9a0a33..ec7072b8e3 100644
--- a/apps/webapp/app/env.server.ts
+++ b/apps/webapp/app/env.server.ts
@@ -1,7 +1,7 @@
-import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
import { z } from "zod";
-import { isValidRegex } from "./utils/regex";
+import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
import { isValidDatabaseUrl } from "./utils/db";
+import { isValidRegex } from "./utils/regex";
const EnvironmentSchema = z.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -721,6 +721,10 @@ const EnvironmentSchema = z.object({
// kapa.ai
KAPA_AI_WEBSITE_ID: z.string().optional(),
+
+ // BetterStack
+ BETTERSTACK_API_KEY: z.string().optional(),
+ BETTERSTACK_STATUS_PAGE_ID: z.string().optional(),
});
export type Environment = z.infer
;
diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx
new file mode 100644
index 0000000000..319242c8d8
--- /dev/null
+++ b/apps/webapp/app/routes/resources.incidents.tsx
@@ -0,0 +1,83 @@
+import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
+import { json } from "@remix-run/node";
+import { useFetcher } from "@remix-run/react";
+import { useCallback, useEffect } from "react";
+import { motion } from "framer-motion";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { useFeatures } from "~/hooks/useFeatures";
+import { BetterStackClient } from "~/services/betterstack/betterstack.server";
+
+export async function loader() {
+ const client = new BetterStackClient();
+ const result = await client.getIncidents();
+
+ if (!result.success) {
+ return json({ operational: true });
+ }
+
+ return json({
+ operational: result.data.attributes.aggregate_state === "operational",
+ });
+}
+
+export function IncidentStatusPanel() {
+ const { isManagedCloud } = useFeatures();
+ if (!isManagedCloud) {
+ return null;
+ }
+
+ const fetcher = useFetcher();
+
+ const fetchIncidents = useCallback(() => {
+ if (fetcher.state === "idle") {
+ fetcher.load("/resources/incidents");
+ }
+ }, [fetcher]);
+
+ useEffect(() => {
+ fetchIncidents();
+
+ const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const operational = fetcher.data?.operational ?? true;
+
+ return (
+ <>
+ {!operational && (
+
+
+
+
+
+ Active incident
+
+
+
+ Our team is working on resolving the issue. Check our status page for more
+ information.
+
+
+ View status page
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/webapp/app/services/betterstack/betterstack.server.ts b/apps/webapp/app/services/betterstack/betterstack.server.ts
new file mode 100644
index 0000000000..41a43b3a69
--- /dev/null
+++ b/apps/webapp/app/services/betterstack/betterstack.server.ts
@@ -0,0 +1,88 @@
+import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
+import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
+import { MemoryStore } from "@unkey/cache/stores";
+import { z } from "zod";
+import { env } from "~/env.server";
+
+const IncidentSchema = z.object({
+ data: z.object({
+ id: z.string(),
+ type: z.string(),
+ attributes: z.object({
+ aggregate_state: z.string(),
+ }),
+ }),
+});
+
+export type Incident = z.infer;
+
+const ctx = new DefaultStatefulContext();
+const memory = new MemoryStore({ persistentMap: new Map() });
+
+const cache = createCache({
+ query: new Namespace>(ctx, {
+ stores: [memory],
+ fresh: 15_000,
+ stale: 30_000,
+ }),
+});
+
+export class BetterStackClient {
+ private readonly baseUrl = "https://uptime.betterstack.com/api/v2";
+
+ async getIncidents() {
+ const apiKey = env.BETTERSTACK_API_KEY;
+ if (!apiKey) {
+ return { success: false as const, error: "BETTERSTACK_API_KEY is not set" };
+ }
+
+ const statusPageId = env.BETTERSTACK_STATUS_PAGE_ID;
+ if (!statusPageId) {
+ return { success: false as const, error: "BETTERSTACK_STATUS_PAGE_ID is not set" };
+ }
+
+ const cachedResult = await cache.query.swr("betterstack", async () => {
+ try {
+ const result = await wrapZodFetch(
+ IncidentSchema,
+ `${this.baseUrl}/status-pages/${statusPageId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ },
+ {
+ retry: {
+ maxAttempts: 3,
+ minTimeoutInMs: 1000,
+ maxTimeoutInMs: 5000,
+ },
+ }
+ );
+
+ return result;
+ } catch (error) {
+ console.error("Failed to fetch incidents from BetterStack:", error);
+ return {
+ success: false as const,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ });
+
+ if (cachedResult.err) {
+ return { success: false as const, error: cachedResult.err };
+ }
+
+ if (!cachedResult.val) {
+ return { success: false as const, error: "No result from BetterStack" };
+ }
+
+ if (!cachedResult.val.success) {
+ return { success: false as const, error: cachedResult.val.error };
+ }
+
+ return { success: true as const, data: cachedResult.val.data.data };
+ }
+}