make alerts and health checks table server side

This commit is contained in:
miloschwartz
2026-04-21 16:49:45 -07:00
parent b22ac17178
commit db2942447a
11 changed files with 333 additions and 142 deletions

View File

@@ -1358,6 +1358,7 @@
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarAlerting": "Alerting", "sidebarAlerting": "Alerting",
"sidebarHealthChecks": "Health checks",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarManagement": "Management", "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses", "sidebarBillingAndLicenses": "Billing & Licenses",

View File

@@ -33,7 +33,7 @@ export default function EditAlertRulePage() {
useEffect(() => { useEffect(() => {
if (isNaN(alertRuleId)) { if (isNaN(alertRuleId)) {
router.replace(`/${orgId}/settings/alerting`); router.replace(`/${orgId}/settings/alerting/rules`);
return; return;
} }
@@ -56,7 +56,7 @@ export default function EditAlertRulePage() {
useEffect(() => { useEffect(() => {
if (formValues === null) { if (formValues === null) {
router.replace(`/${orgId}/settings/alerting`); router.replace(`/${orgId}/settings/alerting/rules`);
} }
}, [formValues, orgId, router]); }, [formValues, orgId, router]);

View File

@@ -0,0 +1,86 @@
import HealthChecksTable from "@app/components/HealthChecksTable";
import DismissableBanner from "@app/components/DismissableBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { AxiosResponse } from "axios";
import { HeartPulse } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Health checks"
};
type AlertingHealthChecksPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function AlertingHealthChecksPage(
props: AlertingHealthChecksPageProps
) {
const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1);
const pageSize = Math.max(
1,
parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20
);
const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined;
const apiSp = new URLSearchParams();
apiSp.set("limit", String(pageSize));
apiSp.set("offset", String(pageIndex * pageSize));
if (query) apiSp.set("query", query);
let healthChecks: ListHealthChecksResponse["healthChecks"] = [];
let pagination: ListHealthChecksResponse["pagination"] = {
total: 0,
limit: pageSize,
offset: pageIndex * pageSize
};
try {
const res = await internal.get<AxiosResponse<ListHealthChecksResponse>>(
`/org/${params.orgId}/health-checks?${apiSp.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
healthChecks = responseData.healthChecks;
pagination = responseData.pagination;
} catch {
// leave defaults
}
const t = await getTranslations();
return (
<div className="space-y-6">
<DismissableBanner
storageKey="alerting-health-checks-banner-dismissed"
version={1}
title={t("alertingHealthChecksBannerTitle")}
titleIcon={
<HeartPulse className="w-5 h-5 text-primary shrink-0" />
}
description={t("alertingHealthChecksBannerDescription")}
/>
<HealthChecksTable
orgId={params.orgId}
healthChecks={healthChecks}
rowCount={pagination.total}
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
type AlertingLayoutProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function AlertingLayout({
children,
params
}: AlertingLayoutProps) {
const { orgId } = await params;
const t = await getTranslations();
const navItems = [
{
title: t("alertingTabRules"),
href: `/${orgId}/settings/alerting/rules`,
activePrefix: `/${orgId}/settings/alerting`
},
{
title: t("alertingTabHealthChecks"),
href: `/${orgId}/settings/alerting/health-checks`
}
];
return (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}

View File

@@ -1,58 +1,15 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import type { Metadata } from "next";
import AlertingRulesTable from "@app/components/AlertingRulesTable"; import { redirect } from "next/navigation";
import HealthChecksTable from "@app/components/HealthChecksTable";
import DismissableBanner from "@app/components/DismissableBanner";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import { BellRing, HeartPulse } from "lucide-react";
import { getTranslations } from "next-intl/server";
type AlertingPageProps = { export const metadata: Metadata = {
title: "Alerting"
};
type AlertingIndexPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic"; export default async function AlertingIndexPage(props: AlertingIndexPageProps) {
export default async function AlertingPage(props: AlertingPageProps) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); redirect(`/${params.orgId}/settings/alerting/rules`);
const tabs: TabItem[] = [
{ title: t("alertingTabRules"), href: "" },
{ title: t("alertingTabHealthChecks"), href: "" }
];
return (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<HorizontalTabs items={tabs} clientSide>
<div className="space-y-6">
<DismissableBanner
storageKey="alerting-rules-banner-dismissed"
version={1}
title={t("alertingRulesBannerTitle")}
titleIcon={
<BellRing className="w-5 h-5 text-primary shrink-0" />
}
description={t("alertingRulesBannerDescription")}
/>
<AlertingRulesTable orgId={params.orgId} />
</div>
<div className="space-y-6">
<DismissableBanner
storageKey="alerting-health-checks-banner-dismissed"
version={1}
title={t("alertingHealthChecksBannerTitle")}
titleIcon={
<HeartPulse className="w-5 h-5 text-primary shrink-0" />
}
description={t("alertingHealthChecksBannerDescription")}
/>
<HealthChecksTable orgId={params.orgId} />
</div>
</HorizontalTabs>
</>
);
} }

View File

@@ -0,0 +1,100 @@
import AlertingRulesTable from "@app/components/AlertingRulesTable";
import DismissableBanner from "@app/components/DismissableBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { ListAlertRulesResponse } from "@server/private/routers/alertRule";
import { AxiosResponse } from "axios";
import { BellRing } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Alerting"
};
type AlertingRulesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function AlertingRulesPage(props: AlertingRulesPageProps) {
const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1);
const pageSize = Math.max(
1,
parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20
);
const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined;
const sortBy = searchParams.get("sort_by") ?? undefined;
const order = searchParams.get("order") ?? undefined;
const enabled = searchParams.get("enabled");
const enabledParam =
enabled === "true" || enabled === "false" ? enabled : undefined;
const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined);
const resourceId = parsePositiveInt(
searchParams.get("resourceId") ?? undefined
);
const apiSp = new URLSearchParams();
apiSp.set("limit", String(pageSize));
apiSp.set("offset", String(pageIndex * pageSize));
if (query) apiSp.set("query", query);
if (siteId != null) apiSp.set("siteId", String(siteId));
if (resourceId != null) apiSp.set("resourceId", String(resourceId));
if (sortBy) {
apiSp.set("sort_by", sortBy);
if (order) apiSp.set("order", order);
}
if (enabledParam) apiSp.set("enabled", enabledParam);
let alertRules: ListAlertRulesResponse["alertRules"] = [];
let pagination: ListAlertRulesResponse["pagination"] = {
total: 0,
limit: pageSize,
offset: pageIndex * pageSize
};
try {
const res = await internal.get<AxiosResponse<ListAlertRulesResponse>>(
`/org/${params.orgId}/alert-rules?${apiSp.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
alertRules = responseData.alertRules;
pagination = responseData.pagination;
} catch {
// leave defaults
}
const t = await getTranslations();
return (
<div className="space-y-6">
<DismissableBanner
storageKey="alerting-rules-banner-dismissed"
version={1}
title={t("alertingRulesBannerTitle")}
titleIcon={
<BellRing className="w-5 h-5 text-primary shrink-0" />
}
description={t("alertingRulesBannerDescription")}
/>
<AlertingRulesTable
orgId={params.orgId}
alertRules={alertRules}
rowCount={pagination.total}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Health checks"
};
type LegacyHealthChecksPageProps = {
params: Promise<{ orgId: string }>;
};
/** @deprecated Use `/settings/alerting/health-checks` */
export default async function LegacyHealthChecksRedirect(
props: LegacyHealthChecksPageProps
) {
const params = await props.params;
redirect(`/${params.orgId}/settings/alerting/health-checks`);
}

View File

@@ -16,7 +16,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { import {
alertRuleAllHealthChecksSelected, alertRuleAllHealthChecksSelected,
@@ -34,10 +33,9 @@ import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react"; import { useMemo, useState, useTransition } from "react";
import z from "zod"; import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import type { DataTablePaginationState } from "@app/components/ui/data-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
@@ -47,13 +45,7 @@ const alertRulesEnabledQuerySchema = z
.optional() .optional()
.catch(undefined); .catch(undefined);
type AlertingRulesTableProps = { export type AlertRuleRow = {
orgId: string;
siteId?: number;
resourceId?: number;
};
type AlertRuleRow = {
alertRuleId: number; alertRuleId: number;
orgId: string; orgId: string;
name: string; name: string;
@@ -68,6 +60,12 @@ type AlertRuleRow = {
resourceIds: number[]; resourceIds: number[];
}; };
type AlertingRulesTableProps = {
orgId: string;
alertRules: AlertRuleRow[];
rowCount: number;
};
function ruleHref(orgId: string, ruleId: number) { function ruleHref(orgId: string, ruleId: number) {
return `/${orgId}/settings/alerting/${ruleId}`; return `/${orgId}/settings/alerting/${ruleId}`;
} }
@@ -129,13 +127,13 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) {
export default function AlertingRulesTable({ export default function AlertingRulesTable({
orgId, orgId,
siteId, alertRules,
resourceId rowCount
}: AlertingRulesTableProps) { }: AlertingRulesTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const queryClient = useQueryClient(); const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
@@ -167,24 +165,16 @@ export default function AlertingRulesTable({
[t] [t]
); );
const { data, isLoading, refetch, isRefetching } = useQuery( const rows = alertRules;
orgQueries.alertRules({ const total = rowCount;
orgId,
limit: pageSize,
offset: pageIndex * pageSize,
query,
siteId,
resourceId,
sortBy,
order,
enabled: enabledForQuery
})
);
const rows = data?.alertRules ?? [];
const total = data?.pagination.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.max(1, Math.ceil(total / pageSize));
function refreshList() {
startRefresh(() => {
router.refresh();
});
}
const paginationState: DataTablePaginationState = { const paginationState: DataTablePaginationState = {
pageIndex, pageIndex,
pageSize, pageSize,
@@ -223,18 +213,13 @@ export default function AlertingRulesTable({
filter({ searchParams: sp }); filter({ searchParams: sp });
} }
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "ALERT_RULES"]
});
const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => {
setTogglingId(rule.alertRuleId); setTogglingId(rule.alertRuleId);
try { try {
await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, {
enabled enabled
}); });
await invalidate(); refreshList();
} catch (e) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
@@ -252,7 +237,7 @@ export default function AlertingRulesTable({
await api.delete( await api.delete(
`/org/${orgId}/alert-rule/${selected.alertRuleId}` `/org/${orgId}/alert-rule/${selected.alertRuleId}`
); );
await invalidate(); refreshList();
toast({ title: t("alertingRuleDeleted") }); toast({ title: t("alertingRuleDeleted") });
} catch (e) { } catch (e) {
toast({ toast({
@@ -442,8 +427,8 @@ export default function AlertingRulesTable({
onAdd={() => { onAdd={() => {
router.push(`/${orgId}/settings/alerting/create`); router.push(`/${orgId}/settings/alerting/create`);
}} }}
onRefresh={() => refetch()} onRefresh={refreshList}
isRefreshing={isRefetching || isLoading || isFiltering} isRefreshing={isRefreshing || isFiltering}
addButtonText={t("alertingAddRule")} addButtonText={t("alertingAddRule")}
enableColumnVisibility enableColumnVisibility
stickyLeftColumn="name" stickyLeftColumn="name"

View File

@@ -6,7 +6,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import HealthCheckCredenza, { import HealthCheckCredenza, {
HealthCheckRow HealthCheckRow
} from "@app/components/HealthCheckCredenza"; } from "@app/components/HealthCheckCredenza";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
@@ -19,22 +18,23 @@ import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState, useTransition, useEffect } from "react";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import type { DataTablePaginationState } from "@app/components/ui/data-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
type StandaloneHealthChecksTableProps = { type StandaloneHealthChecksTableProps = {
orgId: string; orgId: string;
healthChecks: HealthCheckRow[];
rowCount: number;
}; };
function formatTarget(row: HealthCheckRow): string { function formatTarget(row: HealthCheckRow): string {
@@ -57,21 +57,15 @@ const healthLabel: Record<HealthCheckRow["hcHealth"], string> = {
unknown: "Unknown" unknown: "Unknown"
}; };
const healthVariant: Record<
HealthCheckRow["hcHealth"],
"green" | "red" | "secondary"
> = {
healthy: "green",
unhealthy: "red",
unknown: "secondary"
};
export default function HealthChecksTable({ export default function HealthChecksTable({
orgId orgId,
healthChecks,
rowCount
}: StandaloneHealthChecksTableProps) { }: StandaloneHealthChecksTableProps) {
const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const queryClient = useQueryClient(); const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks);
@@ -91,25 +85,23 @@ export default function HealthChecksTable({
const pageIndex = page - 1; const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined; const query = searchParams.get("query") ?? undefined;
const { const rows = healthChecks;
data, const total = rowCount;
isLoading,
refetch,
isRefetching
} = useQuery({
...orgQueries.standaloneHealthChecks({
orgId,
limit: pageSize,
offset: pageIndex * pageSize,
query
}),
refetchInterval: 10_000
});
const rows = data?.healthChecks ?? [];
const total = data?.pagination.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.max(1, Math.ceil(total / pageSize));
function refreshList() {
startRefresh(() => {
router.refresh();
});
}
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
return () => clearInterval(interval);
}, [router]);
const paginationState: DataTablePaginationState = { const paginationState: DataTablePaginationState = {
pageIndex, pageIndex,
pageSize, pageSize,
@@ -132,11 +124,6 @@ export default function HealthChecksTable({
filter({ searchParams }); filter({ searchParams });
}, 300); }, 300);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"]
});
const handleToggleEnabled = async ( const handleToggleEnabled = async (
row: HealthCheckRow, row: HealthCheckRow,
enabled: boolean enabled: boolean
@@ -147,7 +134,7 @@ export default function HealthChecksTable({
`/org/${orgId}/health-check/${row.targetHealthCheckId}`, `/org/${orgId}/health-check/${row.targetHealthCheckId}`,
{ hcEnabled: enabled } { hcEnabled: enabled }
); );
await invalidate(); refreshList();
} catch (e) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
@@ -165,7 +152,7 @@ export default function HealthChecksTable({
await api.delete( await api.delete(
`/org/${orgId}/health-check/${selected.targetHealthCheckId}` `/org/${orgId}/health-check/${selected.targetHealthCheckId}`
); );
await invalidate(); refreshList();
toast({ title: t("standaloneHcDeleted") }); toast({ title: t("standaloneHcDeleted") });
} catch (e) { } catch (e) {
toast({ toast({
@@ -400,7 +387,7 @@ export default function HealthChecksTable({
}} }}
orgId={orgId} orgId={orgId}
initialValues={selected} initialValues={selected}
onSaved={invalidate} onSaved={refreshList}
/> />
<PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} /> <PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} />
@@ -418,8 +405,8 @@ export default function HealthChecksTable({
setCredenzaOpen(true); setCredenzaOpen(true);
}} }}
addButtonDisabled={!isPaid} addButtonDisabled={!isPaid}
onRefresh={() => refetch()} onRefresh={refreshList}
isRefreshing={isRefetching || isLoading || isFiltering} isRefreshing={isRefreshing || isFiltering}
addButtonText={t("standaloneHcAddButton")} addButtonText={t("standaloneHcAddButton")}
enableColumnVisibility enableColumnVisibility
stickyLeftColumn="name" stickyLeftColumn="name"

View File

@@ -11,6 +11,8 @@ import { useTranslations } from "next-intl";
export type TabItem = { export type TabItem = {
title: string; title: string;
href: string; href: string;
/** When set, active tab detection uses this path instead of `href` (link target unchanged). */
activePrefix?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
showProfessional?: boolean; showProfessional?: boolean;
exact?: boolean; exact?: boolean;
@@ -115,18 +117,33 @@ export function HorizontalTabs({
} }
// Server-side mode: original behavior with routing // Server-side mode: original behavior with routing
const activeIndex: number | null = (() => {
if (pathname.includes("create")) return null;
let best: number | null = null;
let bestLen = -1;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const matchBase = hydrateHref(item.activePrefix ?? item.href);
const matched = item.exact
? pathname === matchBase
: pathname === matchBase ||
pathname.startsWith(`${matchBase}/`);
if (matched && matchBase.length > bestLen) {
bestLen = matchBase.length;
best = i;
}
}
return best;
})();
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">
<div className="overflow-x-auto scrollbar-hide"> <div className="overflow-x-auto scrollbar-hide">
<div className="flex space-x-4 border-b min-w-max"> <div className="flex space-x-4 border-b min-w-max">
{items.map((item) => { {items.map((item, index) => {
const hydratedHref = hydrateHref(item.href); const hydratedHref = hydrateHref(item.href);
const isActive = const isActive = activeIndex === index;
(item.exact
? pathname === hydratedHref
: pathname.startsWith(hydratedHref)) &&
!pathname.includes("create");
const isProfessional = const isProfessional =
item.showProfessional && !isUnlocked(); item.showProfessional && !isUnlocked();
@@ -135,7 +152,7 @@ export function HorizontalTabs({
return ( return (
<Link <Link
key={hydratedHref} key={`${hydratedHref}-${index}`}
href={isProfessional ? "#" : hydratedHref} href={isProfessional ? "#" : hydratedHref}
className={cn( className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative", "px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",

View File

@@ -158,7 +158,9 @@ export default function UptimeAlertSection({
const alertButton = alertRulesLoading ? null : hasRules ? ( const alertButton = alertRulesLoading ? null : hasRules ? (
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={`/${orgId}/settings/alerting?siteId=${siteId}&resourceId=${resourceId}`}> <Link
href={`/${orgId}/settings/alerting/rules?siteId=${siteId}&resourceId=${resourceId}`}
>
<BellRing className="size-4 mr-2" /> <BellRing className="size-4 mr-2" />
View Alerts View Alerts
</Link> </Link>