From fce1ef7e0b47d32515f05c970c150e63d3ee13f4 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 8 May 2025 17:32:34 +0100 Subject: [PATCH 1/4] WIP adding a side menu panel to display incident statuses --- .../app/components/navigation/SideMenu.tsx | 24 +++--- apps/webapp/app/env.server.ts | 8 +- .../webapp/app/routes/resources.incidents.tsx | 75 +++++++++++++++++++ .../betterstack/betterstack.server.ts | 54 +++++++++++++ 4 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 apps/webapp/app/routes/resources.incidents.tsx create mode 100644 apps/webapp/app/services/betterstack/betterstack.server.ts 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..7f6a05e064 --- /dev/null +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -0,0 +1,75 @@ +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 { 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.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); + }, [fetchIncidents]); + + const operational = fetcher.data?.operational ?? true; + + return ( + <> + {!operational && ( +
+
+
+ + + Active Incident + +
+ + We're currently experiencing service disruptions. Our team is actively working on + resolving the issue. Check our status page for real-time updates. + + + 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..7de093420d --- /dev/null +++ b/apps/webapp/app/services/betterstack/betterstack.server.ts @@ -0,0 +1,54 @@ +import { wrapZodFetch } from "@trigger.dev/core/v3/zodfetch"; +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; + +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" }; + } + + try { + return await wrapZodFetch( + IncidentSchema, + `${this.baseUrl}/status-pages/${statusPageId}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }, + { + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 5000, + }, + } + ); + } catch (error) { + console.error("Failed to fetch incidents from BetterStack:", error); + return { success: false as const, error }; + } + } +} From 9f60ea4a9d29558dbaa6c6da1866a50be8bc1038 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 8 May 2025 17:49:18 +0100 Subject: [PATCH 2/4] Fixes re-rendering bug and copy tweak --- .../webapp/app/routes/resources.incidents.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx index 7f6a05e064..e11ee58e15 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -2,6 +2,7 @@ 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"; @@ -40,24 +41,28 @@ export function IncidentStatusPanel() { const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute return () => clearInterval(interval); - }, [fetchIncidents]); + }, []); const operational = fetcher.data?.operational ?? true; return ( <> {!operational && ( -
+
- Active Incident + Active incident
- - We're currently experiencing service disruptions. Our team is actively working on - resolving the issue. Check our status page for real-time updates. + + Our team is working on resolving the issue. Monitor our status page for updates. - View Status Page + View status page
-
+ )} ); From 0c3d934f7b978a170d3e0154a2394a4f1ee6fe4c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 8 May 2025 18:14:55 +0100 Subject: [PATCH 3/4] cache the betterstack response using unkey --- .../webapp/app/routes/resources.incidents.tsx | 2 +- .../betterstack/betterstack.server.ts | 76 ++++++++++++++----- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx index e11ee58e15..c57caa78e3 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -17,7 +17,7 @@ export async function loader() { } return json({ - operational: result.data.data.attributes.aggregate_state === "operational", + operational: result.data.attributes.aggregate_state === "operational", }); } diff --git a/apps/webapp/app/services/betterstack/betterstack.server.ts b/apps/webapp/app/services/betterstack/betterstack.server.ts index 7de093420d..41a43b3a69 100644 --- a/apps/webapp/app/services/betterstack/betterstack.server.ts +++ b/apps/webapp/app/services/betterstack/betterstack.server.ts @@ -1,4 +1,6 @@ -import { wrapZodFetch } from "@trigger.dev/core/v3/zodfetch"; +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"; @@ -14,6 +16,17 @@ const IncidentSchema = z.object({ 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"; @@ -28,27 +41,48 @@ export class BetterStackClient { return { success: false as const, error: "BETTERSTACK_STATUS_PAGE_ID is not set" }; } - try { - return await wrapZodFetch( - IncidentSchema, - `${this.baseUrl}/status-pages/${statusPageId}`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - }, - { - retry: { - maxAttempts: 3, - minTimeoutInMs: 1000, - maxTimeoutInMs: 5000, + 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", + }, }, - } - ); - } catch (error) { - console.error("Failed to fetch incidents from BetterStack:", error); - return { success: false as const, error }; + { + 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 }; } } From 36fd278a4d9407bb08ab16acedd884f4a0c021d1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 9 May 2025 10:58:21 +0100 Subject: [PATCH 4/4] Style the button to fit the panel colors --- apps/webapp/app/routes/resources.incidents.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx index c57caa78e3..319242c8d8 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -51,6 +51,7 @@ export function IncidentStatusPanel() { @@ -62,15 +63,17 @@ export function IncidentStatusPanel() {
- Our team is working on resolving the issue. Monitor our status page for updates. + Our team is working on resolving the issue. Check our status page for more + information. - View status page + View status page