Skip to content

Active incident status panel in the side menu #2033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -279,16 +280,19 @@ export function SideMenu({
</SideMenuSection>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between">
<HelpAndAI />
<div>
<IncidentStatusPanel />
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between">
<HelpAndAI />
</div>
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
)}
</div>
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
)}
</div>
</div>
);
Expand Down
8 changes: 6 additions & 2 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
@@ -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")]),
Expand Down Expand Up @@ -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<typeof EnvironmentSchema>;
Expand Down
83 changes: 83 additions & 0 deletions apps/webapp/app/routes/resources.incidents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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";
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.attributes.aggregate_state === "operational",
});
}

export function IncidentStatusPanel() {
const { isManagedCloud } = useFeatures();
if (!isManagedCloud) {
return null;
}

const fetcher = useFetcher<typeof loader>();

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);
}, []);

const operational = fetcher.data?.operational ?? true;

return (
<>
{!operational && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="p-1"
>
<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">
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>
<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>
</motion.div>
)}
</>
);
}
88 changes: 88 additions & 0 deletions apps/webapp/app/services/betterstack/betterstack.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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";

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<typeof IncidentSchema>;

const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });

const cache = createCache({
query: new Namespace<ApiResult<Incident>>(ctx, {
stores: [memory],
fresh: 15_000,
stale: 30_000,
}),
});

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" };
}

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",
};
}
});

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 };
}
}