From 12e777b32e7840ebfad66574f1c8d0c1d32cd934 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 12 May 2026 20:25:32 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=20Add=20labels=20column=20to=20pri?= =?UTF-8?q?vate=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/client/page.tsx | 3 +- src/components/ClientResourcesTable.tsx | 691 +++++++++++------- 2 files changed, 425 insertions(+), 269 deletions(-) diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 42d4e69eb..ad661f55b 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -127,7 +127,8 @@ export default async function ClientResourcesPage( authDaemonPort: siteResource.authDaemonPort ?? null, subdomain: siteResource.subdomain ?? null, domainId: siteResource.domainId ?? null, - fullDomain: siteResource.fullDomain ?? null + fullDomain: siteResource.fullDomain ?? null, + labels: siteResource.labels ?? [] }; } ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 88b1e938e..7ebef5795 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -2,7 +2,6 @@ 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"; @@ -30,13 +29,21 @@ import { ChevronDown, ChevronsUpDownIcon, Funnel, - MoreHorizontal + MoreHorizontal, + PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { useEffect, useMemo, useState, useTransition } from "react"; +import { + startTransition, + useEffect, + useMemo, + useOptimistic, + useState, + useTransition +} from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import type { PaginationState } from "@tanstack/react-table"; @@ -53,6 +60,10 @@ import { } from "@app/components/ResourceSitesStatusCell"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { build } from "@server/build"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { LabelBadge } from "./label-badge"; +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -84,6 +95,11 @@ export type InternalResourceRow = { subdomain?: string | null; domainId?: string | null; fullDomain?: string | null; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; function formatDestinationDisplay(row: InternalResourceRow): string { @@ -141,7 +157,10 @@ export default function ClientResourcesTable({ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [siteFilterOpen, setSiteFilterOpen] = useState(false); - const [isRefreshing, startTransition] = useTransition(); + const [isRefreshing, startRefreshTransition] = useTransition(); + + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); useEffect(() => { const interval = setInterval(() => { @@ -167,7 +186,7 @@ export default function ClientResourcesTable({ }, [initialFilterSite, siteIdQ, siteIdNum, t]); const refreshData = () => { - startTransition(() => { + startRefreshTransition(() => { try { router.refresh(); } catch (error) { @@ -254,296 +273,333 @@ export default function ClientResourcesTable({ ); } - const internalColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: t("name"), - header: () => { - const nameOrder = getSortDirection("name", searchParams); - const Icon = - nameOrder === "asc" - ? ArrowDown01Icon - : nameOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; + const internalColumns = useMemo< + ExtendedColumnDef[] + >(() => { + const cols: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; - return ( - - ); - } - }, - { - id: "niceId", - accessorKey: "niceId", - friendlyName: t("identifier"), - enableHiding: true, - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - return {row.original.niceId || "-"}; - } - }, - { - id: "sites", - accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), - friendlyName: t("sites"), - header: () => ( - - + return ( - - { + return ( + + ); + }, + cell: ({ row }) => { + return {row.original.niceId || "-"}; + } + }, + { + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", "), + friendlyName: t("sites"), + header: () => ( + -
+ -
- + +
+ +
+ +
+
+ ), + cell: ({ row }) => { + const resourceRow = row.original; + return ( + -
-
- ), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - ); - } - }, - { - accessorKey: "mode", - friendlyName: t("editInternalResourceDialogMode"), - header: () => ( - ( + + handleFilterChange("mode", value) } - ]} - selectedValue={searchParams.get("mode") ?? undefined} - onValueChange={(value) => handleFilterChange("mode", value)} - searchPlaceholder={t("searchPlaceholder")} - emptyMessage={t("emptySearchOptions")} - label={t("editInternalResourceDialogMode")} - className="p-3" - /> - ), - cell: ({ row }) => { - const resourceRow = row.original; - const modeLabels: Record< - "host" | "cidr" | "port" | "http", - string - > = { - host: t("editInternalResourceDialogModeHost"), - cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort"), - http: t("editInternalResourceDialogModeHttp") - }; - return {modeLabels[resourceRow.mode]}; - } - }, - { - accessorKey: "destination", - friendlyName: t("resourcesTableDestination"), - header: () => ( - {t("resourcesTableDestination")} - ), - cell: ({ row }) => { - const resourceRow = row.original; - const display = formatDestinationDisplay(resourceRow); - return ( - - ); - } - }, - { - accessorKey: "alias", - friendlyName: t("resourcesTableAlias"), - header: () => ( - {t("resourcesTableAlias")} - ), - cell: ({ row }) => { - const resourceRow = row.original; - if (resourceRow.mode === "host" && resourceRow.alias) { + ), + cell: ({ row }) => { + const resourceRow = row.original; + const modeLabels: Record< + "host" | "cidr" | "port" | "http", + string + > = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + port: t("editInternalResourceDialogModePort"), + http: t("editInternalResourceDialogModeHttp") + }; + return {modeLabels[resourceRow.mode]}; + } + }, + { + accessorKey: "destination", + friendlyName: t("resourcesTableDestination"), + header: () => ( + + {t("resourcesTableDestination")} + + ), + cell: ({ row }) => { + const resourceRow = row.original; + const display = formatDestinationDisplay(resourceRow); return ( ); } - if (resourceRow.mode === "http") { - const domainId = resourceRow.domainId; - const fullDomain = resourceRow.fullDomain; - const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; - const did = - build !== "oss" && - resourceRow.ssl && - domainId != null && - domainId !== "" && - fullDomain != null && - fullDomain !== ""; + }, + { + accessorKey: "alias", + friendlyName: t("resourcesTableAlias"), + header: () => ( + {t("resourcesTableAlias")} + ), + cell: ({ row }) => { + const resourceRow = row.original; + if (resourceRow.mode === "host" && resourceRow.alias) { + return ( + + ); + } + if (resourceRow.mode === "http") { + const domainId = resourceRow.domainId; + const fullDomain = resourceRow.fullDomain; + const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; + const did = + build !== "oss" && + resourceRow.ssl && + domainId != null && + domainId !== "" && + fullDomain != null && + fullDomain !== ""; - return ( -
- {did ? ( - - ) : null} -
- + return ( +
+ {did ? ( + + ) : null} +
+ +
+ ); + } + return -; + } + }, + { + accessorKey: "aliasAddress", + friendlyName: t("resourcesTableAliasAddress"), + enableHiding: true, + header: () => ( +
+ {t("resourcesTableAliasAddress")} + +
+ ), + cell: ({ row }) => { + const resourceRow = row.original; + return resourceRow.aliasAddress ? ( + + ) : ( + - + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + { + setSelectedInternalResource( + resourceRow + ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
); } - return -; } - }, - { - accessorKey: "aliasAddress", - friendlyName: t("resourcesTableAliasAddress"), - enableHiding: true, - header: () => ( -
- {t("resourcesTableAliasAddress")} - -
- ), - cell: ({ row }) => { - const resourceRow = row.original; - return resourceRow.aliasAddress ? ( - ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: InternalResourceRow } }) => ( + - ) : ( - - - ); - } - }, - { - id: "actions", - enableHiding: false, - header: () => , - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - { - setSelectedInternalResource( - resourceRow - ); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - -
- ); - } + ) + }); } - ]; + + return cols; + }, [isLabelFeatureEnabled, orgId, t, searchParams]); function handleFilterChange( column: string, @@ -638,7 +694,8 @@ export default function ClientResourcesTable({ enableColumnVisibility columnVisibility={{ niceId: false, - aliasAddress: false + aliasAddress: false, + labels: false }} stickyLeftColumn="name" stickyRightColumn="actions" @@ -674,3 +731,101 @@ export default function ClientResourcesTable({ ); } + +type ClientResourceLabelCellProps = { + resource: InternalResourceRow; + orgId: string; +}; + +function ClientResourceLabelCell({ + resource, + orgId +}: ClientResourceLabelCellProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const router = useRouter(); + + const labels = resource.labels ?? []; + const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); + + function toggleResourceLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + startTransition(async () => { + try { + if (action === "attach") { + setOptimisticLabels([...optimisticLabels, label]); + await api.put( + `/org/${orgId}/label/${label.labelId}/attach`, + { siteResourceId: resource.id } + ); + } else { + setOptimisticLabels( + optimisticLabels.filter( + (lb) => lb.labelId !== label.labelId + ) + ); + await api.put( + `/org/${orgId}/label/${label.labelId}/detach`, + { siteResourceId: resource.id } + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } finally { + router.refresh(); + } + }); + } + + return ( +
+ {optimisticLabels.slice(0, 3).map((label) => ( + setIsPopoverOpen(true)} + {...label} + /> + ))} + {optimisticLabels.length > 3 && ( + + )} + + + + + + + + +
+ ); +}