diff --git a/messages/en-US.json b/messages/en-US.json index 299e64044..6da7ccb94 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1358,6 +1358,7 @@ "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarAlerting": "Alerting", + "sidebarHealthChecks": "Health checks", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 9035dc7e5..427ac7c44 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -33,7 +33,7 @@ export default function EditAlertRulePage() { useEffect(() => { if (isNaN(alertRuleId)) { - router.replace(`/${orgId}/settings/alerting`); + router.replace(`/${orgId}/settings/alerting/rules`); return; } @@ -56,7 +56,7 @@ export default function EditAlertRulePage() { useEffect(() => { if (formValues === null) { - router.replace(`/${orgId}/settings/alerting`); + router.replace(`/${orgId}/settings/alerting/rules`); } }, [formValues, orgId, router]); diff --git a/src/app/[orgId]/settings/alerting/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/health-checks/page.tsx new file mode 100644 index 000000000..8bb19fc8c --- /dev/null +++ b/src/app/[orgId]/settings/alerting/health-checks/page.tsx @@ -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>; +}; + +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>( + `/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 ( +
+ + } + description={t("alertingHealthChecksBannerDescription")} + /> + +
+ ); +} diff --git a/src/app/[orgId]/settings/alerting/layout.tsx b/src/app/[orgId]/settings/alerting/layout.tsx new file mode 100644 index 000000000..f235eb2e9 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/layout.tsx @@ -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 ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index 945128ca0..1768fbced 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,58 +1,15 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import AlertingRulesTable from "@app/components/AlertingRulesTable"; -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"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -type AlertingPageProps = { +export const metadata: Metadata = { + title: "Alerting" +}; + +type AlertingIndexPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - -export default async function AlertingPage(props: AlertingPageProps) { +export default async function AlertingIndexPage(props: AlertingIndexPageProps) { const params = await props.params; - const t = await getTranslations(); - - const tabs: TabItem[] = [ - { title: t("alertingTabRules"), href: "" }, - { title: t("alertingTabHealthChecks"), href: "" } - ]; - - return ( - <> - - -
- - } - description={t("alertingRulesBannerDescription")} - /> - -
-
- - } - description={t("alertingHealthChecksBannerDescription")} - /> - -
-
- - ); + redirect(`/${params.orgId}/settings/alerting/rules`); } diff --git a/src/app/[orgId]/settings/alerting/rules/page.tsx b/src/app/[orgId]/settings/alerting/rules/page.tsx new file mode 100644 index 000000000..0bdcd817c --- /dev/null +++ b/src/app/[orgId]/settings/alerting/rules/page.tsx @@ -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>; +}; + +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>( + `/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 ( +
+ + } + description={t("alertingRulesBannerDescription")} + /> + +
+ ); +} diff --git a/src/app/[orgId]/settings/health-checks/page.tsx b/src/app/[orgId]/settings/health-checks/page.tsx new file mode 100644 index 000000000..e35880f56 --- /dev/null +++ b/src/app/[orgId]/settings/health-checks/page.tsx @@ -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`); +} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 9ee70b205..52ff3b609 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -16,7 +16,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { alertRuleAllHealthChecksSelected, @@ -34,10 +33,9 @@ import moment from "moment"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useTransition } from "react"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useDebouncedCallback } from "use-debounce"; @@ -47,13 +45,7 @@ const alertRulesEnabledQuerySchema = z .optional() .catch(undefined); -type AlertingRulesTableProps = { - orgId: string; - siteId?: number; - resourceId?: number; -}; - -type AlertRuleRow = { +export type AlertRuleRow = { alertRuleId: number; orgId: string; name: string; @@ -68,6 +60,12 @@ type AlertRuleRow = { resourceIds: number[]; }; +type AlertingRulesTableProps = { + orgId: string; + alertRules: AlertRuleRow[]; + rowCount: number; +}; + function ruleHref(orgId: string, ruleId: number) { return `/${orgId}/settings/alerting/${ruleId}`; } @@ -129,13 +127,13 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { export default function AlertingRulesTable({ orgId, - siteId, - resourceId + alertRules, + rowCount }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); - const queryClient = useQueryClient(); + const [isRefreshing, startRefresh] = useTransition(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); @@ -167,24 +165,16 @@ export default function AlertingRulesTable({ [t] ); - const { data, isLoading, refetch, isRefetching } = useQuery( - orgQueries.alertRules({ - orgId, - limit: pageSize, - offset: pageIndex * pageSize, - query, - siteId, - resourceId, - sortBy, - order, - enabled: enabledForQuery - }) - ); - - const rows = data?.alertRules ?? []; - const total = data?.pagination.total ?? 0; + const rows = alertRules; + const total = rowCount; const pageCount = Math.max(1, Math.ceil(total / pageSize)); + function refreshList() { + startRefresh(() => { + router.refresh(); + }); + } + const paginationState: DataTablePaginationState = { pageIndex, pageSize, @@ -223,18 +213,13 @@ export default function AlertingRulesTable({ filter({ searchParams: sp }); } - const invalidate = () => - queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "ALERT_RULES"] - }); - const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { setTogglingId(rule.alertRuleId); try { await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { enabled }); - await invalidate(); + refreshList(); } catch (e) { toast({ title: t("error"), @@ -252,7 +237,7 @@ export default function AlertingRulesTable({ await api.delete( `/org/${orgId}/alert-rule/${selected.alertRuleId}` ); - await invalidate(); + refreshList(); toast({ title: t("alertingRuleDeleted") }); } catch (e) { toast({ @@ -442,8 +427,8 @@ export default function AlertingRulesTable({ onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} - onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading || isFiltering} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 9e5d9f2c6..63b48c53a 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -6,7 +6,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { HealthCheckRow } from "@app/components/HealthCheckCredenza"; -import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -19,22 +18,23 @@ import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; 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 { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useState, useTransition, useEffect } from "react"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type StandaloneHealthChecksTableProps = { orgId: string; + healthChecks: HealthCheckRow[]; + rowCount: number; }; function formatTarget(row: HealthCheckRow): string { @@ -57,21 +57,15 @@ const healthLabel: Record = { unknown: "Unknown" }; -const healthVariant: Record< - HealthCheckRow["hcHealth"], - "green" | "red" | "secondary" -> = { - healthy: "green", - unhealthy: "red", - unknown: "secondary" -}; - export default function HealthChecksTable({ - orgId + orgId, + healthChecks, + rowCount }: StandaloneHealthChecksTableProps) { + const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); - const queryClient = useQueryClient(); + const [isRefreshing, startRefresh] = useTransition(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); @@ -91,25 +85,23 @@ export default function HealthChecksTable({ const pageIndex = page - 1; const query = searchParams.get("query") ?? undefined; - const { - data, - 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 rows = healthChecks; + const total = rowCount; 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 = { pageIndex, pageSize, @@ -132,11 +124,6 @@ export default function HealthChecksTable({ filter({ searchParams }); }, 300); - const invalidate = () => - queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] - }); - const handleToggleEnabled = async ( row: HealthCheckRow, enabled: boolean @@ -147,7 +134,7 @@ export default function HealthChecksTable({ `/org/${orgId}/health-check/${row.targetHealthCheckId}`, { hcEnabled: enabled } ); - await invalidate(); + refreshList(); } catch (e) { toast({ title: t("error"), @@ -165,7 +152,7 @@ export default function HealthChecksTable({ await api.delete( `/org/${orgId}/health-check/${selected.targetHealthCheckId}` ); - await invalidate(); + refreshList(); toast({ title: t("standaloneHcDeleted") }); } catch (e) { toast({ @@ -400,7 +387,7 @@ export default function HealthChecksTable({ }} orgId={orgId} initialValues={selected} - onSaved={invalidate} + onSaved={refreshList} /> @@ -418,8 +405,8 @@ export default function HealthChecksTable({ setCredenzaOpen(true); }} addButtonDisabled={!isPaid} - onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading || isFiltering} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} addButtonText={t("standaloneHcAddButton")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 717a3c120..bffaadeba 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -11,6 +11,8 @@ import { useTranslations } from "next-intl"; export type TabItem = { title: string; href: string; + /** When set, active tab detection uses this path instead of `href` (link target unchanged). */ + activePrefix?: string; icon?: React.ReactNode; showProfessional?: boolean; exact?: boolean; @@ -115,18 +117,33 @@ export function HorizontalTabs({ } // 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 (
- {items.map((item) => { + {items.map((item, index) => { const hydratedHref = hydrateHref(item.href); - const isActive = - (item.exact - ? pathname === hydratedHref - : pathname.startsWith(hydratedHref)) && - !pathname.includes("create"); + const isActive = activeIndex === index; const isProfessional = item.showProfessional && !isUnlocked(); @@ -135,7 +152,7 @@ export function HorizontalTabs({ return ( - + View Alerts