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:
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user