From dc58c0fc74d6c852be7f50d7830d53badc7c1bef Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 4 Feb 2026 20:27:28 -0800 Subject: [PATCH 1/2] Display the incident message and brighten the panel --- .../webapp/app/routes/resources.incidents.tsx | 121 ++++++------ .../betterstack/betterstack.server.ts | 178 +++++++++++++----- 2 files changed, 194 insertions(+), 105 deletions(-) diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx index 532038d4f9..445c3ef912 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -1,58 +1,87 @@ import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { json } from "@remix-run/node"; -import { useFetcher } from "@remix-run/react"; +import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; import { motion } from "framer-motion"; -import { useCallback, useEffect } from "react"; +import { useEffect, useRef } from "react"; import { LinkButton } from "~/components/primitives/Buttons"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useFeatures } from "~/hooks/useFeatures"; -import { BetterStackClient } from "~/services/betterstack/betterstack.server"; +import { BetterStackClient, type AggregateState } from "~/services/betterstack/betterstack.server"; + +// Prevent Remix from revalidating this route when other fetchers submit +export const shouldRevalidate: ShouldRevalidateFunction = () => false; + +export type IncidentLoaderData = { + status: AggregateState; + title: string | null; +}; export async function loader() { const client = new BetterStackClient(); - const result = await client.getIncidents(); + const result = await client.getIncidentStatus(); if (!result.success) { - return json({ operational: true }); + return json({ status: "operational", title: null }); } - return json({ - operational: result.data.attributes.aggregate_state === "operational", + return json({ + status: result.data.status, + title: result.data.title, }); } -export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) { +const DEFAULT_MESSAGE = + "Our team is working on resolving the issue. Check our status page for more information."; + +const POLL_INTERVAL_MS = 60_000; + +/** Hook to fetch and poll incident status */ +export function useIncidentStatus() { const { isManagedCloud } = useFeatures(); const fetcher = useFetcher(); - - const fetchIncidents = useCallback(() => { - if (fetcher.state === "idle") { - fetcher.load("/resources/incidents"); - } - }, []); + const hasInitiallyFetched = useRef(false); useEffect(() => { if (!isManagedCloud) return; - fetchIncidents(); + // Initial fetch on mount + if (!hasInitiallyFetched.current && fetcher.state === "idle") { + hasInitiallyFetched.current = true; + fetcher.load("/resources/incidents"); + } - const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute + // Poll every 60 seconds + const interval = setInterval(() => { + if (fetcher.state === "idle") { + fetcher.load("/resources/incidents"); + } + }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [isManagedCloud, fetchIncidents]); + }, [isManagedCloud]); + + return { + status: fetcher.data?.status ?? "operational", + title: fetcher.data?.title ?? null, + hasIncident: (fetcher.data?.status ?? "operational") !== "operational", + isManagedCloud, + }; +} - const operational = fetcher.data?.operational ?? true; +export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) { + const { title, hasIncident, isManagedCloud } = useIncidentStatus(); - if (!isManagedCloud || operational) { + if (!isManagedCloud || !hasIncident) { return null; } + const message = title || DEFAULT_MESSAGE; + return (
- {/* Expanded panel - animated height and opacity */} -
- {/* Header */} -
- - - Active incident - -
- - {/* Description */} - - Our team is working on resolving the issue. Check our status page for more - information. - - - {/* Button */} - - View status page - -
+
- {/* Collapsed button - animated height and opacity */} - + + } content="Active incident" @@ -115,32 +118,32 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
- +
); } -function IncidentPopoverContent() { +function IncidentPanelContent({ message }: { message: string }) { return ( -
-
- - +
+
+ + Active incident
- - Our team is working on resolving the issue. Check our status page for more information. + + {message} - View status page + View status page
); diff --git a/apps/webapp/app/services/betterstack/betterstack.server.ts b/apps/webapp/app/services/betterstack/betterstack.server.ts index 75b404745a..95fe220883 100644 --- a/apps/webapp/app/services/betterstack/betterstack.server.ts +++ b/apps/webapp/app/services/betterstack/betterstack.server.ts @@ -1,26 +1,56 @@ -import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch"; +import { wrapZodFetch } from "@trigger.dev/core/v3/zodfetch"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { createLRUMemoryStore } from "@internal/cache"; import { z } from "zod"; import { env } from "~/env.server"; -const IncidentSchema = z.object({ +const StatusPageSchema = z.object({ data: z.object({ id: z.string(), type: z.string(), attributes: z.object({ - aggregate_state: z.string(), + aggregate_state: z.enum(["operational", "degraded", "downtime"]), }), }), }); -export type Incident = z.infer; +const StatusReportsSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + type: z.literal("status_report"), + attributes: z.object({ + title: z.string().nullable(), + starts_at: z.string().nullable(), + ends_at: z.string().nullable(), + aggregate_state: z.string().nullable(), + }), + }) + ), + pagination: z.object({ + first: z.string().nullable(), + last: z.string().nullable(), + prev: z.string().nullable(), + next: z.string().nullable(), + }), +}); + +export type AggregateState = "operational" | "degraded" | "downtime"; + +export type IncidentStatus = { + status: AggregateState; + title: string | null; +}; + +type CachedResult = + | { success: true; data: IncidentStatus } + | { success: false; error: unknown }; const ctx = new DefaultStatefulContext(); const memory = createLRUMemoryStore(100); const cache = createCache({ - query: new Namespace>(ctx, { + query: new Namespace(ctx, { stores: [memory], fresh: 15_000, stale: 30_000, @@ -30,59 +60,115 @@ const cache = createCache({ export class BetterStackClient { private readonly baseUrl = "https://uptime.betterstack.com/api/v2"; - async getIncidents() { + async getIncidentStatus(): Promise { 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 (!apiKey || !statusPageId) { + return { success: false, error: "Missing BetterStack configuration" }; } - 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-incident-status", () => + this.fetchIncidentStatus(apiKey, statusPageId) + ); + + if (cachedResult.err || !cachedResult.val) { + return { success: false, error: cachedResult.err ?? "No result from cache" }; } - 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", - }; + return cachedResult.val; + } + + private async fetchIncidentStatus( + apiKey: string, + statusPageId: string + ): Promise { + const headers = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + const retryConfig = { + retry: { maxAttempts: 3, minTimeoutInMs: 1000, maxTimeoutInMs: 5000 }, + }; + + try { + // Fetch the status page to get aggregate state + const statusPageResult = await wrapZodFetch( + StatusPageSchema, + `${this.baseUrl}/status-pages/${statusPageId}`, + { headers }, + retryConfig + ); + + if (!statusPageResult.success) { + return { success: false, error: statusPageResult.error }; + } + + const status = statusPageResult.data.data.attributes.aggregate_state; + + // If operational, no need to fetch reports + if (status === "operational") { + return { success: true, data: { status, title: null } }; } - }); - if (cachedResult.err) { - return { success: false as const, error: cachedResult.err }; + // Fetch status reports to get the incident title + const title = await this.fetchActiveReportTitle(apiKey, statusPageId, headers, retryConfig); + + return { success: true, data: { status, title } }; + } catch (error) { + console.error("Failed to fetch incident status from BetterStack:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + private async fetchActiveReportTitle( + apiKey: string, + statusPageId: string, + headers: Record, + retryConfig: { retry: { maxAttempts: number; minTimeoutInMs: number; maxTimeoutInMs: number } } + ): Promise { + const reportsUrl = `${this.baseUrl}/status-pages/${statusPageId}/status-reports`; + + let reportsResult = await wrapZodFetch( + StatusReportsSchema, + reportsUrl, + { headers }, + retryConfig + ); + + if (!reportsResult.success) { + return null; } - if (!cachedResult.val) { - return { success: false as const, error: "No result from BetterStack" }; + // Fetch last page if there are multiple pages (most recent reports are at the end) + const { first, last } = reportsResult.data.pagination; + if (last && last !== first) { + const lastPageResult = await wrapZodFetch( + StatusReportsSchema, + last, + { headers }, + retryConfig + ); + if (lastPageResult.success) { + reportsResult = lastPageResult; + } } - if (!cachedResult.val.success) { - return { success: false as const, error: cachedResult.val.error }; + // Find active reports (not resolved, not ended) + const activeReports = reportsResult.data.data.filter( + (report) => + report.attributes.aggregate_state !== "resolved" && report.attributes.ends_at === null + ); + + if (activeReports.length === 0) { + return null; } - return { success: true as const, data: cachedResult.val.data.data }; + // Return the title from the most recent active report + const mostRecent = activeReports[activeReports.length - 1]; + return mostRecent.attributes.title; } } From bcb26541f40f1a92de0155a5c0a850e812f73464 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 4 Feb 2026 20:37:42 -0800 Subject: [PATCH 2/2] Adds missing custom focus state --- .../webapp/app/components/navigation/HelpAndFeedbackPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 74077eed72..1626ec9f91 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -59,7 +59,7 @@ export function HelpAndFeedback({ button={