Feat(dashboard): show the Betterstack incident title in the dashboard (#3006)

When the incident panel is displayed, show the title added to
BetterStack as the contents of the incident panel.

I've also brightened the UI so it's more visible.

<img width="536" height="590" alt="CleanShot 2026-02-04 at 20 46 36@2x"
src="https://github.com/user-attachments/assets/040a04f8-5b52-40e8-8892-51c8efd6c08c"
/>

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3006"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
This commit is contained in:
James Ritchie
2026-02-12 08:58:21 +00:00
committed by GitHub
parent 6e3ac8bd91
commit d7bc37fdc0
3 changed files with 195 additions and 106 deletions
@@ -59,7 +59,7 @@ export function HelpAndFeedback({
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750 focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
+62 -59
View File
@@ -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<IncidentLoaderData>({ status: "operational", title: null });
}
return json({
operational: result.data.attributes.aggregate_state === "operational",
return json<IncidentLoaderData>({
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<typeof loader>();
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]);
const operational = fetcher.data?.operational ?? true;
return {
status: fetcher.data?.status ?? "operational",
title: fetcher.data?.title ?? null,
hasIncident: (fetcher.data?.status ?? "operational") !== "operational",
isManagedCloud,
};
}
if (!isManagedCloud || operational) {
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { title, hasIncident, isManagedCloud } = useIncidentStatus();
if (!isManagedCloud || !hasIncident) {
return null;
}
const message = title || DEFAULT_MESSAGE;
return (
<Popover>
<div className="p-1">
{/* Expanded panel - animated height and opacity */}
<motion.div
initial={false}
animate={{
@@ -62,35 +91,9 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
{/* Header */}
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>
{/* Description */}
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more
information.
</Paragraph>
{/* Button */}
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
>
<span className="text-warning">View status page</span>
</LinkButton>
</div>
<IncidentPanelContent message={message} />
</motion.div>
{/* Collapsed button - animated height and opacity */}
<motion.div
initial={false}
animate={{
@@ -102,8 +105,8 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
>
<SimpleTooltip
button={
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
<ExclamationTriangleIcon className="size-5 text-warning" />
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-yellow-500/30 bg-yellow-500/15 transition-colors hover:border-yellow-500/50 hover:bg-yellow-500/25">
<ExclamationTriangleIcon className="size-5 text-yellow-400" />
</PopoverTrigger>
}
content="Active incident"
@@ -115,32 +118,32 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
<IncidentPopoverContent />
<IncidentPanelContent message={message} />
</PopoverContent>
</Popover>
);
}
function IncidentPopoverContent() {
function IncidentPanelContent({ message }: { message: string }) {
return (
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
<div className="flex flex-col gap-2 rounded border border-yellow-500/30 bg-yellow-500/10 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-yellow-500/30 pb-1">
<ExclamationTriangleIcon className="size-4 text-yellow-400" />
<Paragraph variant="small/bright" className="text-yellow-300">
Active incident
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more information.
<Paragraph variant="extra-small/bright" className="text-yellow-300">
{message}
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
className="border-yellow-500/30 bg-yellow-500/15 hover:!border-yellow-500/50 hover:!bg-yellow-500/25"
>
<span className="text-warning">View status page</span>
<span className="text-yellow-300">View status page</span>
</LinkButton>
</div>
);
@@ -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<typeof IncidentSchema>;
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<ApiResult<Incident>>(ctx, {
query: new Namespace<CachedResult>(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<CachedResult> {
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" };
if (!apiKey || !statusPageId) {
return { success: false, error: "Missing BetterStack configuration" };
}
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,
},
}
);
const cachedResult = await cache.query.swr("betterstack-incident-status", () =>
this.fetchIncidentStatus(apiKey, statusPageId)
);
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 || !cachedResult.val) {
return { success: false, error: cachedResult.err ?? "No result from cache" };
}
return cachedResult.val;
}
private async fetchIncidentStatus(
apiKey: string,
statusPageId: string
): Promise<CachedResult> {
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 };
}
});
if (cachedResult.err) {
return { success: false as const, error: cachedResult.err };
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 } };
}
// 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<string, string>,
retryConfig: { retry: { maxAttempts: number; minTimeoutInMs: number; maxTimeoutInMs: number } }
): Promise<string | null> {
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;
}
}