diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d1accfc9d..652c9112e 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -113,6 +113,16 @@ const listResourcesSchema = z.object({ enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], 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." + }), + siteId: z.coerce + .number() + .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; siteName: string | null; }>; + sites: Array<{ + siteId: number; + siteName: string; + siteNiceId: string; + online: boolean; + }>; }; // Aggregate filters @@ -260,7 +276,8 @@ export async function listResources( query, healthStatus, sort_by, - order + order, + siteId } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); @@ -380,6 +397,19 @@ export async function listResources( } } + 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) + ); + } + let aggregateFilters: SQL | undefined = sql`1 = 1`; if (typeof healthStatus !== "undefined") { @@ -444,12 +474,15 @@ export async function listResources( .select({ targetId: targets.targetId, resourceId: targets.resourceId, + siteId: targets.siteId, ip: targets.ip, port: targets.port, enabled: targets.enabled, healthStatus: targetHealthCheck.hcHealth, hcEnabled: targetHealthCheck.hcEnabled, - siteName: sites.name + siteName: sites.name, + siteNiceId: sites.niceId, + siteOnline: sites.online }) .from(targets) .where(inArray(targets.resourceId, resourceIdList)) @@ -481,7 +514,8 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [] + targets: [], + sites: [] }; map.set(row.resourceId, entry); } @@ -491,6 +525,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()); return response(res, { diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index cdbf959f4..ed76aafb7 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; import type { GetOrgResponse } from "@server/routers/org"; 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 { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; @@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps { searchParams: Promise>; } +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( props: ProxyResourcesPageProps ) { @@ -47,13 +55,31 @@ export default async function ProxyResourcesPage( pagination = responseData.pagination; } catch (e) {} - let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; - } 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).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } let org = null; try { @@ -102,7 +128,8 @@ export default async function ProxyResourcesPage( enabled: target.enabled, healthStatus: target.healthStatus, siteName: target.siteName - })) + })), + sites: resource.sites ?? [] }; }); return ( @@ -123,6 +150,7 @@ export default async function ProxyResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b43cf1ac1..9866e444f 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -48,13 +48,12 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; +import { + ResourceSitesStatusCell, + type ResourceSiteRow +} from "@app/components/ResourceSitesStatusCell"; -export type InternalResourceSiteRow = { - siteId: number; - siteName: string; - siteNiceId: string; - online: boolean; -}; +export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceRow = { id: number; @@ -119,109 +118,6 @@ 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 -; - } - - const aggregate = aggregateSitesStatus(resourceSites); - const countLabel = t("multiSitesSelectorSitesCount", { - count: resourceSites.length - }); - - return ( - - - - - - {resourceSites.map((site) => { - const isOnline = site.online; - return ( - - -
-
- - {site.siteName} - -
- - {isOnline ? t("online") : t("offline")} - - - - ); - })} - - - ); -} - type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -321,9 +217,7 @@ export default function ClientResourcesTable({ if (siteNames.length === 1) { return ( - + + + +
+ +
+ +
+ + ), + cell: ({ row }) => ( + + ) + }, { accessorKey: "protocol", friendlyName: t("protocol"), @@ -620,6 +715,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) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/ResourceSitesStatusCell.tsx b/src/components/ResourceSitesStatusCell.tsx new file mode 100644 index 000000000..3c940c6b0 --- /dev/null +++ b/src/components/ResourceSitesStatusCell.tsx @@ -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 -; + } + + const aggregate = aggregateSitesStatus(resourceSites); + const countLabel = t("multiSitesSelectorSitesCount", { + count: resourceSites.length + }); + + return ( + + + + + + {resourceSites.map((site) => { + const isOnline = site.online; + return ( + + +
+
+ + {site.siteName} + +
+ + {isOnline ? t("online") : t("offline")} + + + + ); + })} + + + ); +}