mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-21 08:15:17 +00:00
🚧 wip: label selector
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/> */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
126
src/components/labels-selector.tsx
Normal file
126
src/components/labels-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user