add user and agent table filters

This commit is contained in:
miloschwartz committed 2026-11-04 09:44:11 -05:00
1 parent 52701ae8c4
commit b1a6e82fbd
8 files changed
+235 -39

No files matched your search

+1 -1
View File
@@ -575,7 +575,7 @@
"provisioningKeysUpdated": "Provisioning key updated",
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
"provisioningKeysBannerTitle": "Site Provisioning Keys",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
"provisioningKeysBannerDescription": "A single provisioning key handles site setup for you. When a site starts with the key for the first time, Pangolin creates it and issues a new ID and secret.",
"provisioningKeysBannerButtonText": "Learn More",
"pendingSitesBannerTitle": "Pending Sites",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
+49
View File
@@ -99,6 +99,34 @@ const listClientsSchema = z.strictObject({
type: "boolean",
description: "Filter by online status"
}),
agent: z
.enum([
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: [
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
],
description:
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
}),
status: z.preprocess(
(val: string | undefined) => {
if (val) {
@@ -280,6 +308,7 @@ export async function listClients(
online,
query,
status,
agent,
sort_by,
order,
labels: labelFilter,
@@ -347,6 +376,26 @@ export async function listClients(
conditions.push(eq(clients.online, online));
}
const agentValueMap = {
windows: "Pangolin Windows",
android: "Pangolin Android",
ios: "Pangolin iOS",
ipados: "Pangolin iPadOS",
macos: "Pangolin macOS",
cli: "Pangolin CLI",
olm: "Olm CLI"
} satisfies Record<
Exclude<typeof agent, undefined | "unknown">,
string
>;
if (typeof agent !== "undefined") {
if (agent === "unknown") {
conditions.push(isNull(olms.agent));
} else {
conditions.push(eq(olms.agent, agentValueMap[agent]));
}
}
if (status.length > 0) {
const filterAggregates: (SQL<unknown> | undefined)[] = [];
+19 -2
View File
@@ -123,6 +123,10 @@ const listUserDevicesSchema = z.strictObject({
description:
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
}),
userId: z.string().nonempty().optional().catch(undefined).openapi({
type: "string",
description: "Filter by user ID"
}),
status: z.preprocess(
(val: string | undefined) => {
if (val) {
@@ -249,8 +253,17 @@ export async function listUserDevices(
)
);
}
const { page, pageSize, query, sort_by, online, status, agent, order } =
parsedQuery.data;
const {
page,
pageSize,
query,
sort_by,
online,
status,
agent,
userId,
order
} = parsedQuery.data;
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -331,6 +344,10 @@ export async function listUserDevices(
conditions.push(eq(clients.online, online));
}
if (typeof userId !== "undefined") {
conditions.push(eq(clients.userId, userId));
}
const agentValueMap = {
windows: "Pangolin Windows",
android: "Pangolin Android",
+32 -1
View File
@@ -1,10 +1,14 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { ClientRow } from "@app/components/UserDevicesTable";
import UserDevicesTable from "@app/components/UserDevicesTable";
import type { SelectedUser } from "@app/components/user-selector";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { type ListUserDevicesResponse } from "@server/routers/client";
import type { GetOrgUserResponse } from "@server/routers/user";
import type { Pagination } from "@server/types/Pagination";
import type ResponseT from "@server/types/Response";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
@@ -34,12 +38,14 @@ export default async function ClientsPage(props: ClientsPageProps) {
pageSize: 20
};
const header = await authCookieHeader();
try {
const userRes = await internal.get<
AxiosResponse<ListUserDevicesResponse>
>(
`/org/${params.orgId}/user-devices?${searchParams.toString()}`,
await authCookieHeader()
header
);
const responseData = userRes.data.data;
userClients = responseData.devices;
@@ -113,6 +119,30 @@ export default async function ClientsPage(props: ClientsPageProps) {
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
const userIdParam = searchParams.get("userId");
let initialFilterUser: SelectedUser | null = null;
if (userIdParam) {
try {
const userRes = await internal.get(
`/org/${params.orgId}/user/${encodeURIComponent(userIdParam)}`,
header
);
const user = (userRes.data as ResponseT<GetOrgUserResponse>).data;
if (user) {
initialFilterUser = {
id: userIdParam,
text: getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
};
}
} catch {
// leave null so the table falls back to the id
}
}
return (
<>
<SettingsSectionTitle
@@ -128,6 +158,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterUser={initialFilterUser}
/>
</>
);
+1 -1
View File
@@ -5,7 +5,7 @@
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.6rem;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
+2 -2
View File
@@ -23,7 +23,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
description={t("machineClientsBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#pangolin-cli-linux"
href="https://docs.pangolin.net/manage/clients/platforms/cli"
target="_blank"
rel="noopener noreferrer"
>
@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
</Button>
</Link>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
href="https://docs.pangolin.net/manage/clients/platforms/cli#docker-pangolin-cli"
target="_blank"
rel="noopener noreferrer"
>
+46 -1
View File
@@ -384,7 +384,52 @@ export default function MachineClientsTable({
{
accessorKey: "client",
friendlyName: t("agent"),
header: () => <span className="px-3">{t("agent")}</span>,
header: () => (
<ColumnFilterButton
options={[
{
value: "macos",
label: "Pangolin macOS"
},
{
value: "ios",
label: "Pangolin iOS"
},
{
value: "ipados",
label: "Pangolin iPadOS"
},
{
value: "android",
label: "Pangolin Android"
},
{
value: "windows",
label: "Pangolin Windows"
},
{
value: "cli",
label: "Pangolin CLI"
},
{
value: "olm",
label: "Olm CLI"
},
{
value: "unknown",
label: t("unknown")
}
]}
selectedValue={searchParams.get("agent") ?? undefined}
onValueChange={(value) =>
handleFilterChange("agent", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("agent")}
className="p-3"
/>
),
cell: ({ row }) => {
const originalRow = row.original;
+85 -31
View File
@@ -37,8 +37,10 @@ import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
import { useDebouncedCallback } from "use-debounce";
import ClientDownloadBanner from "./ClientDownloadBanner";
import { ColumnEntityFilter } from "./ColumnEntityFilter";
import { ColumnFilterButton } from "./ColumnFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import { UserSelector, type SelectedUser } from "./user-selector";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
import {
@@ -124,12 +126,15 @@ type ClientTableProps = {
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterUser?: SelectedUser | null;
};
export default function UserDevicesTable({
userClients,
orgId,
pagination,
rowCount
rowCount,
initialFilterUser = null
}: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
@@ -138,6 +143,7 @@ export default function UserDevicesTable({
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [userFilterOpen, setUserFilterOpen] = useState(false);
const api = createApiClient(useEnvContext());
const {
@@ -150,6 +156,17 @@ export default function UserDevicesTable({
const latestPlatformVersions = data.data?.data;
const userIdQ = searchParams.get("userId");
const selectedUser: SelectedUser | null = useMemo(() => {
if (!userIdQ) {
return null;
}
if (initialFilterUser && initialFilterUser.id === userIdQ) {
return initialFilterUser;
}
return { id: userIdQ, text: userIdQ };
}, [initialFilterUser, userIdQ]);
const defaultUserColumnVisibility = {
subnet: false,
niceId: false,
@@ -360,6 +377,45 @@ export default function UserDevicesTable({
return userClients.some((client) => !client.userId);
}, [userClients]);
function handleFilterChange(
column: string,
value: string | null | undefined | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
for (const val of value) {
searchParams.append(column, val);
}
}
filter({
searchParams
});
}
function selectUser(user: SelectedUser | null) {
searchParams.delete("page");
if (user?.id) {
searchParams.set("userId", user.id);
} else {
searchParams.delete("userId");
}
filter({ searchParams });
setUserFilterOpen(false);
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
@@ -410,7 +466,25 @@ export default function UserDevicesTable({
{
accessorKey: "userEmail",
friendlyName: t("users"),
header: () => <span className="px-3">{t("users")}</span>,
header: () => (
<ColumnEntityFilter
label={t("users")}
open={userFilterOpen}
onOpenChange={setUserFilterOpen}
selectedName={selectedUser?.text}
clearLabel={t("resourceSessionsFilterAnyUser")}
onClear={() => {
selectUser(null);
}}
>
<UserSelector
orgId={orgId}
selectedUser={selectedUser}
allowClear={false}
onSelectUser={selectUser}
/>
</ColumnEntityFilter>
),
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
@@ -818,7 +892,15 @@ export default function UserDevicesTable({
});
return baseColumns;
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
}, [
hasRowsWithoutUserId,
t,
getSortDirection,
toggleSort,
orgId,
userFilterOpen,
selectedUser
]);
const statusFilterOptions = useMemo(() => {
const allOptions = [
@@ -859,34 +941,6 @@ export default function UserDevicesTable({
return allOptions;
}, [t, latestPlatformVersions]);
function handleFilterChange(
column: string,
value: string | null | undefined | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
for (const val of value) {
searchParams.append(column, val);
}
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());