Skip to content

Commit ccc9764

Browse files
authored
Active incident status panel in the side menu (#2033)
* WIP adding a side menu panel to display incident statuses * Fixes re-rendering bug and copy tweak * cache the betterstack response using unkey * Style the button to fit the panel colors
1 parent 584722d commit ccc9764

File tree

4 files changed

+191
-12
lines changed

4 files changed

+191
-12
lines changed

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Squares2X2Icon,
1919
UsersIcon,
2020
} from "@heroicons/react/20/solid";
21-
import { useLocation, useNavigation } from "@remix-run/react";
21+
import { useNavigation } from "@remix-run/react";
2222
import { useEffect, useRef, useState, type ReactNode } from "react";
2323
import simplur from "simplur";
2424
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
@@ -33,6 +33,7 @@ import { useHasAdminAccess } from "~/hooks/useUser";
3333
import { type User } from "~/models/user.server";
3434
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
3535
import { type FeedbackType } from "~/routes/resources.feedback";
36+
import { IncidentStatusPanel } from "~/routes/resources.incidents";
3637
import { cn } from "~/utils/cn";
3738
import {
3839
accountPath,
@@ -297,16 +298,19 @@ export function SideMenu({
297298
</SideMenuSection>
298299
</div>
299300
</div>
300-
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
301-
<div className="flex w-full items-center justify-between">
302-
<HelpAndAI />
301+
<div>
302+
<IncidentStatusPanel />
303+
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
304+
<div className="flex w-full items-center justify-between">
305+
<HelpAndAI />
306+
</div>
307+
{isFreeUser && (
308+
<FreePlanUsage
309+
to={v3BillingPath(organization)}
310+
percentage={currentPlan.v3Usage.usagePercentage}
311+
/>
312+
)}
303313
</div>
304-
{isFreeUser && (
305-
<FreePlanUsage
306-
to={v3BillingPath(organization)}
307-
percentage={currentPlan.v3Usage.usagePercentage}
308-
/>
309-
)}
310314
</div>
311315
</div>
312316
);

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
21
import { z } from "zod";
3-
import { isValidRegex } from "./utils/regex";
2+
import { SecretStoreOptionsSchema } from "./services/secrets/secretStoreOptionsSchema.server";
43
import { isValidDatabaseUrl } from "./utils/db";
4+
import { isValidRegex } from "./utils/regex";
55

66
const EnvironmentSchema = z.object({
77
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -721,6 +721,10 @@ const EnvironmentSchema = z.object({
721721

722722
// kapa.ai
723723
KAPA_AI_WEBSITE_ID: z.string().optional(),
724+
725+
// BetterStack
726+
BETTERSTACK_API_KEY: z.string().optional(),
727+
BETTERSTACK_STATUS_PAGE_ID: z.string().optional(),
724728
});
725729

726730
export type Environment = z.infer<typeof EnvironmentSchema>;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
2+
import { json } from "@remix-run/node";
3+
import { useFetcher } from "@remix-run/react";
4+
import { useCallback, useEffect } from "react";
5+
import { motion } from "framer-motion";
6+
import { LinkButton } from "~/components/primitives/Buttons";
7+
import { Paragraph } from "~/components/primitives/Paragraph";
8+
import { useFeatures } from "~/hooks/useFeatures";
9+
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
10+
11+
export async function loader() {
12+
const client = new BetterStackClient();
13+
const result = await client.getIncidents();
14+
15+
if (!result.success) {
16+
return json({ operational: true });
17+
}
18+
19+
return json({
20+
operational: result.data.attributes.aggregate_state === "operational",
21+
});
22+
}
23+
24+
export function IncidentStatusPanel() {
25+
const { isManagedCloud } = useFeatures();
26+
if (!isManagedCloud) {
27+
return null;
28+
}
29+
30+
const fetcher = useFetcher<typeof loader>();
31+
32+
const fetchIncidents = useCallback(() => {
33+
if (fetcher.state === "idle") {
34+
fetcher.load("/resources/incidents");
35+
}
36+
}, [fetcher]);
37+
38+
useEffect(() => {
39+
fetchIncidents();
40+
41+
const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
42+
43+
return () => clearInterval(interval);
44+
}, []);
45+
46+
const operational = fetcher.data?.operational ?? true;
47+
48+
return (
49+
<>
50+
{!operational && (
51+
<motion.div
52+
initial={{ opacity: 0 }}
53+
animate={{ opacity: 1 }}
54+
exit={{ opacity: 0 }}
55+
transition={{ duration: 0.3 }}
56+
className="p-1"
57+
>
58+
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
59+
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
60+
<ExclamationTriangleIcon className="size-4" />
61+
<Paragraph variant="small/bright" className="text-warning">
62+
Active incident
63+
</Paragraph>
64+
</div>
65+
<Paragraph variant="extra-small/bright" className="text-warning/80">
66+
Our team is working on resolving the issue. Check our status page for more
67+
information.
68+
</Paragraph>
69+
<LinkButton
70+
variant="secondary/small"
71+
to="https://status.trigger.dev"
72+
target="_blank"
73+
fullWidth
74+
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
75+
>
76+
<span className="text-warning">View status page</span>
77+
</LinkButton>
78+
</div>
79+
</motion.div>
80+
)}
81+
</>
82+
);
83+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
2+
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
3+
import { MemoryStore } from "@unkey/cache/stores";
4+
import { z } from "zod";
5+
import { env } from "~/env.server";
6+
7+
const IncidentSchema = z.object({
8+
data: z.object({
9+
id: z.string(),
10+
type: z.string(),
11+
attributes: z.object({
12+
aggregate_state: z.string(),
13+
}),
14+
}),
15+
});
16+
17+
export type Incident = z.infer<typeof IncidentSchema>;
18+
19+
const ctx = new DefaultStatefulContext();
20+
const memory = new MemoryStore({ persistentMap: new Map() });
21+
22+
const cache = createCache({
23+
query: new Namespace<ApiResult<Incident>>(ctx, {
24+
stores: [memory],
25+
fresh: 15_000,
26+
stale: 30_000,
27+
}),
28+
});
29+
30+
export class BetterStackClient {
31+
private readonly baseUrl = "https://uptime.betterstack.com/api/v2";
32+
33+
async getIncidents() {
34+
const apiKey = env.BETTERSTACK_API_KEY;
35+
if (!apiKey) {
36+
return { success: false as const, error: "BETTERSTACK_API_KEY is not set" };
37+
}
38+
39+
const statusPageId = env.BETTERSTACK_STATUS_PAGE_ID;
40+
if (!statusPageId) {
41+
return { success: false as const, error: "BETTERSTACK_STATUS_PAGE_ID is not set" };
42+
}
43+
44+
const cachedResult = await cache.query.swr("betterstack", async () => {
45+
try {
46+
const result = await wrapZodFetch(
47+
IncidentSchema,
48+
`${this.baseUrl}/status-pages/${statusPageId}`,
49+
{
50+
headers: {
51+
Authorization: `Bearer ${apiKey}`,
52+
"Content-Type": "application/json",
53+
},
54+
},
55+
{
56+
retry: {
57+
maxAttempts: 3,
58+
minTimeoutInMs: 1000,
59+
maxTimeoutInMs: 5000,
60+
},
61+
}
62+
);
63+
64+
return result;
65+
} catch (error) {
66+
console.error("Failed to fetch incidents from BetterStack:", error);
67+
return {
68+
success: false as const,
69+
error: error instanceof Error ? error.message : "Unknown error",
70+
};
71+
}
72+
});
73+
74+
if (cachedResult.err) {
75+
return { success: false as const, error: cachedResult.err };
76+
}
77+
78+
if (!cachedResult.val) {
79+
return { success: false as const, error: "No result from BetterStack" };
80+
}
81+
82+
if (!cachedResult.val.success) {
83+
return { success: false as const, error: cachedResult.val.error };
84+
}
85+
86+
return { success: true as const, data: cachedResult.val.data.data };
87+
}
88+
}

0 commit comments

Comments
 (0)