mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 02:06:41 +00:00
✨ toggle column sorting & pagination
This commit is contained in:
@@ -4,7 +4,17 @@ import { remoteExitNodes } from "@server/db";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, count, eq, ilike, inArray, or, sql } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
or,
|
||||||
|
sql
|
||||||
|
} from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -88,28 +98,15 @@ const listSitesSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.catch(1)
|
.catch(1)
|
||||||
.default(1),
|
.default(1),
|
||||||
query: z.string().optional()
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc")
|
||||||
});
|
});
|
||||||
|
|
||||||
function querySites(
|
function querySitesBase() {
|
||||||
orgId: string,
|
|
||||||
accessibleSiteIds: number[],
|
|
||||||
query: string = ""
|
|
||||||
) {
|
|
||||||
let conditions = and(
|
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
conditions = and(
|
|
||||||
conditions,
|
|
||||||
or(
|
|
||||||
ilike(sites.name, "%" + query + "%"),
|
|
||||||
ilike(sites.niceId, "%" + query + "%")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -136,11 +133,10 @@ function querySites(
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
remoteExitNodes,
|
remoteExitNodes,
|
||||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
)
|
);
|
||||||
.where(conditions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,7 +172,7 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { pageSize, page, query } = parsedQuery.data;
|
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -220,7 +216,7 @@ export async function listSites(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
const baseQuery = querySites(orgId, accessibleSiteIds, query);
|
const baseQuery = querySitesBase();
|
||||||
|
|
||||||
let conditions = and(
|
let conditions = and(
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
@@ -241,23 +237,30 @@ export async function listSites(
|
|||||||
.from(sites)
|
.from(sites)
|
||||||
.where(conditions);
|
.where(conditions);
|
||||||
|
|
||||||
const sitesList = await baseQuery
|
const siteListQuery = baseQuery
|
||||||
|
.where(conditions)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(pageSize * (page - 1));
|
.offset(pageSize * (page - 1));
|
||||||
|
|
||||||
|
if (sort_by) {
|
||||||
|
siteListQuery.orderBy(
|
||||||
|
order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by])
|
||||||
|
);
|
||||||
|
}
|
||||||
const totalCountResult = await countQuery;
|
const totalCountResult = await countQuery;
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = (
|
||||||
(site) => {
|
await siteListQuery
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
).map((site) => {
|
||||||
// Initially set to false, will be updated if version check succeeds
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
// Initially set to false, will be updated if version check succeeds
|
||||||
return siteWithUpdate;
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
}
|
return siteWithUpdate;
|
||||||
);
|
});
|
||||||
|
|
||||||
// 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
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ import {
|
|||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useSortColumn } from "@app/hooks/useSortColumn";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { parseDataSize } from "@app/lib/dataSize";
|
import { parseDataSize } from "@app/lib/dataSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { Column, type PaginationState } from "@tanstack/react-table";
|
import { type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUp10Icon,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -71,6 +74,8 @@ export default function SitesTable({
|
|||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [getSortDirection, toggleSorting] = useSortColumn();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -102,22 +107,15 @@ export default function SitesTable({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dataInOrder = getSortDirection("megabytesIn");
|
||||||
|
const dataOutOrder = getSortDirection("megabytesOut");
|
||||||
|
|
||||||
const columns: ExtendedColumnDef<SiteRow>[] = [
|
const columns: ExtendedColumnDef<SiteRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("name")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,18 +123,8 @@ export default function SitesTable({
|
|||||||
accessorKey: "nice",
|
accessorKey: "nice",
|
||||||
friendlyName: t("identifier"),
|
friendlyName: t("identifier"),
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("identifier")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <span>{row.original.nice || "-"}</span>;
|
return <span>{row.original.nice || "-"}</span>;
|
||||||
@@ -145,18 +133,8 @@ export default function SitesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("online"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("online")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("online")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
@@ -187,16 +165,20 @@ export default function SitesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: t("dataIn"),
|
friendlyName: t("dataIn"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSorting("megabytesIn")}
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("dataIn")}
|
{t("dataIn")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -207,16 +189,20 @@ export default function SitesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "mbOut",
|
accessorKey: "mbOut",
|
||||||
friendlyName: t("dataOut"),
|
friendlyName: t("dataOut"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSorting("megabytesOut")}
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("dataOut")}
|
{t("dataOut")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -227,18 +213,8 @@ export default function SitesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
friendlyName: t("type"),
|
friendlyName: t("type"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("type")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("type")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
@@ -283,18 +259,8 @@ export default function SitesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "exitNode",
|
accessorKey: "exitNode",
|
||||||
friendlyName: t("exitNode"),
|
friendlyName: t("exitNode"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("exitNode")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("exitNode")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
@@ -347,18 +313,8 @@ export default function SitesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "address",
|
accessorKey: "address",
|
||||||
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
|
header: () => {
|
||||||
return (
|
return <span className="p-3">{t("address")}</span>;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Address
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }: { row: any }) => {
|
cell: ({ row }: { row: any }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
@@ -435,11 +391,6 @@ export default function SitesTable({
|
|||||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
console.log({
|
|
||||||
pagination,
|
|
||||||
rowCount
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedSite && (
|
{selectedSite && (
|
||||||
|
|||||||
56
src/hooks/useSortColumn.ts
Normal file
56
src/hooks/useSortColumn.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { SortOrder } from "@app/lib/types/sort";
|
||||||
|
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||||
|
import { startTransition } from "react";
|
||||||
|
|
||||||
|
export function useSortColumn() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const toggleSorting = (column: string) => {
|
||||||
|
const sp = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
let nextDirection: SortOrder = "indeterminate";
|
||||||
|
|
||||||
|
if (sp.get("sort_by") === column) {
|
||||||
|
nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (nextDirection) {
|
||||||
|
case "indeterminate": {
|
||||||
|
nextDirection = "asc";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "asc": {
|
||||||
|
nextDirection = "desc";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
nextDirection = "indeterminate";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sp.delete("sort_by");
|
||||||
|
sp.delete("order");
|
||||||
|
|
||||||
|
if (nextDirection !== "indeterminate") {
|
||||||
|
sp.set("sort_by", column);
|
||||||
|
sp.set("order", nextDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSortDirection(column: string) {
|
||||||
|
let currentDirection: SortOrder = "indeterminate";
|
||||||
|
|
||||||
|
if (searchParams.get("sort_by") === column) {
|
||||||
|
currentDirection =
|
||||||
|
(searchParams.get("order") as SortOrder) ?? "indeterminate";
|
||||||
|
}
|
||||||
|
return currentDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [getSortDirection, toggleSorting] as const;
|
||||||
|
}
|
||||||
1
src/lib/types/sort.ts
Normal file
1
src/lib/types/sort.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type SortOrder = "asc" | "desc" | "indeterminate";
|
||||||
Reference in New Issue
Block a user