Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-04-24 17:32:28 -07:00
19 changed files with 572 additions and 149 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.

View File

@@ -0,0 +1,6 @@
---
alwaysApply: true
---
Proxy resources = public resources
Private resources = client resources

View File

@@ -93,6 +93,8 @@
"siteConfirmCopy": "I have copied the config", "siteConfirmCopy": "I have copied the config",
"searchSitesProgress": "Search sites...", "searchSitesProgress": "Search sites...",
"siteAdd": "Add Site", "siteAdd": "Add Site",
"sitesTableViewPublicResources": "View Public Resources",
"sitesTableViewPrivateResources": "View Private Resources",
"siteInstallNewt": "Install Site", "siteInstallNewt": "Install Site",
"siteInstallNewtDescription": "Install the site connector for your system", "siteInstallNewtDescription": "Install the site connector for your system",
"WgConfiguration": "WireGuard Configuration", "WgConfiguration": "WireGuard Configuration",

View File

@@ -113,6 +113,16 @@ const listResourcesSchema = z.object({
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
description: description:
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
}),
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only resources that have at least one target on this site are returned"
}) })
}); });
@@ -141,6 +151,12 @@ export type ResourceWithTargets = {
healthStatus: "healthy" | "unhealthy" | "unknown" | null; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null; siteName: string | null;
}>; }>;
sites: Array<{
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
}>;
}; };
function queryResourcesBase() { function queryResourcesBase() {
@@ -240,7 +256,8 @@ export async function listResources(
query, query,
healthStatus, healthStatus,
sort_by, sort_by,
order order,
siteId
} = parsedQuery.data; } = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params); const parsedParams = listResourcesParamsSchema.safeParse(req.params);
@@ -361,6 +378,18 @@ export async function listResources(
if (typeof healthStatus !== "undefined") { if (typeof healthStatus !== "undefined") {
conditions.push(eq(resources.health, healthStatus)); conditions.push(eq(resources.health, healthStatus));
} }
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
);
conditions.push(
inArray(resources.resourceId, resourcesWithSite)
);
}
} }
const baseQuery = queryResourcesBase().where(and(...conditions)); const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -390,12 +419,15 @@ export async function listResources(
.select({ .select({
targetId: targets.targetId, targetId: targets.targetId,
resourceId: targets.resourceId, resourceId: targets.resourceId,
siteId: targets.siteId,
ip: targets.ip, ip: targets.ip,
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth, healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled, hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online
}) })
.from(targets) .from(targets)
.where(inArray(targets.resourceId, resourceIdList)) .where(inArray(targets.resourceId, resourceIdList))
@@ -427,7 +459,8 @@ export async function listResources(
enabled: row.enabled, enabled: row.enabled,
domainId: row.domainId, domainId: row.domainId,
headerAuthId: row.headerAuthId, headerAuthId: row.headerAuthId,
targets: [] targets: [],
sites: []
}; };
map.set(row.resourceId, entry); map.set(row.resourceId, entry);
} }
@@ -437,6 +470,33 @@ export async function listResources(
); );
} }
for (const entry of map.values()) {
const raw = allResourceTargets.filter(
(t) => t.resourceId === entry.resourceId
);
const siteById = new Map<
number,
{
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
}
>();
for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}
const resourcesList: ResourceWithTargets[] = Array.from(map.values()); const resourcesList: ResourceWithTargets[] = Array.from(map.values());
return response<ListResourcesResponse>(res, { return response<ListResourcesResponse>(res, {

View File

@@ -4,7 +4,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination"; import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
enum: ["asc", "desc"], enum: ["asc", "desc"],
default: "asc", default: "asc",
description: "Sort order" description: "Sort order"
}),
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
}) })
}); });
@@ -199,10 +209,31 @@ export async function listAllSiteResourcesByOrg(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { page, pageSize, query, mode, sort_by, order } = const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data; parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
const resourcesForSite = db
.select({ id: siteResources.siteResourceId })
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.orgId, orgId),
eq(sites.orgId, orgId),
eq(sites.siteId, siteId)
)
);
conditions.push(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (query) { if (query) {
conditions.push( conditions.push(
or( or(

View File

@@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListResourcesResponse } from "@server/routers/resource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -22,6 +24,13 @@ export interface ClientResourcesPageProps {
searchParams: Promise<Record<string, string>>; searchParams: Promise<Record<string, string>>;
} }
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ClientResourcesPage( export default async function ClientResourcesPage(
props: ClientResourcesPageProps props: ClientResourcesPageProps
) { ) {
@@ -47,6 +56,32 @@ export default async function ClientResourcesPage(
pagination = responseData.pagination; pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null; let org = null;
try { try {
const res = await getCachedOrg(params.orgId); const res = await getCachedOrg(params.orgId);
@@ -114,6 +149,7 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1, pageIndex: pagination.page - 1,
pageSize: pagination.pageSize pageSize: pagination.pageSize
}} }}
initialFilterSite={initialFilterSite}
/> />
</OrgProvider> </OrgProvider>
</> </>

View File

@@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org"; import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps {
searchParams: Promise<Record<string, string>>; searchParams: Promise<Record<string, string>>;
} }
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ProxyResourcesPage( export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps props: ProxyResourcesPageProps
) { ) {
@@ -47,13 +55,31 @@ export default async function ProxyResourcesPage(
pagination = responseData.pagination; pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
try {
const res = await internal.get< let initialFilterSite: {
AxiosResponse<ListAllSiteResourcesByOrgResponse> siteId: number;
>(`/org/${params.orgId}/site-resources`, await authCookieHeader()); name: string;
siteResources = res.data.data.siteResources; type: string;
} catch (e) {} } | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null; let org = null;
try { try {
@@ -102,7 +128,8 @@ export default async function ProxyResourcesPage(
enabled: target.enabled, enabled: target.enabled,
healthStatus: target.healthStatus, healthStatus: target.healthStatus,
siteName: target.siteName siteName: target.siteName
})) })),
sites: resource.sites ?? []
}; };
}); });
return ( return (
@@ -123,6 +150,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1, pageIndex: pagination.page - 1,
pageSize: pagination.pageSize pageSize: pagination.pageSize
}} }}
initialFilterSite={initialFilterSite}
/> />
</OrgProvider> </OrgProvider>
</> </>

View File

@@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table"; import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,11 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -23,12 +29,14 @@ import {
ArrowUpRight, ArrowUpRight,
ChevronDown, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Funnel,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
@@ -40,13 +48,13 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
export type InternalResourceSiteRow = { export type InternalResourceSiteRow = ResourceSiteRow;
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
@@ -111,121 +119,20 @@ function isSafeUrlForLink(href: string): boolean {
} }
} }
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 = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
pagination: PaginationState; pagination: PaginationState;
rowCount: number; rowCount: number;
initialFilterSite?: Selectedsite | null;
}; };
export default function ClientResourcesTable({ export default function ClientResourcesTable({
internalResources, internalResources,
orgId, orgId,
pagination, pagination,
rowCount rowCount,
initialFilterSite = null
}: ClientResourcesTableProps) { }: ClientResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const { const {
@@ -247,9 +154,26 @@ export default function ClientResourcesTable({
const [editingResource, setEditingResource] = const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>(); useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => { const refreshData = () => {
startTransition(() => { startTransition(() => {
try { try {
@@ -294,9 +218,7 @@ export default function ClientResourcesTable({
if (siteNames.length === 1) { if (siteNames.length === 1) {
return ( return (
<Link <Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
>
<Button variant="outline"> <Button variant="outline">
{siteNames[0]} {siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
@@ -321,10 +243,7 @@ export default function ClientResourcesTable({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => ( {siteNames.map((siteName, idx) => (
<DropdownMenuItem <DropdownMenuItem key={siteNiceIds[idx]} asChild>
key={siteNiceIds[idx]}
asChild
>
<Link <Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`} href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer" className="flex items-center gap-2 cursor-pointer"
@@ -391,11 +310,59 @@ export default function ClientResourcesTable({
id: "sites", id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"), friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>, header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<ClientResourceSitesStatusCell <ResourceSitesStatusCell
orgId={resourceRow.orgId} orgId={resourceRow.orgId}
resourceSites={resourceRow.sites} resourceSites={resourceRow.sites}
/> />
@@ -576,6 +543,16 @@ export default function ClientResourcesTable({
}); });
} }
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
interface FilterOption { interface FilterOption {
@@ -74,7 +75,10 @@ export function ColumnFilter({
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" /> <ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start"> <PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={searchPlaceholder} />
<CommandList> <CommandList>

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
interface FilterOption { interface FilterOption {
@@ -75,7 +76,10 @@ export function ColumnFilterButton({
</div> </div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start"> <PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={searchPlaceholder} />
<CommandList> <CommandList>

View File

@@ -18,6 +18,7 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon, Funnel } from "lucide-react"; import { CheckIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
type FilterOption = { type FilterOption = {
@@ -101,7 +102,10 @@ export function ColumnMultiFilterButton({
</div> </div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start"> <PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={searchPlaceholder} />
<CommandList> <CommandList>

View File

@@ -50,6 +50,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
type StandaloneHealthChecksTableProps = { type StandaloneHealthChecksTableProps = {
orgId: string; orgId: string;
@@ -376,7 +377,7 @@ export default function HealthChecksTable({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0" className={dataTableFilterPopoverContentClassName}
align="start" align="start"
> >
<div className="border-b p-1"> <div className="border-b p-1">
@@ -445,7 +446,7 @@ export default function HealthChecksTable({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0" className={dataTableFilterPopoverContentClassName}
align="start" align="start"
> >
<div className="border-b p-1"> <div className="border-b p-1">

View File

@@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn";
export function InfoSections({ export function InfoSections({
children, children,
cols cols,
columnSizing = "content"
}: { }: {
children: React.ReactNode; children: React.ReactNode;
cols?: number; cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content";
}) { }) {
const n = cols || 1;
const track =
columnSizing === "fill" ? "minmax(0, 1fr)" : "minmax(0, max-content)";
return ( return (
<div <div
className={`grid grid-cols-2 md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start`} className={cn(
"grid grid-cols-2 md:grid-cols-(--columns) md:gap-16 gap-2 md:items-start",
columnSizing === "content" &&
"md:justify-items-start md:justify-start"
)}
style={{ style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the // @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value // value of a CSS variable at runtime and tailwind will just reuse that value
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))` "--columns": `repeat(${n}, ${track})`
}} }}
> >
{children} {children}

View File

@@ -2,6 +2,11 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
@@ -11,9 +16,17 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -29,6 +42,7 @@ import {
ChevronDown, ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Clock, Clock,
Funnel,
MoreHorizontal, MoreHorizontal,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
@@ -39,6 +53,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
useEffect, useEffect,
useMemo,
useOptimistic, useOptimistic,
useRef, useRef,
useState, useState,
@@ -84,6 +99,7 @@ export type ResourceRow = {
targetPort?: number; targetPort?: number;
targets?: TargetHealth[]; targets?: TargetHealth[];
health?: "online" | "degraded" | "unhealthy" | "unknown"; health?: "online" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[];
}; };
function StatusIcon({ function StatusIcon({
@@ -114,13 +130,15 @@ type ProxyResourcesTableProps = {
orgId: string; orgId: string;
pagination: PaginationState; pagination: PaginationState;
rowCount: number; rowCount: number;
initialFilterSite?: Selectedsite | null;
}; };
export default function ProxyResourcesTable({ export default function ProxyResourcesTable({
resources, resources,
orgId, orgId,
pagination, pagination,
rowCount rowCount,
initialFilterSite = null
}: ProxyResourcesTableProps) { }: ProxyResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const { const {
@@ -140,13 +158,30 @@ export default function ProxyResourcesTable({
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
router.refresh(); router.refresh();
}, 10_000); }, 10_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [router]);
const refreshData = () => { const refreshData = () => {
startTransition(() => { startTransition(() => {
@@ -351,6 +386,67 @@ export default function ProxyResourcesTable({
return <span>{row.original.nice || "-"}</span>; return <span>{row.original.nice || "-"}</span>;
} }
}, },
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
resourceSites={row.original.sites}
/>
)
},
{ {
accessorKey: "protocol", accessorKey: "protocol",
friendlyName: t("protocol"), friendlyName: t("protocol"),
@@ -596,6 +692,16 @@ export default function ProxyResourcesTable({
}); });
} }
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);

View File

@@ -0,0 +1,123 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { cn } from "@app/lib/cn";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
export type ResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
function aggregateSitesStatus(
resourceSites: ResourceSiteRow[]
): 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";
}
}
export function ResourceSitesStatusCell({
orgId,
resourceSites
}: {
orgId: string;
resourceSites: ResourceSiteRow[];
}) {
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>
);
}

View File

@@ -239,9 +239,7 @@ export default function SitesTable({
if (originalRow.type == "local") { if (originalRow.type == "local") {
return <span>-</span>; return <span>-</span>;
} }
return ( return <UptimeMiniBar siteId={originalRow.id} days={30} />;
<UptimeMiniBar siteId={originalRow.id} days={30} />
);
} }
}, },
{ {
@@ -437,6 +435,22 @@ export default function SitesTable({
{t("viewSettings")} {t("viewSettings")}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t("sitesTableViewPublicResources")}
</DropdownMenuItem>
</Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t("sitesTableViewPrivateResources")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedSite(siteRow); setSelectedSite(siteRow);

View File

@@ -33,6 +33,7 @@ import {
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
import { import {
ChevronDown, ChevronDown,
@@ -345,7 +346,9 @@ export function ControlledDataTable<TData, TValue>({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="w-48" className={
dataTableFilterDropdownContentClassName
}
> >
<DropdownMenuLabel> <DropdownMenuLabel>
{filter.label} {filter.label}

View File

@@ -34,6 +34,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import { import {
Card, Card,
@@ -603,7 +604,9 @@ export function DataTable<TData, TValue>({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="w-48" className={
dataTableFilterDropdownContentClassName
}
> >
<DropdownMenuLabel> <DropdownMenuLabel>
{filter.label} {filter.label}

View File

@@ -0,0 +1,5 @@
export const dataTableFilterPopoverContentClassName =
"w-[min(16rem,calc(100vw-2rem))] p-0";
export const dataTableFilterDropdownContentClassName =
"w-[min(16rem,calc(100vw-2rem))]";