mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-05 15:26:35 +00:00
make alerts and health checks table server side
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
86
src/app/[orgId]/settings/alerting/health-checks/page.tsx
Normal file
86
src/app/[orgId]/settings/alerting/health-checks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/[orgId]/settings/alerting/layout.tsx
Normal file
38
src/app/[orgId]/settings/alerting/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/app/[orgId]/settings/alerting/rules/page.tsx
Normal file
100
src/app/[orgId]/settings/alerting/rules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/[orgId]/settings/health-checks/page.tsx
Normal file
18
src/app/[orgId]/settings/health-checks/page.tsx
Normal 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`);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user