mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 12:42:22 +00:00
more ui/ux enhancements around labels and tables
This commit is contained in:
@@ -27,20 +27,18 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={userDisplayName ? 3 : 2}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
{userDisplayName ? (
|
||||||
<InfoSectionTitle>
|
<InfoSection>
|
||||||
{userDisplayName ? t("user") : t("identifier")}
|
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
|
||||||
</InfoSectionTitle>
|
<InfoSectionContent>
|
||||||
<InfoSectionContent>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span>{userDisplayName}</span>
|
||||||
<span>{userDisplayName || client.niceId}</span>
|
{(client.userType ?? "internal") !==
|
||||||
{userDisplayName &&
|
|
||||||
(client.userType ?? "internal") !==
|
|
||||||
"internal" && (
|
"internal" && (
|
||||||
<IdpTypeBadge
|
<IdpTypeBadge
|
||||||
type={client.userType ?? "oidc"}
|
type={client.userType ?? "oidc"}
|
||||||
@@ -54,9 +52,10 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Funnel,
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -62,10 +60,10 @@ import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertI
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -164,12 +162,12 @@ export default function ClientResourcesTable({
|
|||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const siteIdQ = searchParams.get("siteId");
|
const siteIdQ = searchParams.get("siteId");
|
||||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
@@ -700,27 +698,28 @@ function ClientResourceLabelCell({
|
|||||||
}: ClientResourceLabelCellProps) {
|
}: ClientResourceLabelCellProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||||
const router = useRouter();
|
resource.labels,
|
||||||
|
resource.id
|
||||||
const labels = resource.labels ?? [];
|
);
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleResourceLabel(
|
function toggleResourceLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ siteResourceId: resource.id }
|
{ siteResourceId: resource.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -730,54 +729,21 @@ function ClientResourceLabelCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLabels = optimisticLabels.slice(0, 3);
|
|
||||||
const overflowLabels = optimisticLabels.slice(3);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
<TableLabelsCell
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
orgId={orgId}
|
||||||
<PopoverTrigger asChild>
|
localLabels={localLabels}
|
||||||
<Button
|
toggleLabel={toggleResourceLabel}
|
||||||
size="icon"
|
/>
|
||||||
variant="outline"
|
|
||||||
className="size-auto shrink-0 rounded-full p-1"
|
|
||||||
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={optimisticLabels}
|
|
||||||
toggleLabel={toggleResourceLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{visibleLabels.map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<LabelOverflowBadge
|
|
||||||
labels={overflowLabels}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function ExitNodesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -131,7 +131,7 @@ export default function ExitNodesTable({
|
|||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("online")}
|
{t("status")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,12 +163,12 @@ export default function HealthChecksTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const handlePaginationChange = (newState: PaginationState) => {
|
const handlePaginationChange = (newState: PaginationState) => {
|
||||||
searchParams.set("page", (newState.pageIndex + 1).toString());
|
searchParams.set("page", (newState.pageIndex + 1).toString());
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
CircleSlash,
|
CircleSlash,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -33,20 +32,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
startTransition,
|
startTransition,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -277,7 +274,7 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
@@ -299,7 +296,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("online")}
|
label={t("status")}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -617,27 +614,25 @@ function MachineClientLabelCell({
|
|||||||
}: MachineClientLabelCellProps) {
|
}: MachineClientLabelCellProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(client.labels, client.id);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = client.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleClientLabel(
|
function toggleClientLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ clientId: client.id }
|
{ clientId: client.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -647,54 +642,21 @@ function MachineClientLabelCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLabels = optimisticLabels.slice(0, 3);
|
|
||||||
const overflowLabels = optimisticLabels.slice(3);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
<TableLabelsCell
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
orgId={orgId}
|
||||||
<PopoverTrigger asChild>
|
localLabels={localLabels}
|
||||||
<Button
|
toggleLabel={toggleClientLabel}
|
||||||
size="icon"
|
/>
|
||||||
variant="outline"
|
|
||||||
className="size-auto shrink-0 rounded-full p-1"
|
|
||||||
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={optimisticLabels}
|
|
||||||
toggleLabel={toggleClientLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{visibleLabels.map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<LabelOverflowBadge
|
|
||||||
labels={overflowLabels}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ import z from "zod";
|
|||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import UptimeMiniBar from "./UptimeMiniBar";
|
import UptimeMiniBar from "./UptimeMiniBar";
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -171,12 +171,12 @@ export default function ProxyResourcesTable({
|
|||||||
};
|
};
|
||||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -766,29 +766,29 @@ function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||||
|
resource.labels,
|
||||||
const router = useRouter();
|
resource.id
|
||||||
|
);
|
||||||
const labels = resource.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
function toggleSiteLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
|
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ resourceId: resource.id }
|
{ resourceId: resource.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -798,55 +798,22 @@ function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLabels = optimisticLabels.slice(0, 3);
|
|
||||||
const overflowLabels = optimisticLabels.slice(3);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
<TableLabelsCell
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
orgId={orgId}
|
||||||
<PopoverTrigger asChild>
|
localLabels={localLabels}
|
||||||
<Button
|
toggleLabel={toggleSiteLabel}
|
||||||
size="icon"
|
/>
|
||||||
variant="outline"
|
|
||||||
className="size-auto shrink-0 rounded-full p-1"
|
|
||||||
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={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{visibleLabels.map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<LabelOverflowBadge
|
|
||||||
labels={overflowLabels}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{/* 4 cols because of the certs */}
|
{/* 4 cols because of the certs */}
|
||||||
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
|
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}>
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<span className="inline-flex items-center">
|
|
||||||
{resource.niceId}
|
|
||||||
</span>
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={site.endpoint ? 5 : 4}>
|
<InfoSections cols={site.endpoint ? 4 : 3}>
|
||||||
{identifierSection}
|
|
||||||
{statusSection}
|
{statusSection}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -46,7 +45,6 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
|
||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -61,11 +59,10 @@ import {
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { LabelBadge } from "./label-badge";
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -124,12 +121,12 @@ export default function SitesTable({
|
|||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -225,7 +222,7 @@ export default function SitesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
@@ -241,7 +238,7 @@ export default function SitesTable({
|
|||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("online")}
|
label={t("status")}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -693,29 +690,26 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = site.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleSiteLabel(
|
function toggleSiteLabel(
|
||||||
label: SelectedLabel,
|
label: SelectedLabel,
|
||||||
action: "attach" | "detach"
|
action: "attach" | "detach"
|
||||||
) {
|
) {
|
||||||
startTransition(async () => {
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (action === "attach") {
|
if (action === "attach") {
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
setLocalLabels([...previousLabels, label]);
|
||||||
|
|
||||||
await api.put(
|
await api.put(
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
{ siteId: site.id }
|
{ siteId: site.id }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setOptimisticLabels(
|
setLocalLabels(
|
||||||
optimisticLabels.filter(
|
previousLabels.filter(
|
||||||
(lb) => lb.labelId !== label.labelId
|
(lb) => lb.labelId !== label.labelId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -725,54 +719,21 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLabels = optimisticLabels.slice(0, 3);
|
|
||||||
const overflowLabels = optimisticLabels.slice(3);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
|
<TableLabelsCell
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
orgId={orgId}
|
||||||
<PopoverTrigger asChild>
|
localLabels={localLabels}
|
||||||
<Button
|
toggleLabel={toggleSiteLabel}
|
||||||
size="icon"
|
/>
|
||||||
variant="outline"
|
|
||||||
className="size-auto shrink-0 rounded-full p-1"
|
|
||||||
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={optimisticLabels}
|
|
||||||
toggleLabel={toggleSiteLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{visibleLabels.map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<LabelOverflowBadge
|
|
||||||
labels={overflowLabels}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
97
src/components/TableLabelsCell.tsx
Normal file
97
src/components/TableLabelsCell.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
import type { Measurable } from "@radix-ui/rect";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { LabelBadge } from "./label-badge";
|
||||||
|
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||||
|
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "./ui/popover";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_LABELS = 3;
|
||||||
|
|
||||||
|
type TableLabelsCellProps = {
|
||||||
|
orgId: string;
|
||||||
|
localLabels: SelectedLabel[];
|
||||||
|
toggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TableLabelsCell({
|
||||||
|
orgId,
|
||||||
|
localLabels,
|
||||||
|
toggleLabel
|
||||||
|
}: TableLabelsCellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const frozenAnchorRef = useRef<Measurable>({
|
||||||
|
getBoundingClientRect: () => new DOMRect()
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleLabels = localLabels.slice(0, MAX_VISIBLE_LABELS);
|
||||||
|
const overflowLabels = localLabels.slice(MAX_VISIBLE_LABELS);
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (open && triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
frozenAnchorRef.current = {
|
||||||
|
getBoundingClientRect: () => rect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-1">
|
||||||
|
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="size-auto shrink-0 rounded-full p-1"
|
||||||
|
title={t("addLabels")}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t("addLabels")}</span>
|
||||||
|
<PlusIcon className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
className={`${dataTableFilterPopoverContentClassName} p-0`}
|
||||||
|
updatePositionStrategy="optimized"
|
||||||
|
>
|
||||||
|
<LabelsSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedLabels={localLabels}
|
||||||
|
toggleLabel={toggleLabel}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<div className="flex min-w-0 flex-nowrap items-center justify-start gap-1 overflow-hidden">
|
||||||
|
{visibleLabels.map((label) => (
|
||||||
|
<LabelBadge
|
||||||
|
key={label.labelId}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => handleOpenChange(true)}
|
||||||
|
{...label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LabelOverflowBadge
|
||||||
|
labels={overflowLabels}
|
||||||
|
onClick={() => handleOpenChange(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -405,7 +405,7 @@ export default function UserDevicesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("connected"),
|
friendlyName: t("status"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColumnFilterButton
|
<ColumnFilterButton
|
||||||
@@ -427,7 +427,7 @@ export default function UserDevicesTable({
|
|||||||
}
|
}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage={t("emptySearchOptions")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
label={t("connected")}
|
label={t("status")}
|
||||||
className="p-3"
|
className="p-3"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
21
src/hooks/useLocalLabels.ts
Normal file
21
src/hooks/useLocalLabels.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SelectedLabel } from "@app/components/labels-selector";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useLocalLabels(
|
||||||
|
serverLabels: SelectedLabel[] | undefined,
|
||||||
|
entityId: number
|
||||||
|
) {
|
||||||
|
const labels = serverLabels ?? [];
|
||||||
|
const [localLabels, setLocalLabels] = useState(labels);
|
||||||
|
|
||||||
|
const serverLabelIds = labels
|
||||||
|
.map((label) => label.labelId)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalLabels(serverLabels ?? []);
|
||||||
|
}, [entityId, serverLabelIds]);
|
||||||
|
|
||||||
|
return [localLabels, setLocalLabels] as const;
|
||||||
|
}
|
||||||
@@ -759,7 +759,13 @@ export const logQueries = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
access: ({ orgId, filters }: { orgId: string; filters: AccessLogFilters }) =>
|
access: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: AccessLogFilters;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user