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 }; + } +}