diff --git a/.cursor/rules/Localization.mdc b/.cursor/rules/Localization.mdc new file mode 100644 index 000000000..3014c6177 --- /dev/null +++ b/.cursor/rules/Localization.mdc @@ -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. diff --git a/.cursor/rules/Nomenclature.mdc b/.cursor/rules/Nomenclature.mdc new file mode 100644 index 000000000..33c4af797 --- /dev/null +++ b/.cursor/rules/Nomenclature.mdc @@ -0,0 +1,6 @@ +--- +alwaysApply: true +--- + +Proxy resources = public resources +Private resources = client resources diff --git a/messages/en-US.json b/messages/en-US.json index 99e4d09e5..c8e76add4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", + "sitesTableViewPublicResources": "View Public Resources", + "sitesTableViewPrivateResources": "View Private Resources", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index e6889d285..9127d74e6 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; + }>; }; function queryResourcesBase() { @@ -240,7 +256,8 @@ export async function listResources( query, healthStatus, sort_by, - order + order, + siteId } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); @@ -361,6 +378,18 @@ export async function listResources( if (typeof healthStatus !== "undefined") { 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)); @@ -390,12 +419,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)) @@ -427,7 +459,8 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [] + targets: [], + sites: [] }; 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()); return response(res, { diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 8750e7516..0a6166755 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -4,7 +4,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; 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 createHttpError from "http-errors"; import { z } from "zod"; @@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + siteId: z.coerce + .number() + .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 { page, pageSize, query, mode, sort_by, order } = + const { page, pageSize, query, mode, sort_by, order, siteId } = parsedQuery.data; 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) { conditions.push( or( diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index da967feea..42d4e69eb 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import type { ListResourcesResponse } from "@server/routers/resource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; @@ -22,6 +24,13 @@ export interface ClientResourcesPageProps { 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 ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -47,6 +56,32 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } 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 { const res = await getCachedOrg(params.orgId); @@ -114,6 +149,7 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> 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 36f8caa78..f10f6414e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { DataTable } 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 { DropdownMenu, @@ -12,6 +13,11 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -23,12 +29,14 @@ import { ArrowUpRight, ChevronDown, ChevronsUpDownIcon, + Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; 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 EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; @@ -40,13 +48,13 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +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; @@ -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 -; - } - - 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; pagination: PaginationState; rowCount: number; + initialFilterSite?: Selectedsite | null; }; export default function ClientResourcesTable({ internalResources, orgId, pagination, - rowCount + rowCount, + initialFilterSite = null }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -247,9 +154,26 @@ export default function ClientResourcesTable({ const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); 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 = () => { startTransition(() => { try { @@ -294,9 +218,7 @@ export default function ClientResourcesTable({ if (siteNames.length === 1) { return ( - + + + +
+ +
+ +
+ + ), cell: ({ row }) => { const resourceRow = row.original; return ( - @@ -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) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index 3e7b585b8..5a944cd88 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -74,7 +75,10 @@ export function ColumnFilter({ - + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx index 7d17066cb..689f78983 100644 --- a/src/components/ColumnFilterButton.tsx +++ b/src/components/ColumnFilterButton.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -75,7 +76,10 @@ export function ColumnFilterButton({
- + diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx index ee386461d..787a306b2 100644 --- a/src/components/ColumnMultiFilterButton.tsx +++ b/src/components/ColumnMultiFilterButton.tsx @@ -18,6 +18,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; type FilterOption = { @@ -101,7 +102,10 @@ export function ColumnMultiFilterButton({ - + diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 404ade547..9545cbb7d 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -50,6 +50,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -376,7 +377,7 @@ export default function HealthChecksTable({
@@ -445,7 +446,7 @@ export default function HealthChecksTable({
diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index b00503c3d..7203236e1 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn"; export function InfoSections({ children, - cols + cols, + columnSizing = "content" }: { children: React.ReactNode; 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 (
{children} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index ff0a6ad5b..324f29552 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -2,6 +2,11 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; 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 { ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -11,9 +16,17 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; 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 { useEnvContext } from "@app/hooks/useEnvContext"; 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 { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -29,6 +42,7 @@ import { ChevronDown, ChevronsUpDownIcon, Clock, + Funnel, MoreHorizontal, ShieldCheck, ShieldOff, @@ -39,6 +53,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, + useMemo, useOptimistic, useRef, useState, @@ -84,6 +99,7 @@ export type ResourceRow = { targetPort?: number; targets?: TargetHealth[]; health?: "online" | "degraded" | "unhealthy" | "unknown"; + sites: ResourceSiteRow[]; }; function StatusIcon({ @@ -114,13 +130,15 @@ type ProxyResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; + initialFilterSite?: Selectedsite | null; }; export default function ProxyResourcesTable({ resources, orgId, pagination, - rowCount + rowCount, + initialFilterSite = null }: ProxyResourcesTableProps) { const router = useRouter(); const { @@ -140,13 +158,30 @@ export default function ProxyResourcesTable({ const [isRefreshing, startTransition] = 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(() => { const interval = setInterval(() => { router.refresh(); }, 10_000); return () => clearInterval(interval); - }, []); + }, [router]); const refreshData = () => { startTransition(() => { @@ -351,6 +386,67 @@ export default function ProxyResourcesTable({ return {row.original.nice || "-"}; } }, + { + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", "), + friendlyName: t("sites"), + header: () => ( + + + + + +
+ +
+ +
+
+ ), + cell: ({ row }) => ( + + ) + }, { accessorKey: "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) { 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")} + + + + ); + })} + + + ); +} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d59244552..45c0d9a0b 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -239,9 +239,7 @@ export default function SitesTable({ if (originalRow.type == "local") { return -; } - return ( - - ); + return ; } }, { @@ -437,6 +435,22 @@ export default function SitesTable({ {t("viewSettings")} + + + {t("sitesTableViewPublicResources")} + + + + + {t("sitesTableViewPrivateResources")} + + { setSelectedSite(siteRow); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 58081783a..1f7683adc 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -33,6 +33,7 @@ import { } from "@app/components/ui/dropdown-menu"; import { Input } from "@app/components/ui/input"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover"; import { ChevronDown, @@ -345,7 +346,9 @@ export function ControlledDataTable({ {filter.label} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 2b2861bb5..673388454 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -34,6 +34,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; +import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover"; import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, @@ -603,7 +604,9 @@ export function DataTable({ {filter.label} diff --git a/src/lib/dataTableFilterPopover.ts b/src/lib/dataTableFilterPopover.ts new file mode 100644 index 000000000..606527209 --- /dev/null +++ b/src/lib/dataTableFilterPopover.ts @@ -0,0 +1,5 @@ +export const dataTableFilterPopoverContentClassName = + "w-[min(16rem,calc(100vw-2rem))] p-0"; + +export const dataTableFilterDropdownContentClassName = + "w-[min(16rem,calc(100vw-2rem))]";