mirror of
https://github.com/fosrl/pangolin.git
synced 2026-11-06 05:16:36 +00:00
add user and agent table filters
This commit is contained in:
8 files changed
+235
-39
No files matched your search
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user