From e61ef2ca2a03a80d8e426a2db85cee1746a3cb19 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 20:06:42 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20label=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 + server/routers/site/listSites.ts | 45 ++++++++++- src/components/SitesTable.tsx | 46 ++++++++++- src/components/labels-selector.tsx | 126 +++++++++++++++++++++++++++++ src/lib/queries.ts | 28 +++++++ 5 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/components/labels-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 871906841..2a6d1ee40 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1128,6 +1128,9 @@ "addLabels": "Add labels", "siteLabelsTab": "Labels", "siteLabelsDescription": "Manage labels associated with this site.", + "labelsNotFound": "Labels not found", + "labelSearch": "Search labels", + "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index fc4ea5be1..8c35a2521 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -9,7 +9,10 @@ import { siteResources, targets, sites, - userSites + userSites, + labels, + siteLabels, + type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; import response from "@server/lib/response"; @@ -23,6 +26,8 @@ import createHttpError from "http-errors"; import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; // Stale-while-revalidate: keeps the last successfully fetched version so that // a transient network failure / timeout does not flip every site back to @@ -233,6 +238,7 @@ type SiteRowBase = Awaited>[0]; type SiteWithUpdateAvailable = Omit & { online?: SiteRowBase["online"]; // undefined for local sites newtUpdateAvailable?: boolean; + labels?: Array>; }; export type ListSitesResponse = PaginatedResponse<{ @@ -367,11 +373,46 @@ export async function listSites( // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); + const siteIds = rows.map((site) => site.siteId); + + let labelsForSites: Array<{ + labelId: number; + name: string; + color: string; + siteId: number; + }> = []; + + // The label feature should be added in the tiers + // if (await isLicensedOrSubscribed(orgId, tierMatrix.fullRbac)) { + // } + labelsForSites = + siteIds.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.name, + siteId: siteLabels.siteId + }) + .from(labels) + .innerJoin( + siteLabels, + eq(siteLabels.labelId, labels.labelId) + ) + .where(inArray(siteLabels.siteId, siteIds)); + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; // Initially set to false, will be updated if version check succeeds siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; + + // associate labels + const labelsForSite = labelsForSites.filter( + (label) => label.siteId === site.siteId + ); + + return { ...siteWithUpdate, labels: labelsForSite }; }); // Try to get the latest version, but don't block if it fails diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d53a6fdc0..a50bc8b20 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -50,6 +50,9 @@ import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { LabelsSelector } from "./labels-selector"; export type SiteRow = { id: number; @@ -67,6 +70,11 @@ export type SiteRow = { exitNodeEndpoint?: string; remoteExitNodeId?: string; resourceCount: number; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; type SitesTableProps = { @@ -368,7 +376,7 @@ export default function SitesTable({ variant="ghost" size="sm" onClick={() => setResourcesDialogSite(siteRow)} - className="flex h-8 items-center gap-2 px-0 font-normal" + className="flex h-8 items-center gap-2 px-2 font-normal" > {siteRow.resourceCount} {t("resources")} @@ -450,11 +458,41 @@ export default function SitesTable({ ); } }, + // The label feature should be added to the tiers { accessorKey: "labels", header: () => {t("labels")}, cell: ({ row }) => { - return <>; + const labels = row.original.labels ?? []; + return ( +
+ + + + + + {}} + /> + + +
+ ); } }, { @@ -616,11 +654,11 @@ export default function SitesTable({ title={t("siteDelete")} /> - + /> */} )} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx new file mode 100644 index 000000000..988a9e02b --- /dev/null +++ b/src/components/labels-selector.tsx @@ -0,0 +1,126 @@ +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, SiteOnlineStatus } from "./site-selector"; + +type SelectedLabel = { + name: string; + color: string; + labelId: number; +}; + +export type LabelsSelectorProps = { + orgId: string; + selectedLabels: SelectedLabel[]; + onSelectionChange: (sites: SelectedLabel[]) => void; +}; + +export function LabelsSelector({ + orgId, + selectedLabels, + onSelectionChange +}: LabelsSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 10 + }) + ); + + const labelsShown = useMemo(() => { + const base = [...labels]; + if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) { + const selectedNotInBase = selectedLabels.filter( + (sel) => !base.some((s) => s.labelId === sel.labelId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, labels, selectedLabels]); + + const selectedIds = useMemo( + () => new Set(selectedLabels.map((s) => s.labelId)), + [selectedLabels] + ); + + return ( + + + + + {labelSearchQuery.trim().length > 0 ? ( + <> + {t("createNewLabel", { + label: labelSearchQuery.trim() + })} + + ) : ( + t("labelsNotFound") + )} + + + {labelsShown.map((label) => ( + { + if (selectedIds.has(label.labelId)) { + onSelectionChange( + selectedLabels.filter( + (l) => l.labelId !== label.labelId + ) + ); + } else { + onSelectionChange([ + ...selectedLabels, + label + ]); + } + }} + > + {}} + aria-hidden + tabIndex={-1} + /> +
+ + + {label.name} + +
+
+ ))} +
+
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e58a5d471..d317323c3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -33,6 +33,7 @@ import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; export type ProductUpdate = { link: string | null; @@ -208,6 +209,33 @@ export const orgQueries = { } }), + labels: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "LABELS", { query, perPage }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/labels?${sp.toString()}`, { signal }); + return res.data.data.labels; + } + }), + domains: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const,