Merge branch 'alerting-rules' into dev

This commit is contained in:
Owen
2026-04-21 15:05:12 -07:00
230 changed files with 16351 additions and 3320 deletions

View 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}
/>
</>
);
}

View File

@@ -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>
)}

View File

@@ -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(() => {

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

View File

@@ -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")}

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

View File

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

View File

@@ -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")}

View File

@@ -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>
)}

View File

@@ -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>
);

File diff suppressed because it is too large Load Diff

View File

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

View 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>
)}
</>
);
}

View 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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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"
/>

View File

@@ -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>
)}

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

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>
);

View 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>
</>
);
}

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

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

View File

@@ -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>
);

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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>
);

View File

@@ -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>
));