mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-17 13:49:51 +00:00
Merge branch 'alerting-rules' into dev
This commit is contained in:
95
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
95
src/app/[orgId]/settings/alerting/[ruleId]/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { apiResponseToFormValues } from "@app/lib/alertRuleForm";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { GetAlertRuleResponse } from "@server/private/routers/alertRule";
|
||||
import type { AlertRuleFormValues } from "@app/lib/alertRuleForm";
|
||||
|
||||
export default function EditAlertRulePage() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const orgId = params.orgId as string;
|
||||
const ruleIdParam = params.ruleId as string;
|
||||
const alertRuleId = parseInt(ruleIdParam, 10);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||
|
||||
const [formValues, setFormValues] = useState<AlertRuleFormValues | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNaN(alertRuleId)) {
|
||||
router.replace(`/${orgId}/settings/alerting`);
|
||||
return;
|
||||
}
|
||||
|
||||
api.get<AxiosResponse<GetAlertRuleResponse>>(
|
||||
`/org/${orgId}/alert-rule/${alertRuleId}`
|
||||
)
|
||||
.then((res) => {
|
||||
const rule = res.data.data;
|
||||
setFormValues(apiResponseToFormValues(rule));
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
setFormValues(null);
|
||||
});
|
||||
}, [orgId, alertRuleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formValues === null) {
|
||||
router.replace(`/${orgId}/settings/alerting`);
|
||||
}
|
||||
}, [formValues, orgId, router]);
|
||||
|
||||
if (formValues === undefined) {
|
||||
return (
|
||||
<>
|
||||
<HeaderTitle
|
||||
title={t("alertingEditRule")}
|
||||
description={t("alertingRuleCredenzaDescription")}
|
||||
/>
|
||||
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (formValues === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderTitle
|
||||
title={t("alertingEditRule")}
|
||||
description={t("alertingRuleCredenzaDescription")}
|
||||
/>
|
||||
<AlertRuleGraphEditor
|
||||
key={alertRuleId}
|
||||
orgId={orgId}
|
||||
alertRuleId={alertRuleId}
|
||||
initialValues={formValues}
|
||||
isNew={false}
|
||||
disabled={!isPaid}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
32
src/app/[orgId]/settings/alerting/create/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { defaultFormValues } from "@app/lib/alertRuleForm";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function NewAlertRulePage() {
|
||||
const params = useParams();
|
||||
const orgId = params.orgId as string;
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderTitle
|
||||
title={t("alertingCreateRule")}
|
||||
description={t("alertingRuleCredenzaDescription")}
|
||||
/>
|
||||
<AlertRuleGraphEditor
|
||||
orgId={orgId}
|
||||
initialValues={defaultFormValues()}
|
||||
isNew
|
||||
disabled={!isPaid}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/app/[orgId]/settings/alerting/page.tsx
Normal file
34
src/app/[orgId]/settings/alerting/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import AlertingRulesTable from "@app/components/AlertingRulesTable";
|
||||
import HealthChecksTable from "@app/components/HealthChecksTable";
|
||||
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type AlertingPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AlertingPage(props: AlertingPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ title: t("alertingTabRules"), href: "" },
|
||||
{ title: t("alertingTabHealthChecks"), href: "" }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("alertingTitle")}
|
||||
description={t("alertingDescription")}
|
||||
/>
|
||||
<HorizontalTabs items={tabs} clientSide>
|
||||
<AlertingRulesTable orgId={params.orgId} />
|
||||
<HealthChecksTable orgId={params.orgId} />
|
||||
</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -471,11 +471,7 @@ export default function GeneralPage() {
|
||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes === null || bytes === undefined) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
@@ -33,7 +33,7 @@ function formatBytes(bytes: number | null): string {
|
||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||
if (endedAt === null || endedAt === undefined) return "Active";
|
||||
const durationSec = endedAt - startedAt;
|
||||
if (durationSec < 0) return "—";
|
||||
if (durationSec < 0) return "-";
|
||||
if (durationSec < 60) return `${durationSec}s`;
|
||||
if (durationSec < 3600) {
|
||||
const m = Math.floor(durationSec / 60);
|
||||
@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
@@ -464,7 +460,7 @@ export default function ConnectionLogsPage() {
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.resourceName ?? "—"}
|
||||
{row.original.resourceName ?? "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -497,11 +493,7 @@ export default function ConnectionLogsPage() {
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Laptop className="mr-1 h-3 w-3" />
|
||||
{row.original.clientName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
@@ -511,7 +503,7 @@ export default function ConnectionLogsPage() {
|
||||
}
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{row.original.clientName ?? "—"}
|
||||
{row.original.clientName ?? "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -546,7 +538,7 @@ export default function ConnectionLogsPage() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>—</span>;
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -620,23 +612,23 @@ export default function ConnectionLogsPage() {
|
||||
<div>
|
||||
<strong>Session ID:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.sessionId ?? "—"}
|
||||
{row.sessionId ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Protocol:</strong>{" "}
|
||||
{row.protocol?.toUpperCase() ?? "—"}
|
||||
{row.protocol?.toUpperCase() ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Source:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.sourceAddr ?? "—"}
|
||||
{row.sourceAddr ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Destination:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
{row.destAddr ?? "—"}
|
||||
{row.destAddr ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,7 +638,7 @@ export default function ConnectionLogsPage() {
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Resource:</strong>{" "}
|
||||
{row.resourceName ?? "—"}
|
||||
{row.resourceName ?? "-"}
|
||||
{row.resourceNiceId && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({row.resourceNiceId})
|
||||
@@ -654,7 +646,7 @@ export default function ConnectionLogsPage() {
|
||||
)}
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Site:</strong> {row.siteName ?? "—"}
|
||||
<strong>Site:</strong> {row.siteName ?? "-"}
|
||||
{row.siteNiceId && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({row.siteNiceId})
|
||||
@@ -662,7 +654,7 @@ export default function ConnectionLogsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Site ID:</strong> {row.siteId ?? "—"}
|
||||
<strong>Site ID:</strong> {row.siteId ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Started At:</strong>{" "}
|
||||
@@ -670,14 +662,12 @@ export default function ConnectionLogsPage() {
|
||||
? new Date(
|
||||
row.startedAt * 1000
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
: "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Ended At:</strong>{" "}
|
||||
{row.endedAt
|
||||
? new Date(
|
||||
row.endedAt * 1000
|
||||
).toLocaleString()
|
||||
? new Date(row.endedAt * 1000).toLocaleString()
|
||||
: "Active"}
|
||||
</div>
|
||||
<div>
|
||||
@@ -686,7 +676,7 @@ export default function ConnectionLogsPage() {
|
||||
</div>
|
||||
{/*<div>
|
||||
<strong>Resource ID:</strong>{" "}
|
||||
{row.siteResourceId ?? "—"}
|
||||
{row.siteResourceId ?? "-"}
|
||||
</div>*/}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -360,6 +360,7 @@ export default function GeneralPage() {
|
||||
// 105 - Valid Password
|
||||
// 106 - Valid email
|
||||
// 107 - Valid SSO
|
||||
// 108 - Connected Client
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
@@ -377,6 +378,7 @@ export default function GeneralPage() {
|
||||
105: t("validPassword"),
|
||||
106: t("validEmail"),
|
||||
107: t("validSSO"),
|
||||
108: t("connectedClient"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
@@ -510,14 +512,14 @@ export default function GeneralPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
||||
href={
|
||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
|
||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
@@ -634,6 +636,7 @@ export default function GeneralPage() {
|
||||
{ value: "105", label: t("validPassword") },
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "108", label: t("connectedClient") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
HttpDestinationCredenza,
|
||||
parseHttpConfig
|
||||
} from "@app/components/HttpDestinationCredenza";
|
||||
import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza";
|
||||
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// ── Re-export Destination so the rest of the file can use it ──────────────────
|
||||
@@ -203,7 +205,6 @@ function DestinationTypePicker({
|
||||
id: "s3",
|
||||
title: t("streamingS3Title"),
|
||||
description: t("streamingS3Description"),
|
||||
disabled: true,
|
||||
icon: (
|
||||
<Image
|
||||
src="/third-party/s3.png"
|
||||
@@ -218,7 +219,6 @@ function DestinationTypePicker({
|
||||
id: "datadog",
|
||||
title: t("streamingDatadogTitle"),
|
||||
description: t("streamingDatadogDescription"),
|
||||
disabled: true,
|
||||
icon: (
|
||||
<Image
|
||||
src="/third-party/dd.png"
|
||||
@@ -255,7 +255,7 @@ function DestinationTypePicker({
|
||||
<StrategySelect
|
||||
options={destinationTypeOptions}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
onChange={(type) => setSelected(type)}
|
||||
cols={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -291,6 +291,7 @@ export default function StreamingDestinationsPage() {
|
||||
const [typePickerOpen, setTypePickerOpen] = useState(false);
|
||||
const [editingDestination, setEditingDestination] =
|
||||
useState<Destination | null>(null);
|
||||
const [pickedType, setPickedType] = useState<DestinationType>("http");
|
||||
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Delete state
|
||||
@@ -392,7 +393,8 @@ export default function StreamingDestinationsPage() {
|
||||
setTypePickerOpen(true);
|
||||
};
|
||||
|
||||
const handleTypePicked = (_type: DestinationType) => {
|
||||
const handleTypePicked = (type: DestinationType) => {
|
||||
setPickedType(type);
|
||||
setTypePickerOpen(false);
|
||||
setEditingDestination(null);
|
||||
setModalOpen(true);
|
||||
@@ -400,6 +402,7 @@ export default function StreamingDestinationsPage() {
|
||||
|
||||
const openEdit = (destination: Destination) => {
|
||||
setEditingDestination(destination);
|
||||
setPickedType((destination.type as DestinationType) ?? "http");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -434,7 +437,7 @@ export default function StreamingDestinationsPage() {
|
||||
disabled={!isEnterprise}
|
||||
/>
|
||||
))}
|
||||
{/* Add card is always clickable — paywall is enforced inside the picker */}
|
||||
{/* Add card is always clickable - paywall is enforced inside the picker */}
|
||||
<AddDestinationCard onClick={openCreate} />
|
||||
</div>
|
||||
)}
|
||||
@@ -446,13 +449,33 @@ export default function StreamingDestinationsPage() {
|
||||
isPaywalled={!isEnterprise}
|
||||
/>
|
||||
|
||||
<HttpDestinationCredenza
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
editing={editingDestination}
|
||||
orgId={orgId}
|
||||
onSaved={loadDestinations}
|
||||
/>
|
||||
{pickedType === "http" && (
|
||||
<HttpDestinationCredenza
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
editing={editingDestination}
|
||||
orgId={orgId}
|
||||
onSaved={loadDestinations}
|
||||
/>
|
||||
)}
|
||||
{pickedType === "s3" && (
|
||||
<S3DestinationCredenza
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
editing={editingDestination}
|
||||
orgId={orgId}
|
||||
onSaved={loadDestinations}
|
||||
/>
|
||||
)}
|
||||
{pickedType === "datadog" && (
|
||||
<DatadogDestinationCredenza
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
editing={editingDestination}
|
||||
orgId={orgId}
|
||||
onSaved={loadDestinations}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
@@ -65,23 +65,34 @@ export default async function ClientResourcesPage(
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
siteAddress: siteResource.siteAddress || null,
|
||||
mode: siteResource.mode || ("port" as any),
|
||||
sites: siteResource.siteIds.map((siteId, idx) => ({
|
||||
siteId,
|
||||
siteName: siteResource.siteNames[idx],
|
||||
siteNiceId: siteResource.siteNiceIds[idx],
|
||||
online: siteResource.siteOnlines[idx]
|
||||
})),
|
||||
mode: siteResource.mode,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
siteNames: siteResource.siteNames,
|
||||
siteAddresses: siteResource.siteAddresses || null,
|
||||
// protocol: siteResource.protocol,
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
siteIds: siteResource.siteIds,
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
httpHttpsPort: siteResource.destinationPort ?? null,
|
||||
alias: siteResource.alias || null,
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceId: siteResource.siteNiceId,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||
subdomain: siteResource.subdomain ?? null,
|
||||
domainId: siteResource.domainId ?? null,
|
||||
fullDomain: siteResource.fullDomain ?? null
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -62,6 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import UptimeAlertSection from "@app/components/UptimeAlertSection";
|
||||
|
||||
type MaintenanceSectionFormProps = {
|
||||
resource: GetResourceResponse;
|
||||
@@ -578,6 +579,13 @@ export default function GeneralForm() {
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
{resource?.resourceId && resource?.orgId && (
|
||||
<UptimeAlertSection
|
||||
orgId={resource.orgId}
|
||||
resourceId={resource.resourceId}
|
||||
startingName={resource.name}
|
||||
/>
|
||||
)}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import HealthCheckDialog from "@/components/HealthCheckDialog";
|
||||
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -168,6 +168,30 @@ function ProxyResourceTargetsForm({
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
|
||||
const { data: polledTargets } = useQuery({
|
||||
...resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
}),
|
||||
refetchInterval: 10_000
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polledTargets) return;
|
||||
setTargets((prev) =>
|
||||
prev.map((t) => {
|
||||
const fresh = polledTargets.find(
|
||||
(p) => p.targetId === t.targetId
|
||||
);
|
||||
if (!fresh) return t;
|
||||
return {
|
||||
...t,
|
||||
hcHealth: fresh.hcHealth,
|
||||
hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [polledTargets]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -317,19 +341,6 @@ function ProxyResourceTargetsForm({
|
||||
header: () => <span className="p-3">{t("healthCheck")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.hcHealth || "unknown";
|
||||
const isEnabled = row.original.hcEnabled;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return "green";
|
||||
case "unhealthy":
|
||||
return "red";
|
||||
case "unknown":
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -343,19 +354,7 @@ function ProxyResourceTargetsForm({
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return <CircleCheck className="w-3 h-3" />;
|
||||
case "unhealthy":
|
||||
return <CircleX className="w-3 h-3" />;
|
||||
case "unknown":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{row.original.siteType === "newt" ? (
|
||||
<Button
|
||||
@@ -366,12 +365,15 @@ function ProxyResourceTargetsForm({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
|
||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-foreground" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
||||
></div>
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
@@ -640,10 +642,10 @@ function ProxyResourceTargetsForm({
|
||||
hcInterval: null,
|
||||
hcTimeout: null,
|
||||
hcHeaders: null,
|
||||
hcFollowRedirects: null,
|
||||
hcScheme: null,
|
||||
hcHostname: null,
|
||||
hcPort: null,
|
||||
hcFollowRedirects: null,
|
||||
hcHealth: "unknown",
|
||||
hcStatus: null,
|
||||
hcMode: null,
|
||||
@@ -965,10 +967,10 @@ function ProxyResourceTargetsForm({
|
||||
</SettingsSection>
|
||||
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckDialog
|
||||
<HealthCheckCredenza
|
||||
mode="autoSave"
|
||||
open={healthCheckDialogOpen}
|
||||
setOpen={setHealthCheckDialogOpen}
|
||||
targetId={selectedTargetForHealthCheck.targetId}
|
||||
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
|
||||
targetMethod={
|
||||
selectedTargetForHealthCheck.method || undefined
|
||||
@@ -993,7 +995,7 @@ function ProxyResourceTargetsForm({
|
||||
selectedTargetForHealthCheck.hcPort ||
|
||||
selectedTargetForHealthCheck.port,
|
||||
hcFollowRedirects:
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ||
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ??
|
||||
true,
|
||||
hcStatus:
|
||||
selectedTargetForHealthCheck.hcStatus || undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import HealthCheckDialog from "@app/components/HealthCheckDialog";
|
||||
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
@@ -1477,12 +1477,10 @@ export default function Page() {
|
||||
</Button>
|
||||
</div>
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckDialog
|
||||
<HealthCheckCredenza
|
||||
mode="autoSave"
|
||||
open={healthCheckDialogOpen}
|
||||
setOpen={setHealthCheckDialogOpen}
|
||||
targetId={
|
||||
selectedTargetForHealthCheck.targetId
|
||||
}
|
||||
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
|
||||
targetMethod={
|
||||
selectedTargetForHealthCheck.method ||
|
||||
@@ -1517,7 +1515,7 @@ export default function Page() {
|
||||
selectedTargetForHealthCheck.hcPort ||
|
||||
selectedTargetForHealthCheck.port,
|
||||
hcFollowRedirects:
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ||
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ??
|
||||
true,
|
||||
hcStatus:
|
||||
selectedTargetForHealthCheck.hcStatus ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import UptimeAlertSection from "@app/components/UptimeAlertSection";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -111,6 +113,13 @@ export default function GeneralPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{site?.siteId && site?.orgId && (
|
||||
<UptimeAlertSection
|
||||
orgId={site.orgId}
|
||||
siteId={site.siteId}
|
||||
startingName={site.name}
|
||||
/>
|
||||
)}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
||||
@@ -425,7 +425,7 @@ export default function Page() {
|
||||
setRemoteExitNodeOptions(exitNodeOptions);
|
||||
|
||||
if (exitNodeOptions.length === 0) {
|
||||
// No remote exit nodes available — remove local option and default to newt
|
||||
// No remote exit nodes available - remove local option and default to newt
|
||||
setTunnelTypes((prev: any) =>
|
||||
prev.filter((item: any) => item.id !== "local")
|
||||
);
|
||||
@@ -434,7 +434,7 @@ export default function Page() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch remote exit nodes:", error);
|
||||
// If fetch fails, no remote exit nodes available — remove local option and default to newt
|
||||
// If fetch fails, no remote exit nodes available - remove local option and default to newt
|
||||
setTunnelTypes((prev: any) =>
|
||||
prev.filter((item: any) => item.id !== "local")
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Boxes,
|
||||
Building2,
|
||||
Cable,
|
||||
@@ -212,9 +213,9 @@ export const orgNavSections = (
|
||||
icon: <Building2 className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
title: "sidebarAlerting",
|
||||
href: "/{orgId}/settings/alerting",
|
||||
icon: <BellRing className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarProvisioning",
|
||||
@@ -225,6 +226,11 @@ export const orgNavSections = (
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
32
src/app/private-maintenance-screen/page.tsx
Normal file
32
src/app/private-maintenance-screen/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Placeholder"
|
||||
};
|
||||
|
||||
export default async function MaintenanceScreen() {
|
||||
const t = await getTranslations();
|
||||
|
||||
let title = t("privateMaintenanceScreenTitle");
|
||||
let message = t("privateMaintenanceScreenMessage");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{message}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
src/components/AlertingRulesTable.tsx
Normal file
344
src/components/AlertingRulesTable.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
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 { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
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";
|
||||
|
||||
type AlertingRulesTableProps = {
|
||||
orgId: string;
|
||||
siteId?: number;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type AlertRuleRow = {
|
||||
alertRuleId: number;
|
||||
orgId: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
lastTriggeredAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
};
|
||||
|
||||
function ruleHref(orgId: string, ruleId: number) {
|
||||
return `/${orgId}/settings/alerting/${ruleId}`;
|
||||
}
|
||||
|
||||
function sourceSummary(
|
||||
rule: AlertRuleRow,
|
||||
t: (k: string, o?: Record<string, number | string>) => string
|
||||
) {
|
||||
if (
|
||||
rule.eventType === "site_online" ||
|
||||
rule.eventType === "site_offline" ||
|
||||
rule.eventType === "site_toggle"
|
||||
) {
|
||||
return t("alertingSummarySites", { count: rule.siteIds.length });
|
||||
}
|
||||
if (rule.eventType.startsWith("resource_")) {
|
||||
return t("alertingSummaryResources", { count: rule.resourceIds.length });
|
||||
}
|
||||
return t("alertingSummaryHealthChecks", {
|
||||
count: rule.healthCheckIds.length
|
||||
});
|
||||
}
|
||||
|
||||
function triggerLabel(
|
||||
rule: AlertRuleRow,
|
||||
t: (k: string) => string
|
||||
) {
|
||||
switch (rule.eventType) {
|
||||
case "site_online":
|
||||
return t("alertingTriggerSiteOnline");
|
||||
case "site_offline":
|
||||
return t("alertingTriggerSiteOffline");
|
||||
case "site_toggle":
|
||||
return t("alertingTriggerSiteToggle");
|
||||
case "health_check_healthy":
|
||||
return t("alertingTriggerHcHealthy");
|
||||
case "health_check_unhealthy":
|
||||
return t("alertingTriggerHcUnhealthy");
|
||||
case "health_check_toggle":
|
||||
return t("alertingTriggerHcToggle");
|
||||
case "resource_healthy":
|
||||
return t("alertingTriggerResourceHealthy");
|
||||
case "resource_unhealthy":
|
||||
return t("alertingTriggerResourceUnhealthy");
|
||||
case "resource_toggle":
|
||||
return t("alertingTriggerResourceToggle");
|
||||
default:
|
||||
return rule.eventType;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AlertingRulesTable({ orgId, siteId, resourceId }: AlertingRulesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<AlertRuleRow | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
|
||||
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
|
||||
const pageIndex = page - 1;
|
||||
const query = searchParams.get("query") ?? undefined;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
refetch,
|
||||
isRefetching
|
||||
} = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query, siteId, resourceId }));
|
||||
|
||||
const rows = data?.alertRules ?? [];
|
||||
const total = data?.pagination.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount };
|
||||
|
||||
const handlePaginationChange = (newState: PaginationState) => {
|
||||
searchParams.set("page", (newState.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newState.pageSize.toString());
|
||||
filter({ searchParams });
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((value: string) => {
|
||||
if (value) {
|
||||
searchParams.set("query", value);
|
||||
} else {
|
||||
searchParams.delete("query");
|
||||
}
|
||||
searchParams.delete("page");
|
||||
filter({ searchParams });
|
||||
}, 300);
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/alert-rule/${selected.alertRuleId}`
|
||||
);
|
||||
await invalidate();
|
||||
toast({ title: t("alertingRuleDeleted") });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<AlertRuleRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
friendlyName: t("alertingColumnSource"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnSource")}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{sourceSummary(row.original, t)}</span>
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
friendlyName: t("alertingColumnTrigger"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnTrigger")}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{triggerLabel(row.original, t)}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("alertingColumnEnabled"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnEnabled")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
disabled={!isPaid || togglingId === r.alertRuleId}
|
||||
onCheckedChange={(v) => setEnabled(r, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{moment(row.original.createdAt).format("lll")}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "rowActions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!isPaid}
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={ruleHref(orgId, r.alertRuleId)}>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
setOpen={(val) => {
|
||||
setDeleteOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("alertingDeleteQuestion")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("delete")}
|
||||
onConfirm={confirmDelete}
|
||||
string={selected.name}
|
||||
title={t("alertingDeleteRule")}
|
||||
/>
|
||||
)}
|
||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("alertingRules")}
|
||||
searchPlaceholder={t("alertingSearchRules")}
|
||||
onSearch={handleSearchChange}
|
||||
searchQuery={query}
|
||||
manualFiltering
|
||||
onAdd={() => {
|
||||
router.push(`/${orgId}/settings/alerting/create`);
|
||||
}}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isRefetching || isLoading || isFiltering}
|
||||
addButtonText={t("alertingAddRule")}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="rowActions"
|
||||
pagination={paginationState}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
@@ -38,21 +39,32 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export type InternalResourceSiteRow = {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
siteAddress: string | null;
|
||||
sites: InternalResourceSiteRow[];
|
||||
siteNames: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr";
|
||||
mode: "host" | "cidr" | "http";
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destination: string;
|
||||
// destinationPort: number | null;
|
||||
httpHttpsPort: number | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
@@ -61,8 +73,147 @@ export type InternalResourceRow = {
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonPort?: number | null;
|
||||
subdomain?: string | null;
|
||||
domainId?: string | null;
|
||||
fullDomain?: string | null;
|
||||
};
|
||||
|
||||
function resolveHttpHttpsDisplayPort(
|
||||
mode: "http",
|
||||
httpHttpsPort: number | null
|
||||
): number {
|
||||
if (httpHttpsPort != null) {
|
||||
return httpHttpsPort;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
const { mode, destination, httpHttpsPort, scheme } = row;
|
||||
if (mode !== "http") {
|
||||
return destination;
|
||||
}
|
||||
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
|
||||
const downstreamScheme = scheme ?? "http";
|
||||
const hostPart =
|
||||
destination.includes(":") && !destination.startsWith("[")
|
||||
? `[${destination}]`
|
||||
: destination;
|
||||
return `${downstreamScheme}://${hostPart}:${port}`;
|
||||
}
|
||||
|
||||
function isSafeUrlForLink(href: string): boolean {
|
||||
try {
|
||||
void new URL(href);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||
|
||||
function aggregateSitesStatus(
|
||||
resourceSites: InternalResourceSiteRow[]
|
||||
): AggregateSitesStatus {
|
||||
if (resourceSites.length === 0) {
|
||||
return "allOffline";
|
||||
}
|
||||
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
||||
if (onlineCount === resourceSites.length) return "allOnline";
|
||||
if (onlineCount > 0) return "partial";
|
||||
return "allOffline";
|
||||
}
|
||||
|
||||
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
||||
switch (status) {
|
||||
case "allOnline":
|
||||
return "bg-green-500";
|
||||
case "partial":
|
||||
return "bg-yellow-500";
|
||||
case "allOffline":
|
||||
default:
|
||||
return "bg-neutral-500";
|
||||
}
|
||||
}
|
||||
|
||||
function ClientResourceSitesStatusCell({
|
||||
orgId,
|
||||
resourceSites
|
||||
}: {
|
||||
orgId: string;
|
||||
resourceSites: InternalResourceSiteRow[];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (resourceSites.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const aggregate = aggregateSitesStatus(resourceSites);
|
||||
const countLabel = t("multiSitesSelectorSitesCount", {
|
||||
count: resourceSites.length
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
aggregateStatusDotClass(aggregate)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm tabular-nums">{countLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{resourceSites.map((site) => {
|
||||
const isOnline = site.online;
|
||||
return (
|
||||
<DropdownMenuItem key={site.siteId} asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
isOnline
|
||||
? "bg-green-500"
|
||||
: "bg-neutral-500"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{site.siteName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 capitalize",
|
||||
isOnline
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isOnline ? t("online") : t("offline")}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
@@ -97,8 +248,6 @@ export default function ClientResourcesTable({
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -136,6 +285,60 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
};
|
||||
|
||||
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
|
||||
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
||||
|
||||
if (!siteNames || siteNames.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
if (siteNames.length === 1) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{siteNames[0]}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>
|
||||
{siteNames.length} {t("sites")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{siteNames.map((siteName, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={siteNiceIds[idx]}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{siteName}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -185,20 +388,17 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
friendlyName: t("site"),
|
||||
header: () => <span className="p-3">{t("site")}</span>,
|
||||
id: "sites",
|
||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => <span className="p-3">{t("sites")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<ClientResourceSitesStatusCell
|
||||
orgId={resourceRow.orgId}
|
||||
resourceSites={resourceRow.sites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -215,6 +415,10 @@ export default function ClientResourcesTable({
|
||||
{
|
||||
value: "cidr",
|
||||
label: t("editInternalResourceDialogModeCidr")
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
@@ -227,10 +431,14 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<"host" | "cidr" | "port", string> = {
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort")
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
@@ -243,11 +451,12 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.destination}
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={resourceRow.destination}
|
||||
displayText={display}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -260,15 +469,26 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return resourceRow.mode === "host" && resourceRow.alias ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
/>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -399,7 +619,7 @@ export default function ClientResourcesTable({
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
selectedInternalResource!.siteIds[0]
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
@@ -435,7 +655,6 @@ export default function ClientResourcesTable({
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
@@ -450,7 +669,6 @@ export default function ClientResourcesTable({
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
|
||||
42
src/components/ContactSalesBanner.tsx
Normal file
42
src/components/ContactSalesBanner.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ContactSalesBanner() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("contactSalesEnable")}{" "}
|
||||
<Link
|
||||
href="https://click.fossorial.io/ep922"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
{t("contactSalesBookDemo")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
{" " + t("contactSalesOr") + " "}
|
||||
<Link
|
||||
href="https://pangolin.net/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
{t("contactSalesContactUs")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
@@ -25,13 +24,10 @@ import {
|
||||
type InternalResourceFormValues
|
||||
} from "./InternalResourceForm";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -39,18 +35,21 @@ export default function CreateInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
@@ -65,25 +64,56 @@ export default function CreateInternalResourceDialog({
|
||||
`/org/${orgId}/site-resource`,
|
||||
{
|
||||
name: data.name,
|
||||
siteId: data.siteId,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: data.destination,
|
||||
enabled: true,
|
||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
|
||||
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
|
||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode == "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
}
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
@@ -93,7 +123,9 @@ export default function CreateInternalResourceDialog({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -106,31 +138,39 @@ export default function CreateInternalResourceDialog({
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogCreateClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
||||
{t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<InternalResourceForm
|
||||
variant="create"
|
||||
open={open}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
|
||||
63
src/components/DatadogDestinationCredenza.tsx
Normal file
63
src/components/DatadogDestinationCredenza.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface DatadogDestinationCredenzaProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: any;
|
||||
orgId: string;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function DatadogDestinationCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
orgId,
|
||||
onSaved,
|
||||
}: DatadogDestinationCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="sm:max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{editing
|
||||
? t("datadogDestEditTitle")
|
||||
: t("datadogDestAddTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{editing
|
||||
? t("datadogDestEditDescription")
|
||||
: t("datadogDestAddDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
|
||||
<CredenzaBody>
|
||||
<ContactSalesBanner />
|
||||
</CredenzaBody>
|
||||
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -175,15 +175,18 @@ export default function DomainPicker({
|
||||
domainId: firstOrExistingDomain.domainId
|
||||
};
|
||||
|
||||
const base = firstOrExistingDomain.baseDomain;
|
||||
const sub =
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain?.trim() || undefined
|
||||
: undefined;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: firstOrExistingDomain.domainId,
|
||||
type: "organization",
|
||||
subdomain:
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain || undefined
|
||||
: undefined,
|
||||
fullDomain: firstOrExistingDomain.baseDomain,
|
||||
baseDomain: firstOrExistingDomain.baseDomain
|
||||
subdomain: sub,
|
||||
fullDomain: sub ? `${sub}.${base}` : base,
|
||||
baseDomain: base
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
@@ -27,14 +26,11 @@ import {
|
||||
isHostname
|
||||
} from "./InternalResourceForm";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -43,18 +39,21 @@ export default function EditInternalResourceDialog({
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
@@ -67,24 +66,39 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
await api.post(`/site-resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
siteId: data.siteId,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination,
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
@@ -156,13 +170,13 @@ export default function EditInternalResourceDialog({
|
||||
variant="edit"
|
||||
open={open}
|
||||
resource={resource}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
siteResourceId={resource.id}
|
||||
formId="edit-internal-resource-form"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => handleSubmit(values))
|
||||
}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
@@ -178,7 +192,7 @@ export default function EditInternalResourceDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function ExitNodesTable({
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
1386
src/components/HealthCheckCredenza.tsx
Normal file
1386
src/components/HealthCheckCredenza.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,635 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@/components/Credenza";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type HealthCheckConfig = {
|
||||
hcEnabled: boolean;
|
||||
hcPath: string;
|
||||
hcMethod: string;
|
||||
hcInterval: number;
|
||||
hcTimeout: number;
|
||||
hcStatus: number | null;
|
||||
hcHeaders?: { name: string; value: string }[] | null;
|
||||
hcScheme?: string;
|
||||
hcHostname: string;
|
||||
hcPort: number;
|
||||
hcFollowRedirects: boolean;
|
||||
hcMode: string;
|
||||
hcUnhealthyInterval: number;
|
||||
hcTlsServerName: string;
|
||||
};
|
||||
|
||||
type HealthCheckDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
targetId: number;
|
||||
targetAddress: string;
|
||||
targetMethod?: string;
|
||||
initialConfig?: Partial<HealthCheckConfig>;
|
||||
onChanges: (config: HealthCheckConfig) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function HealthCheckDialog({
|
||||
open,
|
||||
setOpen,
|
||||
targetId,
|
||||
targetAddress,
|
||||
targetMethod,
|
||||
initialConfig,
|
||||
onChanges
|
||||
}: HealthCheckDialogProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const healthCheckSchema = z.object({
|
||||
hcEnabled: z.boolean(),
|
||||
hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }),
|
||||
hcMethod: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckMethodRequired") }),
|
||||
hcInterval: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(5, { message: t("healthCheckIntervalMin") }),
|
||||
hcTimeout: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckTimeoutMin") }),
|
||||
hcStatus: z.int().positive().min(100).optional().nullable(),
|
||||
hcHeaders: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcHostname: z.string(),
|
||||
hcPort: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckPortInvalid") })
|
||||
.refine(
|
||||
(val) => {
|
||||
const port = parseInt(val);
|
||||
return port > 0 && port <= 65535;
|
||||
},
|
||||
{
|
||||
message: t("healthCheckPortInvalid")
|
||||
}
|
||||
),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.int().positive().min(5),
|
||||
hcTlsServerName: z.string()
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof healthCheckSchema>>({
|
||||
resolver: zodResolver(healthCheckSchema),
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Determine default scheme from target method
|
||||
const getDefaultScheme = () => {
|
||||
if (initialConfig?.hcScheme) {
|
||||
return initialConfig.hcScheme;
|
||||
}
|
||||
// Default to target method if it's http or https, otherwise default to http
|
||||
if (targetMethod === "https") {
|
||||
return "https";
|
||||
}
|
||||
return "http";
|
||||
};
|
||||
|
||||
form.reset({
|
||||
hcEnabled: initialConfig?.hcEnabled,
|
||||
hcPath: initialConfig?.hcPath,
|
||||
hcMethod: initialConfig?.hcMethod,
|
||||
hcInterval: initialConfig?.hcInterval,
|
||||
hcTimeout: initialConfig?.hcTimeout,
|
||||
hcStatus: initialConfig?.hcStatus,
|
||||
hcHeaders: initialConfig?.hcHeaders,
|
||||
hcScheme: getDefaultScheme(),
|
||||
hcHostname: initialConfig?.hcHostname,
|
||||
hcPort: initialConfig?.hcPort
|
||||
? initialConfig.hcPort.toString()
|
||||
: "",
|
||||
hcFollowRedirects: initialConfig?.hcFollowRedirects,
|
||||
hcMode: initialConfig?.hcMode,
|
||||
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval,
|
||||
hcTlsServerName: initialConfig?.hcTlsServerName ?? ""
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
const watchedEnabled = form.watch("hcEnabled");
|
||||
|
||||
const handleFieldChange = async (fieldName: string, value: any) => {
|
||||
try {
|
||||
const currentValues = form.getValues();
|
||||
const updatedValues = { ...currentValues, [fieldName]: value };
|
||||
|
||||
// Convert hcPort from string to number before passing to parent
|
||||
const configToSend: HealthCheckConfig = {
|
||||
...updatedValues,
|
||||
hcPort: parseInt(updatedValues.hcPort),
|
||||
hcStatus: updatedValues.hcStatus || null
|
||||
};
|
||||
|
||||
await onChanges(configToSend);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("healthCheckError"),
|
||||
description: t("healthCheckErrorDescription"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("configureHealthCheck")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("configureHealthCheckDescription", {
|
||||
target: targetAddress
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form className="space-y-6">
|
||||
{/* Enable Health Checks */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t("enableHealthChecks")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"enableHealthChecksDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcEnabled",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcScheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthScheme")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcScheme",
|
||||
value
|
||||
);
|
||||
}}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"healthSelectScheme"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
HTTPS
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcHostname",
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target
|
||||
.value;
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcPort",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthCheckPath")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcPath",
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP Method */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("httpMethod")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcMethod",
|
||||
value
|
||||
);
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectHttpMethod"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="HEAD">
|
||||
HEAD
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Check Interval, Timeout, and Retry Attempts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"healthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcInterval",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"unhealthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcUnhealthyInterval",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("timeoutSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcTimeout",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Response Codes */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("expectedResponseCodes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={
|
||||
field.value || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcStatus",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"expectedResponseCodesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/*TLS Server Name (SNI)*/}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("tlsServerName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleFieldChange(
|
||||
"hcTlsServerName",
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"tlsServerNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHeaders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcHeaders",
|
||||
value
|
||||
);
|
||||
}}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button onClick={() => setOpen(false)}>{t("done")}</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
768
src/components/HealthCheckFormFields.tsx
Normal file
768
src/components/HealthCheckFormFields.tsx
Normal file
@@ -0,0 +1,768 @@
|
||||
"use client";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { ExternalLink, KeyRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type HealthCheckFormFieldsProps = {
|
||||
form: UseFormReturn<any>;
|
||||
onFieldChange?: (fieldName: string, value: any) => void;
|
||||
showNameField?: boolean;
|
||||
hideEnabledField?: boolean;
|
||||
watchedEnabled?: boolean;
|
||||
watchedMode?: string;
|
||||
};
|
||||
|
||||
export function HealthCheckFormFields({
|
||||
form,
|
||||
onFieldChange,
|
||||
showNameField,
|
||||
hideEnabledField,
|
||||
watchedEnabled,
|
||||
watchedMode
|
||||
}: HealthCheckFormFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const showFields = hideEnabledField || watchedEnabled;
|
||||
|
||||
const handleChange = (
|
||||
fieldName: string,
|
||||
value: any,
|
||||
fieldOnChange: (v: any) => void
|
||||
) => {
|
||||
fieldOnChange(value);
|
||||
if (onFieldChange) {
|
||||
onFieldChange(fieldName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Name */}
|
||||
{showNameField && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("standaloneHcNameLabel")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"standaloneHcNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Enable Health Checks */}
|
||||
{!hideEnabledField && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<FormLabel>{t("enableHealthChecks")}</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) =>
|
||||
handleChange(
|
||||
"hcEnabled",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFields && (
|
||||
<div className="space-y-4">
|
||||
{/* Strategy */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<StrategySelect
|
||||
cols={2}
|
||||
options={[
|
||||
{
|
||||
id: "http",
|
||||
title: "HTTP",
|
||||
description: t(
|
||||
"healthCheckStrategyHttp"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "tcp",
|
||||
title: "TCP",
|
||||
description: t(
|
||||
"healthCheckStrategyTcp"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "snmp",
|
||||
title: "SNMP",
|
||||
description: t(
|
||||
"healthCheckStrategySnmp"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "icmp",
|
||||
title: "Ping (ICMP)",
|
||||
description: t(
|
||||
"healthCheckStrategyIcmp"
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={(value) =>
|
||||
handleChange(
|
||||
"hcMode",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Inline contact-sales banner for SNMP / ICMP */}
|
||||
{(watchedMode === "snmp" || watchedMode === "icmp") && (
|
||||
<div className="rounded-md border border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
Contact sales to enable this feature.{" "}
|
||||
<Link
|
||||
href="https://click.fossorial.io/ep922"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
Book a demo
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
{" or "}
|
||||
<Link
|
||||
href="https://pangolin.net/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
contact us
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection fields + all remaining config — hidden for SNMP / ICMP */}
|
||||
{watchedMode !== "snmp" && watchedMode !== "icmp" && (
|
||||
<>
|
||||
{/* Connection fields */}
|
||||
{watchedMode === "tcp" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"hcHostname",
|
||||
e.target.value,
|
||||
(v) =>
|
||||
field.onChange(
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value;
|
||||
handleChange(
|
||||
"hcPort",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcScheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthScheme")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
handleChange(
|
||||
"hcScheme",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"healthSelectScheme"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
HTTPS
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"hcHostname",
|
||||
e.target.value,
|
||||
(v) =>
|
||||
field.onChange(
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value;
|
||||
handleChange(
|
||||
"hcPort",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP Method + Path + Timeout (shown when not TCP) */}
|
||||
{watchedMode !== "tcp" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("httpMethod")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
handleChange(
|
||||
"hcMethod",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectHttpMethod"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="HEAD">
|
||||
HEAD
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthCheckPath")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"hcPath",
|
||||
e.target.value,
|
||||
(v) =>
|
||||
field.onChange(
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("timeoutSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
handleChange(
|
||||
"hcTimeout",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TCP timeout (shown only for TCP) */}
|
||||
{watchedMode === "tcp" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("timeoutSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value
|
||||
);
|
||||
handleChange(
|
||||
"hcTimeout",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Healthy interval + healthy threshold */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthyIntervalSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value
|
||||
);
|
||||
handleChange(
|
||||
"hcInterval",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHealthyThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthyThreshold")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value
|
||||
);
|
||||
handleChange(
|
||||
"hcHealthyThreshold",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unhealthy interval + unhealthy threshold */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("unhealthyIntervalSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value
|
||||
);
|
||||
handleChange(
|
||||
"hcUnhealthyInterval",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("unhealthyThreshold")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(
|
||||
e.target.value
|
||||
);
|
||||
handleChange(
|
||||
"hcUnhealthyThreshold",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP-only fields */}
|
||||
{watchedMode !== "tcp" && (
|
||||
<>
|
||||
{/* Expected Response Codes + TLS Server Name */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"expectedResponseCodes"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target
|
||||
.value;
|
||||
const value =
|
||||
val
|
||||
? parseInt(
|
||||
val
|
||||
)
|
||||
: null;
|
||||
handleChange(
|
||||
"hcStatus",
|
||||
value,
|
||||
field.onChange
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("tlsServerName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
"hcTlsServerName",
|
||||
e.target
|
||||
.value,
|
||||
(v) =>
|
||||
field.onChange(
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Follow Redirects inline toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcFollowRedirects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<FormLabel className="cursor-pointer">
|
||||
{t("followRedirects")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(
|
||||
value
|
||||
) =>
|
||||
handleChange(
|
||||
"hcFollowRedirects",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHeaders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={field.value}
|
||||
onChange={(value) =>
|
||||
handleChange(
|
||||
"hcHeaders",
|
||||
value,
|
||||
field.onChange
|
||||
)
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
432
src/components/HealthChecksTable.tsx
Normal file
432
src/components/HealthChecksTable.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
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 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 { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type StandaloneHealthChecksTableProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
function formatTarget(row: HealthCheckRow): string {
|
||||
if (!row.hcHostname) return "-";
|
||||
if (row.hcMode === "tcp") {
|
||||
if (!row.hcPort) return row.hcHostname;
|
||||
return `${row.hcHostname}:${row.hcPort}`;
|
||||
}
|
||||
// HTTP / default
|
||||
const scheme = row.hcScheme ?? "http";
|
||||
const host = row.hcHostname;
|
||||
const port = row.hcPort ? `:${row.hcPort}` : "";
|
||||
const path = row.hcPath ?? "/";
|
||||
return `${scheme}://${host}${port}${path}`;
|
||||
}
|
||||
|
||||
const healthLabel: Record<HealthCheckRow["hcHealth"], string> = {
|
||||
healthy: "Healthy",
|
||||
unhealthy: "Unhealthy",
|
||||
unknown: "Unknown"
|
||||
};
|
||||
|
||||
const healthVariant: Record<
|
||||
HealthCheckRow["hcHealth"],
|
||||
"green" | "red" | "secondary"
|
||||
> = {
|
||||
healthy: "green",
|
||||
unhealthy: "red",
|
||||
unknown: "secondary"
|
||||
};
|
||||
|
||||
export default function HealthChecksTable({
|
||||
orgId
|
||||
}: StandaloneHealthChecksTableProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks);
|
||||
|
||||
const [credenzaOpen, setCredenzaOpen] = useState(false);
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<HealthCheckRow | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
|
||||
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
|
||||
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 pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const paginationState: DataTablePaginationState = {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount
|
||||
};
|
||||
|
||||
const handlePaginationChange = (newState: PaginationState) => {
|
||||
searchParams.set("page", (newState.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newState.pageSize.toString());
|
||||
filter({ searchParams });
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((value: string) => {
|
||||
if (value) {
|
||||
searchParams.set("query", value);
|
||||
} else {
|
||||
searchParams.delete("query");
|
||||
}
|
||||
searchParams.delete("page");
|
||||
filter({ searchParams });
|
||||
}, 300);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"]
|
||||
});
|
||||
|
||||
const handleToggleEnabled = async (
|
||||
row: HealthCheckRow,
|
||||
enabled: boolean
|
||||
) => {
|
||||
setTogglingId(row.targetHealthCheckId);
|
||||
try {
|
||||
await api.post(
|
||||
`/org/${orgId}/health-check/${row.targetHealthCheckId}`,
|
||||
{ hcEnabled: enabled }
|
||||
);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/health-check/${selected.targetHealthCheckId}`
|
||||
);
|
||||
await invalidate();
|
||||
toast({ title: t("standaloneHcDeleted") });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<HealthCheckRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.name ? row.original.name : "-"}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
friendlyName: t("standaloneHcColumnMode"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnMode")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.hcMode?.toUpperCase() ?? "-"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
friendlyName: t("standaloneHcColumnTarget"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnTarget")}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{formatTarget(row.original)}</span>
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
friendlyName: "Resource",
|
||||
header: () => (
|
||||
<span className="p-3">Resource</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.resourceId || !r.resourceName || !r.resourceNiceId) {
|
||||
return <span className="text-neutral-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
friendlyName: "Site",
|
||||
header: () => (
|
||||
<span className="p-3">Site</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.siteId || !r.siteName || !r.siteNiceId) {
|
||||
return <span className="text-neutral-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.siteName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
friendlyName: t("standaloneHcColumnHealth"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnHealth")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const health = row.original.hcHealth;
|
||||
if (health === "healthy") {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{healthLabel.healthy}</span>
|
||||
</span>
|
||||
);
|
||||
} else if (health === "unhealthy") {
|
||||
return (
|
||||
<span className="text-red-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span>{healthLabel.unhealthy}</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{healthLabel.unknown}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
friendlyName: "Uptime",
|
||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<UptimeMiniBar orgId={orgId} healthCheckId={row.original.targetHealthCheckId} days={30} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "hcEnabled",
|
||||
friendlyName: t("alertingColumnEnabled"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnEnabled")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={r.hcEnabled}
|
||||
disabled={
|
||||
!isPaid ||
|
||||
togglingId === r.targetHealthCheckId
|
||||
}
|
||||
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "rowActions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!isPaid}
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isPaid}
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setCredenzaOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && deleteOpen && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
setOpen={(val) => {
|
||||
setDeleteOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("standaloneHcDeleteQuestion")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("delete")}
|
||||
onConfirm={handleDelete}
|
||||
string={selected.name}
|
||||
title={t("standaloneHcDeleteTitle")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HealthCheckCredenza
|
||||
mode="submit"
|
||||
open={credenzaOpen}
|
||||
setOpen={(val) => {
|
||||
setCredenzaOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
orgId={orgId}
|
||||
initialValues={selected}
|
||||
onSaved={invalidate}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("standaloneHcTableTitle")}
|
||||
searchPlaceholder={t("standaloneHcSearchPlaceholder")}
|
||||
onSearch={handleSearchChange}
|
||||
searchQuery={query}
|
||||
manualFiltering
|
||||
onAdd={() => {
|
||||
setSelected(null);
|
||||
setCredenzaOpen(true);
|
||||
}}
|
||||
addButtonDisabled={!isPaid}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isRefetching || isLoading || isFiltering}
|
||||
addButtonText={t("standaloneHcAddButton")}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="rowActions"
|
||||
pagination={paginationState}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function LocaleSwitcherSelect({
|
||||
});
|
||||
// Persist locale to the database (fire-and-forget)
|
||||
api.post("/user/locale", { locale }).catch(() => {
|
||||
// Silently ignore errors — cookie is already set as fallback
|
||||
// Silently ignore errors - cookie is already set as fallback
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -405,7 +405,11 @@ export function LogDataTable<TData, TValue>({
|
||||
onClick={() =>
|
||||
!disabled && onExport()
|
||||
}
|
||||
disabled={isExporting || disabled || isExportDisabled}
|
||||
disabled={
|
||||
isExporting ||
|
||||
disabled ||
|
||||
isExportDisabled
|
||||
}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader className="mr-2 size-4 animate-spin" />
|
||||
|
||||
@@ -293,7 +293,7 @@ export default function MachineClientsTable({
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("disconnected")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function PendingSitesTable({
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -353,9 +353,9 @@ export default function PendingSitesTable({
|
||||
<Link
|
||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" size="sm">
|
||||
{originalRow.exitNodeName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
@@ -37,6 +38,7 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useEffect,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -47,6 +49,14 @@ import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import type { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import UptimeMiniBar from "./UptimeMiniBar";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
@@ -161,6 +171,13 @@ export default function ProxyResourcesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
router.refresh();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
try {
|
||||
@@ -361,6 +378,7 @@ export default function ProxyResourcesTable({
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
friendlyName: t("protocol"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("protocol")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
@@ -422,6 +440,17 @@ export default function ProxyResourcesTable({
|
||||
return statusOrder[statusA] - statusOrder[statusB];
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "statusHistory",
|
||||
friendlyName: t("uptime30d"),
|
||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
friendlyName: t("access"),
|
||||
@@ -656,7 +685,7 @@ export default function ProxyResourcesTable({
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{ niceId: false }}
|
||||
columnVisibility={{ niceId: false, protocol: false }}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
63
src/components/S3DestinationCredenza.tsx
Normal file
63
src/components/S3DestinationCredenza.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface S3DestinationCredenzaProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: any;
|
||||
orgId: string;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function S3DestinationCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
orgId,
|
||||
onSaved,
|
||||
}: S3DestinationCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="sm:max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{editing
|
||||
? t("S3DestEditTitle")
|
||||
: t("S3DestAddTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{editing
|
||||
? t("S3DestEditDescription")
|
||||
: t("S3DestAddDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
|
||||
<CredenzaBody>
|
||||
<ContactSalesBanner />
|
||||
</CredenzaBody>
|
||||
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -144,9 +144,9 @@ export default function ShareLinksTable({
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useTransition, useEffect } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
@@ -84,6 +85,13 @@ export default function SitesTable({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
router.refresh();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
@@ -212,7 +220,7 @@ export default function SitesTable({
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -222,6 +230,17 @@ export default function SitesTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
friendlyName: "Uptime",
|
||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: t("dataIn"),
|
||||
@@ -363,9 +382,9 @@ export default function SitesTable({
|
||||
<Link
|
||||
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" size="sm">
|
||||
{originalRow.exitNodeName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
302
src/components/UptimeAlertSection.tsx
Normal file
302
src/components/UptimeAlertSection.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { BellPlus, BellRing } from "lucide-react";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody
|
||||
} from "@app/components/Settings";
|
||||
import UptimeBar from "@app/components/UptimeBar";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
|
||||
interface UptimeAlertSectionProps {
|
||||
orgId: string;
|
||||
siteId?: number;
|
||||
startingName?: string;
|
||||
resourceId?: number;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
export default function UptimeAlertSection({
|
||||
orgId,
|
||||
siteId,
|
||||
startingName,
|
||||
resourceId,
|
||||
days = 90
|
||||
}: UptimeAlertSectionProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`);
|
||||
const [userTags, setUserTags] = useState<Tag[]>([]);
|
||||
const [roleTags, setRoleTags] = useState<Tag[]>([]);
|
||||
const [emailTags, setEmailTags] = useState<Tag[]>([]);
|
||||
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data: alertRules, isLoading: alertRulesLoading } = useQuery(
|
||||
orgQueries.alertRulesForSource({ orgId, siteId, resourceId })
|
||||
);
|
||||
|
||||
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
|
||||
const allUsers = useMemo(
|
||||
() =>
|
||||
orgUsers.map((u) => ({
|
||||
id: String(u.id),
|
||||
text: getUserDisplayName({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
username: u.username
|
||||
})
|
||||
})),
|
||||
[orgUsers]
|
||||
);
|
||||
|
||||
const allRoles = useMemo(
|
||||
() =>
|
||||
orgRoles
|
||||
.map((r) => ({ id: String(r.roleId), text: r.name }))
|
||||
.filter((r) => r.text !== "Admin"),
|
||||
[orgRoles]
|
||||
);
|
||||
|
||||
const hasRules = (alertRules?.length ?? 0) > 0;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (
|
||||
userTags.length === 0 &&
|
||||
roleTags.length === 0 &&
|
||||
emailTags.length === 0
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "No recipients",
|
||||
description:
|
||||
"Please add at least one user, role, or email to notify."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put(`/org/${orgId}/alert-rule`, {
|
||||
name,
|
||||
eventType: siteId ? "site_toggle" : "resource_toggle",
|
||||
enabled: true,
|
||||
cooldownSeconds: 300,
|
||||
siteIds: siteId ? [siteId] : [],
|
||||
healthCheckIds: [],
|
||||
resourceIds: resourceId ? [resourceId] : [],
|
||||
userIds: userTags.map((tag) => tag.id),
|
||||
roleIds: roleTags.map((tag) => Number(tag.id)),
|
||||
emails: emailTags.map((tag) => tag.text),
|
||||
webhookActions: []
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Alert created",
|
||||
description:
|
||||
"You will be notified when this changes status."
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setName("Uptime Alert");
|
||||
setUserTags([]);
|
||||
setRoleTags([]);
|
||||
setEmailTags([]);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orgQueries.alertRulesForSource({
|
||||
orgId,
|
||||
siteId,
|
||||
resourceId
|
||||
}).queryKey
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create alert",
|
||||
description: formatAxiosError(e, "An error occurred.")
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const alertButton = alertRulesLoading ? null : hasRules ? (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/alerting?siteId=${siteId}&resourceId=${resourceId}`}>
|
||||
<BellRing className="size-4 mr-2" />
|
||||
View Alerts
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<BellPlus className="size-4 mr-2" />
|
||||
Add Alert
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Site availability over the last {days} days.
|
||||
</SettingsSectionDescription>
|
||||
</div>
|
||||
{alertButton}
|
||||
</div>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<UptimeBar
|
||||
siteId={siteId}
|
||||
resourceId={resourceId}
|
||||
days={days}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Email Alert</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Get notified by email when this{" "}
|
||||
{siteId ? "site" : "resource"} goes offline or
|
||||
comes back online.
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="alert-name">Name</Label>
|
||||
<Input
|
||||
id="alert-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Alert name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notify Users</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeUserTagIndex}
|
||||
setActiveTagIndex={setActiveUserTagIndex}
|
||||
placeholder="Select users..."
|
||||
size="sm"
|
||||
tags={userTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(userTags)
|
||||
: newTags;
|
||||
setUserTags(next as Tag[]);
|
||||
}}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={allUsers}
|
||||
restrictTagsToAutocompleteOptions
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notify Roles</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeRoleTagIndex}
|
||||
setActiveTagIndex={setActiveRoleTagIndex}
|
||||
placeholder="Select roles..."
|
||||
size="sm"
|
||||
tags={roleTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(roleTags)
|
||||
: newTags;
|
||||
setRoleTags(next as Tag[]);
|
||||
}}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={allRoles}
|
||||
restrictTagsToAutocompleteOptions
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Additional Emails</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeEmailTagIndex}
|
||||
setActiveTagIndex={setActiveEmailTagIndex}
|
||||
placeholder="Enter email addresses..."
|
||||
size="sm"
|
||||
tags={emailTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(emailTags)
|
||||
: newTags;
|
||||
setEmailTags(next as Tag[]);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
validateTag={(tag) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
|
||||
}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Alert
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
221
src/components/UptimeBar.tsx
Normal file
221
src/components/UptimeBar.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "0s";
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
if (h > 0) return s > 0 ? `${h}h ${m}m ${s}s` : `${h}h ${m}m`;
|
||||
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
const barColorClass: Record<string, string> = {
|
||||
good: "bg-green-500",
|
||||
degraded: "bg-yellow-500",
|
||||
bad: "bg-red-500",
|
||||
no_data: "bg-zinc-700"
|
||||
};
|
||||
|
||||
type UptimeBarProps = {
|
||||
orgId?: string;
|
||||
siteId?: number;
|
||||
resourceId?: number;
|
||||
healthCheckId?: number;
|
||||
days?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function UptimeBar({
|
||||
orgId,
|
||||
siteId,
|
||||
resourceId,
|
||||
healthCheckId,
|
||||
days = 90,
|
||||
title,
|
||||
className
|
||||
}: UptimeBarProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const siteQuery = useQuery({
|
||||
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||
enabled: siteId != null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const hcQuery = useQuery({
|
||||
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
|
||||
enabled: healthCheckId != null && siteId == null && resourceId == null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const resourceQuery = useQuery({
|
||||
...orgQueries.resourceStatusHistory({ resourceId, days }),
|
||||
enabled: resourceId != null && siteId == null && healthCheckId == null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const { data, isLoading } =
|
||||
siteId != null ? siteQuery :
|
||||
resourceId != null ? resourceQuery :
|
||||
hcQuery;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{title && (
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 h-8">
|
||||
{Array.from({ length: days }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-sm bg-zinc-800 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{title && (
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm ml-auto">
|
||||
{!allNoData && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{data.overallUptimePercent.toFixed(2)}%
|
||||
</span>{" "}
|
||||
uptime
|
||||
</span>
|
||||
{data.totalDowntimeSeconds > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDuration(
|
||||
data.totalDowntimeSeconds
|
||||
)}
|
||||
</span>{" "}
|
||||
downtime
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{allNoData && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
No data available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar row */}
|
||||
<div className="flex gap-0.5 h-8">
|
||||
{data.days.map((day, i) => (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 rounded-sm cursor-default transition-opacity hover:opacity-80",
|
||||
barColorClass[day.status]
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-[220px] p-3 space-y-1"
|
||||
>
|
||||
<div className="font-semibold text-xs">
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
{day.status !== "no_data" && (
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
Uptime:{" "}
|
||||
<span className="font-medium text-primary-foreground">
|
||||
{day.uptimePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{day.totalDowntimeSeconds > 0 && (
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
Downtime:{" "}
|
||||
<span className="font-medium text-primary-foreground">
|
||||
{formatDuration(
|
||||
day.totalDowntimeSeconds
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{day.downtimeWindows.length > 0 && (
|
||||
<div className="pt-1 space-y-0.5 border-t border-primary-foreground/20">
|
||||
{day.downtimeWindows.map((w, wi) => (
|
||||
<div
|
||||
key={wi}
|
||||
className="text-xs text-primary-foreground/70"
|
||||
>
|
||||
{formatTime(w.start)}
|
||||
{w.end
|
||||
? ` – ${formatTime(w.end)}`
|
||||
: " – ongoing"}{" "}
|
||||
<span className="capitalize">
|
||||
({w.status})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{day.status === "no_data" && (
|
||||
<div className="text-xs text-primary-foreground/60">
|
||||
No monitoring data
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date labels */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{days} days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/components/UptimeMiniBar.tsx
Normal file
144
src/components/UptimeMiniBar.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "0s";
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
});
|
||||
}
|
||||
|
||||
const barColorClass: Record<string, string> = {
|
||||
good: "bg-green-500",
|
||||
degraded: "bg-yellow-500",
|
||||
bad: "bg-red-500",
|
||||
no_data: "bg-zinc-700"
|
||||
};
|
||||
|
||||
type UptimeMiniBarProps = {
|
||||
orgId?: string;
|
||||
siteId?: number;
|
||||
resourceId?: number;
|
||||
healthCheckId?: number;
|
||||
days?: number;
|
||||
};
|
||||
|
||||
export default function UptimeMiniBar({
|
||||
orgId,
|
||||
siteId,
|
||||
resourceId,
|
||||
healthCheckId,
|
||||
days = 30
|
||||
}: UptimeMiniBarProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const siteQuery = useQuery({
|
||||
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||
enabled: siteId != null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
const hcQuery = useQuery({
|
||||
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
|
||||
enabled: healthCheckId != null && siteId == null && resourceId == null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
const resourceQuery = useQuery({
|
||||
...orgQueries.resourceStatusHistory({ resourceId, days }),
|
||||
enabled: resourceId != null && siteId == null && healthCheckId == null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
const { data, isLoading } =
|
||||
siteId != null ? siteQuery :
|
||||
resourceId != null ? resourceQuery :
|
||||
hcQuery;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-px h-5 w-24">
|
||||
{Array.from({ length: days }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px] bg-zinc-800 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-12">—</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex gap-px h-5"
|
||||
style={{ width: `${days * 5}px` }}
|
||||
>
|
||||
{data.days.map((day, i) => (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 rounded-[2px] cursor-default transition-opacity hover:opacity-75",
|
||||
barColorClass[day.status]
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="p-2 space-y-0.5">
|
||||
<div className="font-semibold text-xs">
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
{day.status === "no_data"
|
||||
? "No data"
|
||||
: `${day.uptimePercent.toFixed(1)}% uptime`}
|
||||
</div>
|
||||
{day.totalDowntimeSeconds > 0 && (
|
||||
<div className="text-xs text-primary-foreground/70">
|
||||
Down:{" "}
|
||||
{formatDuration(day.totalDowntimeSeconds)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{allNoData
|
||||
? "No data"
|
||||
: `${data.overallUptimePercent.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -373,12 +373,12 @@ export default function UserDevicesTable({
|
||||
<Link
|
||||
href={`/${r.orgId}/settings/access/users/${r.userId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" size="sm">
|
||||
{getUserDisplayName({
|
||||
email: r.userEmail,
|
||||
username: r.username
|
||||
}) || r.userId}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -427,7 +427,7 @@ export default function UserDevicesTable({
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>{t("disconnected")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -218,7 +218,7 @@ function drawInteractiveCountries(
|
||||
});
|
||||
hoverPath
|
||||
.datum(country)
|
||||
.attr("d", path(country) as string)
|
||||
.attr("d", path(country as any) as string)
|
||||
.style("display", null);
|
||||
})
|
||||
|
||||
|
||||
1355
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
1355
src/components/alert-rule-editor/AlertRuleFields.tsx
Normal file
File diff suppressed because it is too large
Load Diff
366
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
366
src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionBlock,
|
||||
AddActionPanel,
|
||||
AlertRuleSourceFields,
|
||||
AlertRuleTriggerFields
|
||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
buildFormSchema,
|
||||
defaultFormValues,
|
||||
formValuesToApiPayload,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import type { CreateAlertRuleResponse } from "@server/private/routers/alertRule";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const FORM_ID = "alert-rule-form";
|
||||
|
||||
type StepAccent = {
|
||||
labelClass: string;
|
||||
icon: typeof Flag;
|
||||
};
|
||||
|
||||
type AlertRuleGraphEditorProps = {
|
||||
orgId: string;
|
||||
alertRuleId?: number;
|
||||
initialValues: AlertRuleFormValues;
|
||||
isNew: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function VerticalRuleStep({
|
||||
stepNumber,
|
||||
isLast,
|
||||
title,
|
||||
accent,
|
||||
children
|
||||
}: {
|
||||
stepNumber: number;
|
||||
isLast: boolean;
|
||||
title: string;
|
||||
accent: StepAccent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const Icon = accent.icon;
|
||||
return (
|
||||
<li className="flex gap-4 sm:gap-5">
|
||||
<div
|
||||
className="flex flex-col items-center gap-0 shrink-0 w-8"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-border bg-background text-sm font-semibold text-muted-foreground">
|
||||
{stepNumber}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px flex-1 min-h-8 my-1 border-l-2 border-dashed border-border" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
isLast
|
||||
? "min-w-0 flex-1 space-y-3"
|
||||
: "min-w-0 flex-1 space-y-3 pb-10"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 font-semibold text-base ${accent.labelClass}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-4 sm:p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertRuleGraphEditor({
|
||||
orgId,
|
||||
alertRuleId,
|
||||
initialValues,
|
||||
isNew,
|
||||
disabled = false
|
||||
}: AlertRuleGraphEditorProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const schema = useMemo(() => buildFormSchema(t), [t]);
|
||||
const form = useForm<AlertRuleFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues ?? defaultFormValues()
|
||||
});
|
||||
|
||||
const { fields, append, remove, update } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "actions"
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = formValuesToApiPayload(values);
|
||||
if (isNew) {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateAlertRuleResponse>
|
||||
>(`/org/${orgId}/alert-rule`, payload);
|
||||
toast({ title: t("alertingRuleSaved") });
|
||||
router.replace(
|
||||
`/${orgId}/settings/alerting/${res.data.data.alertRuleId}`
|
||||
);
|
||||
} else {
|
||||
await api.post(
|
||||
`/org/${orgId}/alert-rule/${alertRuleId}`,
|
||||
payload
|
||||
);
|
||||
toast({ title: t("alertingRuleSaved") });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||
<aside className="w-full lg:w-[min(100%,280px)] shrink-0 lg:sticky lg:top-16 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-5 space-y-4">
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "space-y-4"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isNew && (
|
||||
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
|
||||
{t("alertingDraftBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"alertingRuleNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="alert-rule-enabled"
|
||||
label={t(
|
||||
"alertingRuleEnabled"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
disabled={
|
||||
disabled
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 w-full max-w-3xl">
|
||||
<ol className="list-none m-0 p-0">
|
||||
<VerticalRuleStep
|
||||
stepNumber={1}
|
||||
isLast={false}
|
||||
title={t("alertingSectionSource")}
|
||||
accent={{
|
||||
labelClass:
|
||||
"text-emerald-600 dark:text-emerald-400",
|
||||
icon: Flag
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<AlertRuleSourceFields
|
||||
orgId={orgId}
|
||||
control={form.control}
|
||||
/>
|
||||
</fieldset>
|
||||
</VerticalRuleStep>
|
||||
<VerticalRuleStep
|
||||
stepNumber={2}
|
||||
isLast={false}
|
||||
title={t("alertingSectionTrigger")}
|
||||
accent={{
|
||||
labelClass:
|
||||
"text-amber-600 dark:text-amber-400",
|
||||
icon: Cog
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<AlertRuleTriggerFields
|
||||
control={form.control}
|
||||
/>
|
||||
</fieldset>
|
||||
</VerticalRuleStep>
|
||||
<VerticalRuleStep
|
||||
stepNumber={3}
|
||||
isLast
|
||||
title={t("alertingSectionActions")}
|
||||
accent={{
|
||||
labelClass:
|
||||
"text-blue-600 dark:text-blue-400",
|
||||
icon: Zap
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AddActionPanel
|
||||
onAdd={(type) => {
|
||||
if (type === "notify") {
|
||||
append({
|
||||
type: "notify",
|
||||
userTags: [],
|
||||
roleTags: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else {
|
||||
append({
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [
|
||||
{
|
||||
key: "",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
authType: "none",
|
||||
bearerToken: "",
|
||||
basicCredentials:
|
||||
"",
|
||||
customHeaderName:
|
||||
"",
|
||||
customHeaderValue:
|
||||
""
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fields.map((f, index) => (
|
||||
<ActionBlock
|
||||
key={f.id}
|
||||
orgId={orgId}
|
||||
index={index}
|
||||
control={form.control}
|
||||
form={form}
|
||||
onRemove={() =>
|
||||
remove(index)
|
||||
}
|
||||
onUpdate={(val) =>
|
||||
update(index, val)
|
||||
}
|
||||
canRemove
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</VerticalRuleStep>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
117
src/components/multi-site-selector.tsx
Normal file
117
src/components/multi-site-selector.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
|
||||
export type MultiSitesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||
filterTypes?: string[];
|
||||
};
|
||||
|
||||
export function formatMultiSitesSelectorLabel(
|
||||
selectedSites: Selectedsite[],
|
||||
t: (key: string, values?: { count: number }) => string
|
||||
): string {
|
||||
if (selectedSites.length === 0) {
|
||||
return t("selectSites");
|
||||
}
|
||||
if (selectedSites.length === 1) {
|
||||
return selectedSites[0]!.name;
|
||||
}
|
||||
return t("multiSitesSelectorSitesCount", {
|
||||
count: selectedSites.length
|
||||
});
|
||||
}
|
||||
|
||||
export function MultiSitesSelector({
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectionChange,
|
||||
filterTypes
|
||||
}: MultiSitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 10
|
||||
})
|
||||
);
|
||||
|
||||
const sitesShown = useMemo(() => {
|
||||
const base = filterTypes
|
||||
? sites.filter((s) => filterTypes.includes(s.type))
|
||||
: [...sites];
|
||||
if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) {
|
||||
const selectedNotInBase = selectedSites.filter(
|
||||
(sel) => !base.some((s) => s.siteId === sel.siteId)
|
||||
);
|
||||
return [...selectedNotInBase, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [debouncedQuery, sites, selectedSites, filterTypes]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selectedSites.map((s) => s.siteId)),
|
||||
[selectedSites]
|
||||
);
|
||||
|
||||
const toggleSite = (site: Selectedsite) => {
|
||||
if (selectedIds.has(site.siteId)) {
|
||||
onSelectionChange(
|
||||
selectedSites.filter((s) => s.siteId !== site.siteId)
|
||||
);
|
||||
} else {
|
||||
onSelectionChange([...selectedSites, site]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
value={siteSearchQuery}
|
||||
onValueChange={(v) => setSiteSearchQuery(v)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={`${site.siteId}:${site.name}`}
|
||||
onSelect={() => {
|
||||
toggleSite(site);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedIds.has(site.siteId)}
|
||||
onCheckedChange={() => {}}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<span className="truncate">{site.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,6 @@ import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ContainersSelector } from "./ContainersSelector";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
@@ -212,6 +204,12 @@ export function ResourceTargetAddressItem({
|
||||
proxyTarget.port === 0 ? "" : proxyTarget.port
|
||||
}
|
||||
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
|
||||
type="number"
|
||||
onKeyDown={(e) => {
|
||||
if (["e", "E", "+", "-", "."].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
@@ -227,6 +225,7 @@ export function ResourceTargetAddressItem({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ const Checkbox = React.forwardRef<
|
||||
className={cn(checkboxVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-4 w-4" />
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
456
src/lib/alertRuleForm.ts
Normal file
456
src/lib/alertRuleForm.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared primitive schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form-layer types
|
||||
// NOTE: the form uses "health_check_unhealthy" internally; it maps to the
|
||||
// backend's "health_check_unhealthy" at the API boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AlertTrigger =
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
|
||||
export type AlertRuleFormAction =
|
||||
| {
|
||||
type: "notify";
|
||||
userTags: Tag[];
|
||||
roleTags: Tag[];
|
||||
emailTags: Tag[];
|
||||
}
|
||||
| {
|
||||
type: "webhook";
|
||||
url: string;
|
||||
method: string;
|
||||
headers: { key: string; value: string }[];
|
||||
authType: "none" | "bearer" | "basic" | "custom";
|
||||
bearerToken: string;
|
||||
basicCredentials: string;
|
||||
customHeaderName: string;
|
||||
customHeaderValue: string;
|
||||
};
|
||||
|
||||
export type AlertRuleFormValues = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
sourceType: "site" | "health_check" | "resource";
|
||||
allSites: boolean;
|
||||
siteIds: number[];
|
||||
allHealthChecks: boolean;
|
||||
healthCheckIds: number[];
|
||||
allResources: boolean;
|
||||
resourceIds: number[];
|
||||
trigger: AlertTrigger;
|
||||
actions: AlertRuleFormAction[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API boundary types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AlertRuleApiPayload = {
|
||||
name: string;
|
||||
eventType:
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
enabled: boolean;
|
||||
allSites: boolean;
|
||||
siteIds: number[];
|
||||
allHealthChecks: boolean;
|
||||
healthCheckIds: number[];
|
||||
allResources: boolean;
|
||||
resourceIds: number[];
|
||||
userIds: string[];
|
||||
roleIds: number[];
|
||||
emails: string[];
|
||||
webhookActions: {
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
config?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Shape of what GET /org/:orgId/alert-rule/:alertRuleId returns
|
||||
export type AlertRuleApiResponse = {
|
||||
alertRuleId: number;
|
||||
orgId: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
lastTriggeredAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
recipients: {
|
||||
recipientId: number;
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
email: string | null;
|
||||
}[];
|
||||
webhookActions: {
|
||||
webhookActionId: number;
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
lastSentAt: number | null;
|
||||
config: {
|
||||
authType: string;
|
||||
bearerToken?: string;
|
||||
basicCredentials?: string;
|
||||
customHeaderName?: string;
|
||||
customHeaderValue?: string;
|
||||
headers?: { key: string; value: string }[];
|
||||
method?: string;
|
||||
} | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod form schema (for react-hook-form validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildFormSchema(t: (k: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t("alertingErrorNameRequired") }),
|
||||
enabled: z.boolean(),
|
||||
sourceType: z.enum(["site", "health_check", "resource"]),
|
||||
allSites: z.boolean(),
|
||||
siteIds: z.array(z.number()),
|
||||
allHealthChecks: z.boolean(),
|
||||
healthCheckIds: z.array(z.number()),
|
||||
allResources: z.boolean(),
|
||||
resourceIds: z.array(z.number()),
|
||||
trigger: z.enum([
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"site_toggle",
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy",
|
||||
"health_check_toggle",
|
||||
"resource_healthy",
|
||||
"resource_unhealthy",
|
||||
"resource_toggle"
|
||||
]),
|
||||
actions: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("notify"),
|
||||
userTags: z.array(tagSchema),
|
||||
roleTags: z.array(tagSchema),
|
||||
emailTags: z.array(tagSchema)
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("webhook"),
|
||||
url: z.string(),
|
||||
method: z.string(),
|
||||
headers: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
})
|
||||
),
|
||||
authType: z.enum(["none", "bearer", "basic", "custom"]),
|
||||
bearerToken: z.string(),
|
||||
basicCredentials: z.string(),
|
||||
customHeaderName: z.string(),
|
||||
customHeaderValue: z.string()
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.actions.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorActionsMin"),
|
||||
path: ["actions"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "site" &&
|
||||
!val.allSites &&
|
||||
val.siteIds.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorPickSites"),
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "health_check" &&
|
||||
!val.allHealthChecks &&
|
||||
val.healthCheckIds.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorPickHealthChecks"),
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "resource" &&
|
||||
!val.allResources &&
|
||||
val.resourceIds.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorPickResources"),
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
const siteTriggers: AlertTrigger[] = [
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"site_toggle"
|
||||
];
|
||||
const hcTriggers: AlertTrigger[] = [
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy",
|
||||
"health_check_toggle"
|
||||
];
|
||||
const resourceTriggers: AlertTrigger[] = [
|
||||
"resource_healthy",
|
||||
"resource_unhealthy",
|
||||
"resource_toggle"
|
||||
];
|
||||
if (
|
||||
val.sourceType === "site" &&
|
||||
!siteTriggers.includes(val.trigger)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorTriggerSite"),
|
||||
path: ["trigger"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "health_check" &&
|
||||
!hcTriggers.includes(val.trigger)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorTriggerHealth"),
|
||||
path: ["trigger"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
val.sourceType === "resource" &&
|
||||
!resourceTriggers.includes(val.trigger)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorTriggerResource"),
|
||||
path: ["trigger"]
|
||||
});
|
||||
}
|
||||
val.actions.forEach((a, i) => {
|
||||
if (a.type === "notify") {
|
||||
if (
|
||||
a.userTags.length === 0 &&
|
||||
a.roleTags.length === 0 &&
|
||||
a.emailTags.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorNotifyRecipients"),
|
||||
path: ["actions", i, "userTags"]
|
||||
});
|
||||
}
|
||||
}
|
||||
if (a.type === "webhook") {
|
||||
try {
|
||||
new URL(a.url.trim());
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("alertingErrorWebhookUrl"),
|
||||
path: ["actions", i, "url"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defaultFormValues
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function defaultFormValues(): AlertRuleFormValues {
|
||||
return {
|
||||
name: "",
|
||||
enabled: true,
|
||||
sourceType: "site",
|
||||
allSites: true,
|
||||
siteIds: [],
|
||||
allHealthChecks: true,
|
||||
healthCheckIds: [],
|
||||
allResources: true,
|
||||
resourceIds: [],
|
||||
trigger: "site_toggle",
|
||||
actions: []
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response → form values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function apiResponseToFormValues(
|
||||
rule: AlertRuleApiResponse
|
||||
): AlertRuleFormValues {
|
||||
const trigger = rule.eventType;
|
||||
const sourceType = rule.eventType.startsWith("site_")
|
||||
? "site"
|
||||
: rule.eventType.startsWith("resource_")
|
||||
? "resource"
|
||||
: "health_check";
|
||||
|
||||
// Collect notify recipients into a single notify action (if any)
|
||||
const userTags = rule.recipients
|
||||
.filter((r) => r.userId != null)
|
||||
.map((r) => ({ id: r.userId!, text: r.userId! }));
|
||||
const roleTags = rule.recipients
|
||||
.filter((r) => r.roleId != null)
|
||||
.map((r) => ({ id: String(r.roleId!), text: String(r.roleId!) }));
|
||||
const emailTags = rule.recipients
|
||||
.filter((r) => r.email != null)
|
||||
.map((r) => ({ id: r.email!, text: r.email! }));
|
||||
|
||||
const actions: AlertRuleFormAction[] = [];
|
||||
|
||||
if (userTags.length > 0 || roleTags.length > 0 || emailTags.length > 0) {
|
||||
actions.push({ type: "notify", userTags, roleTags, emailTags });
|
||||
}
|
||||
|
||||
// Each webhook action becomes its own form webhook action
|
||||
for (const w of rule.webhookActions) {
|
||||
const cfg = w.config;
|
||||
actions.push({
|
||||
type: "webhook",
|
||||
url: w.webhookUrl,
|
||||
method: cfg?.method ?? "POST",
|
||||
headers: cfg?.headers?.length
|
||||
? cfg.headers
|
||||
: [{ key: "", value: "" }],
|
||||
authType:
|
||||
(cfg?.authType as "none" | "bearer" | "basic" | "custom") ??
|
||||
"none",
|
||||
bearerToken: cfg?.bearerToken ?? "",
|
||||
basicCredentials: cfg?.basicCredentials ?? "",
|
||||
customHeaderName: cfg?.customHeaderName ?? "",
|
||||
customHeaderValue: cfg?.customHeaderValue ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
const allSites = sourceType === "site" && rule.siteIds.length === 0;
|
||||
const allHealthChecks =
|
||||
sourceType === "health_check" && rule.healthCheckIds.length === 0;
|
||||
const allResources =
|
||||
sourceType === "resource" && (rule.resourceIds?.length ?? 0) === 0;
|
||||
|
||||
return {
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
sourceType,
|
||||
allSites,
|
||||
siteIds: rule.siteIds,
|
||||
allHealthChecks,
|
||||
healthCheckIds: rule.healthCheckIds,
|
||||
allResources,
|
||||
resourceIds: rule.resourceIds ?? [],
|
||||
trigger: trigger as AlertTrigger,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form values → API payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formValuesToApiPayload(
|
||||
values: AlertRuleFormValues
|
||||
): AlertRuleApiPayload {
|
||||
const eventType = values.trigger;
|
||||
|
||||
// Collect all notify-type actions and merge their recipient lists
|
||||
const allUserIds: string[] = [];
|
||||
const allRoleIds: number[] = [];
|
||||
const allEmails: string[] = [];
|
||||
|
||||
const webhookActions: AlertRuleApiPayload["webhookActions"] = [];
|
||||
|
||||
for (const action of values.actions) {
|
||||
if (action.type === "notify") {
|
||||
allUserIds.push(...action.userTags.map((t) => t.id));
|
||||
allRoleIds.push(...action.roleTags.map((t) => Number(t.id)));
|
||||
allEmails.push(
|
||||
...action.emailTags.map((t) => t.text.trim()).filter(Boolean)
|
||||
);
|
||||
} else if (action.type === "webhook") {
|
||||
webhookActions.push({
|
||||
webhookUrl: action.url.trim(),
|
||||
enabled: true,
|
||||
config: JSON.stringify({
|
||||
authType: action.authType,
|
||||
bearerToken: action.bearerToken || undefined,
|
||||
basicCredentials: action.basicCredentials || undefined,
|
||||
customHeaderName: action.customHeaderName || undefined,
|
||||
customHeaderValue: action.customHeaderValue || undefined,
|
||||
headers: action.headers.filter((h) => h.key.trim()),
|
||||
method: action.method
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const uniqueUserIds = [...new Set(allUserIds)];
|
||||
const uniqueRoleIds: number[] = [...new Set(allRoleIds)];
|
||||
const uniqueEmails = [...new Set(allEmails)];
|
||||
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
eventType,
|
||||
enabled: values.enabled,
|
||||
allSites: values.allSites,
|
||||
siteIds: values.allSites ? [] : values.siteIds,
|
||||
allHealthChecks: values.allHealthChecks,
|
||||
healthCheckIds: values.allHealthChecks ? [] : values.healthCheckIds,
|
||||
allResources: values.allResources,
|
||||
resourceIds: values.allResources ? [] : values.resourceIds,
|
||||
userIds: uniqueUserIds,
|
||||
roleIds: uniqueRoleIds,
|
||||
emails: uniqueEmails,
|
||||
webhookActions
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ListResourceNamesResponse,
|
||||
ListResourcesResponse
|
||||
} from "@server/routers/resource";
|
||||
import type { ListAlertRulesResponse } from "@server/private/routers/alertRule";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
@@ -27,7 +28,8 @@ import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import { remote } from "./api";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
import { wait } from "./wait";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -156,7 +158,8 @@ export const orgQueries = {
|
||||
queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
pageSize: perPage.toString(),
|
||||
status: "approved"
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
@@ -230,6 +233,204 @@ export const orgQueries = {
|
||||
|
||||
return res.data.data.resources;
|
||||
}
|
||||
}),
|
||||
|
||||
healthChecks: ({
|
||||
orgId,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "HEALTH_CHECKS", { perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
limit: perPage.toString(),
|
||||
offset: "0"
|
||||
});
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListHealthChecksResponse>
|
||||
>(`/org/${orgId}/health-checks?${sp.toString()}`, { signal });
|
||||
return res.data.data.healthChecks;
|
||||
}
|
||||
}),
|
||||
|
||||
alertRules: ({
|
||||
orgId,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
query,
|
||||
siteId,
|
||||
resourceId
|
||||
}: {
|
||||
orgId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
siteId?: number;
|
||||
resourceId?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query, siteId, resourceId }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("limit", String(limit));
|
||||
sp.set("offset", String(offset));
|
||||
if (query) sp.set("query", query);
|
||||
if (siteId != null) sp.set("siteId", String(siteId));
|
||||
if (resourceId != null) sp.set("resourceId", String(resourceId));
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListAlertRulesResponse>
|
||||
>(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
|
||||
return {
|
||||
alertRules: res.data.data.alertRules,
|
||||
pagination: res.data.data.pagination
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
alertRulesForSource: ({
|
||||
orgId,
|
||||
siteId,
|
||||
resourceId
|
||||
}: {
|
||||
orgId: string;
|
||||
siteId?: number;
|
||||
resourceId?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "ALERT_RULES", { siteId, resourceId }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (siteId != null) sp.set("siteId", String(siteId));
|
||||
if (resourceId != null) sp.set("resourceId", String(resourceId));
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListAlertRulesResponse>
|
||||
>(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
|
||||
return res.data.data.alertRules;
|
||||
}
|
||||
}),
|
||||
|
||||
standaloneHealthChecks: ({
|
||||
orgId,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
query
|
||||
}: {
|
||||
orgId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset, query }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("limit", String(limit));
|
||||
sp.set("offset", String(offset));
|
||||
if (query) sp.set("query", query);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<{
|
||||
healthChecks: {
|
||||
targetHealthCheckId: number;
|
||||
name: string;
|
||||
siteId: number | null;
|
||||
siteName: string | null;
|
||||
siteNiceId: string | null;
|
||||
hcEnabled: boolean;
|
||||
hcHealth: "unknown" | "healthy" | "unhealthy";
|
||||
hcMode: string | null;
|
||||
hcHostname: string | null;
|
||||
hcPort: number | null;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
hcMethod: string | null;
|
||||
hcInterval: number | null;
|
||||
hcUnhealthyInterval: number | null;
|
||||
hcTimeout: number | null;
|
||||
hcHeaders: string | null;
|
||||
hcFollowRedirects: boolean | null;
|
||||
hcStatus: number | null;
|
||||
hcTlsServerName: string | null;
|
||||
hcHealthyThreshold: number | null;
|
||||
hcUnhealthyThreshold: number | null;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}>
|
||||
>(`/org/${orgId}/health-checks?${sp.toString()}`, { signal });
|
||||
return {
|
||||
healthChecks: res.data.data.healthChecks,
|
||||
pagination: res.data.data.pagination
|
||||
};
|
||||
}
|
||||
}),
|
||||
siteStatusHistory: ({
|
||||
siteId,
|
||||
days = 90
|
||||
}: {
|
||||
siteId: number;
|
||||
days?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(`/site/${siteId}/status-history?days=${days}`, { signal });
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
|
||||
resourceStatusHistory: ({
|
||||
resourceId,
|
||||
days = 90
|
||||
}: {
|
||||
resourceId?: number;
|
||||
days?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(`/resource/${resourceId}/status-history?days=${days}`, { signal });
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
|
||||
healthCheckStatusHistory: ({
|
||||
orgId,
|
||||
healthCheckId,
|
||||
days = 90
|
||||
}: {
|
||||
orgId: string;
|
||||
healthCheckId: number;
|
||||
days?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"HC_STATUS_HISTORY",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
days
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<StatusHistoryResponse>
|
||||
>(
|
||||
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.data.data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
|
||||
// No cookie found — try to restore from user's saved locale in DB
|
||||
// No cookie found - try to restore from user's saved locale in DB
|
||||
try {
|
||||
const res = await internal.get("/user", await authCookieHeader());
|
||||
const userLocale = res.data?.data?.locale;
|
||||
if (userLocale && locales.includes(userLocale as Locale)) {
|
||||
// Try to cache in a cookie so subsequent requests skip the API
|
||||
// call. cookies().set() is only permitted in Server Actions and
|
||||
// Route Handlers — not during rendering — so we isolate it so
|
||||
// Route Handlers - not during rendering - so we isolate it so
|
||||
// that a write failure doesn't prevent the locale from being
|
||||
// returned for the current request.
|
||||
try {
|
||||
@@ -40,7 +40,7 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
return userLocale as Locale;
|
||||
}
|
||||
} catch {
|
||||
// User not logged in or API unavailable — fall through
|
||||
// User not logged in or API unavailable - fall through
|
||||
}
|
||||
|
||||
const headerList = await headers();
|
||||
|
||||
Reference in New Issue
Block a user