🚧 wip: label selector

This commit is contained in:
Fred KISSIE
2026-05-08 20:06:42 +02:00
parent 39b09b7f3f
commit e61ef2ca2a
5 changed files with 242 additions and 6 deletions

View File

@@ -1128,6 +1128,9 @@
"addLabels": "Add labels", "addLabels": "Add labels",
"siteLabelsTab": "Labels", "siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.", "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.", "inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user", "inviteErrorWrongUser": "Invite is not for this user",
"inviteErrorUserNotExists": "User does not exist. Please create an account first.", "inviteErrorUserNotExists": "User does not exist. Please create an account first.",

View File

@@ -9,7 +9,10 @@ import {
siteResources, siteResources,
targets, targets,
sites, sites,
userSites userSites,
labels,
siteLabels,
type Label
} from "@server/db"; } from "@server/db";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -23,6 +26,8 @@ import createHttpError from "http-errors";
import semver from "semver"; import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; 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 // Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to // a transient network failure / timeout does not flip every site back to
@@ -233,6 +238,7 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & { type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
}; };
export type ListSitesResponse = PaginatedResponse<{ export type ListSitesResponse = PaginatedResponse<{
@@ -367,11 +373,46 @@ export async function listSites(
// Get latest version asynchronously without blocking the response // Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion(); 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 sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds // Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false; 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 // Try to get the latest version, but don't block if it fails

View File

@@ -50,6 +50,9 @@ import {
ControlledDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
} from "./ui/controlled-data-table"; } 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 = { export type SiteRow = {
id: number; id: number;
@@ -67,6 +70,11 @@ export type SiteRow = {
exitNodeEndpoint?: string; exitNodeEndpoint?: string;
remoteExitNodeId?: string; remoteExitNodeId?: string;
resourceCount: number; resourceCount: number;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
}; };
type SitesTableProps = { type SitesTableProps = {
@@ -368,7 +376,7 @@ export default function SitesTable({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setResourcesDialogSite(siteRow)} 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"
> >
<span className="text-sm tabular-nums"> <span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")} {siteRow.resourceCount} {t("resources")}
@@ -450,11 +458,41 @@ export default function SitesTable({
); );
} }
}, },
// The label feature should be added to the tiers
{ {
accessorKey: "labels", accessorKey: "labels",
header: () => <span className="p-3">{t("labels")}</span>, header: () => <span className="p-3">{t("labels")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
return <></>; const labels = row.original.labels ?? [];
return (
<div className="inline-flex flex-wrap items-center justify-end w-full">
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">
{t("addLabels")}
</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
className="p-0 w-full"
>
<LabelsSelector
orgId={orgId}
selectedLabels={[]}
onSelectionChange={() => {}}
/>
</PopoverContent>
</Popover>
</div>
);
} }
}, },
{ {
@@ -616,11 +654,11 @@ export default function SitesTable({
title={t("siteDelete")} title={t("siteDelete")}
/> />
<SiteLabelsDialog {/* <SiteLabelsDialog
isOpen={isLabelsDialogOpen} isOpen={isLabelsDialogOpen}
setIsOpen={setIsLabelsDialogOpen} setIsOpen={setIsLabelsDialogOpen}
site={selectedSite} site={selectedSite}
/> /> */}
</> </>
)} )}

View File

@@ -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 (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty className="px-3 break-all max-w-full wrap-anywhere text-wrap">
{labelSearchQuery.trim().length > 0 ? (
<>
{t("createNewLabel", {
label: labelSearchQuery.trim()
})}
</>
) : (
t("labelsNotFound")
)}
</CommandEmpty>
<CommandGroup>
{labelsShown.map((label) => (
<CommandItem
key={label.labelId}
value={`${label.labelId}`}
onSelect={() => {
if (selectedIds.has(label.labelId)) {
onSelectionChange(
selectedLabels.filter(
(l) => l.labelId !== label.labelId
)
);
} else {
onSelectionChange([
...selectedLabels,
label
]);
}
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(label.labelId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span
className="inline-block p-1 rounded-full bg-(--label-color)"
style={{
// @ts-expect-error CSS variable
"--label-color": label.color
}}
/>
<span className="min-w-0 flex-1 truncate">
{label.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -33,6 +33,7 @@ import { remote } from "./api";
import { durationToMs } from "./durationToMs"; import { durationToMs } from "./durationToMs";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory"; import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; 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<ListOrgLabelsResponse>
>(`/org/${orgId}/labels?${sp.toString()}`, { signal });
return res.data.data.labels;
}
}),
domains: ({ orgId }: { orgId: string }) => domains: ({ orgId }: { orgId: string }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "DOMAINS"] as const, queryKey: ["ORG", orgId, "DOMAINS"] as const,