mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-19 12:06:28 +02:00
Merge branch 'refactor/paginated-tables' into feat/resource-policies
This commit is contained in:
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
|||||||
+4
-1
@@ -461,6 +461,8 @@
|
|||||||
"filterByApprovalState": "Filter By Approval State",
|
"filterByApprovalState": "Filter By Approval State",
|
||||||
"approvalListEmpty": "No approvals",
|
"approvalListEmpty": "No approvals",
|
||||||
"approvalState": "Approval State",
|
"approvalState": "Approval State",
|
||||||
|
"approvalLoadMore": "Load more",
|
||||||
|
"loadingApprovals": "Loading Approvals",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
"denied": "Denied",
|
"denied": "Denied",
|
||||||
@@ -1169,7 +1171,8 @@
|
|||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"searchProgress": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
|
"emptySearchOptions": "No options found",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"orgs": "Organizations",
|
"orgs": "Organizations",
|
||||||
"loginError": "An unexpected error occurred. Please try again.",
|
"loginError": "An unexpected error occurred. Please try again.",
|
||||||
|
|||||||
Generated
+13
-1
@@ -97,6 +97,7 @@
|
|||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
|
"use-debounce": "^10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
@@ -13918,7 +13919,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -21122,6 +21122,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-intl": {
|
"node_modules/use-intl": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -120,6 +120,7 @@
|
|||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
|
"use-debounce": "^10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
@@ -144,6 +145,7 @@
|
|||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "24.10.2",
|
"@types/node": "24.10.2",
|
||||||
"@types/nodemailer": "7.0.4",
|
"@types/nodemailer": "7.0.4",
|
||||||
@@ -156,7 +158,6 @@
|
|||||||
"@types/topojson-client": "3.1.5",
|
"@types/topojson-client": "3.1.5",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"@types/js-yaml": "4.0.9",
|
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.8",
|
||||||
"esbuild": "0.27.2",
|
"esbuild": "0.27.2",
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||||
hcMethod: varchar("hcMethod").default("GET"),
|
hcMethod: varchar("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +222,7 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: varchar("protocol"), // only for port mode
|
protocol: varchar("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
|
|||||||
@@ -216,7 +216,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
}).default(true),
|
}).default(true),
|
||||||
hcMethod: text("hcMethod").default("GET"),
|
hcMethod: text("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: text("protocol"), // only for port mode
|
protocol: text("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { approvals, db, type Approval } from "@server/db";
|
import { approvals, db, type Approval } from "@server/db";
|
||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -88,7 +88,7 @@ export async function countApprovals(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(approvals.orgId, orgId),
|
eq(approvals.orgId, orgId),
|
||||||
sql`${approvals.decision} in ${state}`
|
inArray(approvals.decision, state)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
type Approval
|
type Approval
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.strictObject({
|
||||||
limit: z
|
limit: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().nonnegative()),
|
cursorPending: z.coerce // pending cursor
|
||||||
offset: z
|
.number<string>()
|
||||||
.string()
|
.int()
|
||||||
|
.max(1) // 0 means non pending
|
||||||
|
.min(0) // 1 means pending
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(undefined),
|
||||||
.transform(Number)
|
cursorTimestamp: z.coerce
|
||||||
.pipe(z.int().nonnegative()),
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
approvalState: z
|
approvalState: z
|
||||||
.enum(["pending", "approved", "denied", "all"])
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
|
|||||||
.pipe(z.number().int().positive().optional())
|
.pipe(z.number().int().positive().optional())
|
||||||
});
|
});
|
||||||
|
|
||||||
async function queryApprovals(
|
async function queryApprovals({
|
||||||
orgId: string,
|
orgId,
|
||||||
limit: number,
|
limit,
|
||||||
offset: number,
|
approvalState,
|
||||||
approvalState: z.infer<typeof querySchema>["approvalState"],
|
cursorPending,
|
||||||
clientId?: number
|
cursorTimestamp,
|
||||||
) {
|
clientId
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
limit: number;
|
||||||
|
approvalState: z.infer<typeof querySchema>["approvalState"];
|
||||||
|
cursorPending?: number;
|
||||||
|
cursorTimestamp?: number;
|
||||||
|
clientId?: number;
|
||||||
|
}) {
|
||||||
let state: Array<Approval["decision"]> = [];
|
let state: Array<Approval["decision"]> = [];
|
||||||
switch (approvalState) {
|
switch (approvalState) {
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -83,6 +99,26 @@ async function queryApprovals(
|
|||||||
state = ["approved", "denied", "pending"];
|
state = ["approved", "denied", "pending"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
eq(approvals.orgId, orgId),
|
||||||
|
sql`${approvals.decision} in ${state}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
conditions.push(eq(approvals.clientId, clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
|
||||||
|
|
||||||
|
if (cursorPending != null && cursorTimestamp != null) {
|
||||||
|
// https://stackoverflow.com/a/79720298/10322846
|
||||||
|
// composite cursor, next data means (pending, timestamp) <= cursor
|
||||||
|
conditions.push(
|
||||||
|
lte(pendingSortKey, cursorPending),
|
||||||
|
lte(approvals.timestamp, cursorTimestamp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.select({
|
.select({
|
||||||
approvalId: approvals.approvalId,
|
approvalId: approvals.approvalId,
|
||||||
@@ -105,7 +141,8 @@ async function queryApprovals(
|
|||||||
fingerprintArch: currentFingerprint.arch,
|
fingerprintArch: currentFingerprint.arch,
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
fingerprintUsername: currentFingerprint.username,
|
fingerprintUsername: currentFingerprint.username,
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
fingerprintHostname: currentFingerprint.hostname,
|
||||||
|
timestamp: approvals.timestamp
|
||||||
})
|
})
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
||||||
@@ -118,22 +155,12 @@ async function queryApprovals(
|
|||||||
)
|
)
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||||
.where(
|
.where(and(...conditions))
|
||||||
and(
|
.orderBy(desc(pendingSortKey), desc(approvals.timestamp))
|
||||||
eq(approvals.orgId, orgId),
|
.limit(limit + 1); // the `+1` is used for the cursor
|
||||||
sql`${approvals.decision} in ${state}`,
|
|
||||||
...(clientId ? [eq(approvals.clientId, clientId)] : [])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(
|
|
||||||
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
|
|
||||||
desc(approvals.timestamp)
|
|
||||||
)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
// Process results to format device names and build fingerprint objects
|
// Process results to format device names and build fingerprint objects
|
||||||
return res.map((approval) => {
|
const approvalsList = res.slice(0, limit).map((approval) => {
|
||||||
const model = approval.deviceModel || null;
|
const model = approval.deviceModel || null;
|
||||||
const deviceName = approval.clientName
|
const deviceName = approval.clientName
|
||||||
? getUserDeviceName(model, approval.clientName)
|
? getUserDeviceName(model, approval.clientName)
|
||||||
@@ -152,15 +179,15 @@ async function queryApprovals(
|
|||||||
|
|
||||||
const fingerprint = hasFingerprintData
|
const fingerprint = hasFingerprintData
|
||||||
? {
|
? {
|
||||||
platform: approval.fingerprintPlatform || null,
|
platform: approval.fingerprintPlatform ?? null,
|
||||||
osVersion: approval.fingerprintOsVersion || null,
|
osVersion: approval.fingerprintOsVersion ?? null,
|
||||||
kernelVersion: approval.fingerprintKernelVersion || null,
|
kernelVersion: approval.fingerprintKernelVersion ?? null,
|
||||||
arch: approval.fingerprintArch || null,
|
arch: approval.fingerprintArch ?? null,
|
||||||
deviceModel: approval.deviceModel || null,
|
deviceModel: approval.deviceModel ?? null,
|
||||||
serialNumber: approval.fingerprintSerialNumber || null,
|
serialNumber: approval.fingerprintSerialNumber ?? null,
|
||||||
username: approval.fingerprintUsername || null,
|
username: approval.fingerprintUsername ?? null,
|
||||||
hostname: approval.fingerprintHostname || null
|
hostname: approval.fingerprintHostname ?? null
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -183,11 +210,30 @@ async function queryApprovals(
|
|||||||
niceId: approval.niceId || null
|
niceId: approval.niceId || null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
let nextCursorPending: number | null = null;
|
||||||
|
let nextCursorTimestamp: number | null = null;
|
||||||
|
if (res.length > limit) {
|
||||||
|
const lastItem = res[limit];
|
||||||
|
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
|
||||||
|
nextCursorTimestamp = lastItem.timestamp;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
approvalsList,
|
||||||
|
nextCursorPending,
|
||||||
|
nextCursorTimestamp
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListApprovalsResponse = {
|
export type ListApprovalsResponse = {
|
||||||
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>;
|
approvals: NonNullable<
|
||||||
pagination: { total: number; limit: number; offset: number };
|
Awaited<ReturnType<typeof queryApprovals>>
|
||||||
|
>["approvalsList"];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
cursorPending: number | null;
|
||||||
|
cursorTimestamp: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listApprovals(
|
export async function listApprovals(
|
||||||
@@ -215,17 +261,25 @@ export async function listApprovals(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, approvalState, clientId } = parsedQuery.data;
|
const {
|
||||||
|
limit,
|
||||||
|
cursorPending,
|
||||||
|
cursorTimestamp,
|
||||||
|
approvalState,
|
||||||
|
clientId
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
const approvalsList = await queryApprovals(
|
const { approvalsList, nextCursorPending, nextCursorTimestamp } =
|
||||||
orgId.toString(),
|
await queryApprovals({
|
||||||
limit,
|
orgId: orgId.toString(),
|
||||||
offset,
|
limit,
|
||||||
approvalState,
|
cursorPending,
|
||||||
clientId
|
cursorTimestamp,
|
||||||
);
|
approvalState,
|
||||||
|
clientId
|
||||||
|
});
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -237,7 +291,8 @@ export async function listApprovals(
|
|||||||
pagination: {
|
pagination: {
|
||||||
total: count,
|
total: count,
|
||||||
limit,
|
limit,
|
||||||
offset
|
cursorPending: nextCursorPending,
|
||||||
|
cursorTimestamp: nextCursorTimestamp
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
|
||||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
|
|||||||
|
|
||||||
const licenseData = req.body;
|
const licenseData = req.body;
|
||||||
|
|
||||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
if (
|
||||||
|
licenseData.tier != "big_license" &&
|
||||||
|
licenseData.tier != "small_license"
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||||
apiResponse.message || "Failed to create license from Fossorial API"
|
apiResponse.message ||
|
||||||
|
"Failed to create license from Fossorial API"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,7 +117,10 @@ export async function generateNewEnterpriseLicense(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
const tier =
|
||||||
|
licenseData.tier === "big_license"
|
||||||
|
? LicenseId.BIG_LICENSE
|
||||||
|
: LicenseId.SMALL_LICENSE;
|
||||||
const tierPrice = getLicensePriceSet()[tier];
|
const tierPrice = getLicensePriceSet()[tier];
|
||||||
|
|
||||||
const session = await stripe!.checkout.sessions.create({
|
const session = await stripe!.checkout.sessions.create({
|
||||||
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
|
|||||||
{
|
{
|
||||||
price: tierPrice, // Use the standard tier
|
price: tierPrice, // Use the standard tier
|
||||||
quantity: 1
|
quantity: 1
|
||||||
},
|
}
|
||||||
], // Start with the standard feature set that matches the free limits
|
], // Start with the standard feature set that matches the free limits
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
|
|||||||
export * from "./blockClient";
|
export * from "./blockClient";
|
||||||
export * from "./unblockClient";
|
export * from "./unblockClient";
|
||||||
export * from "./listClients";
|
export * from "./listClients";
|
||||||
|
export * from "./listUserDevices";
|
||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { db, olms, users } from "@server/db";
|
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
currentFingerprint,
|
||||||
|
db,
|
||||||
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
clientSitesAssociationsCache,
|
users
|
||||||
currentFingerprint
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
count,
|
asc,
|
||||||
|
desc,
|
||||||
eq,
|
eq,
|
||||||
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNotNull,
|
|
||||||
isNull,
|
isNull,
|
||||||
or,
|
or,
|
||||||
sql
|
sql,
|
||||||
|
type SQL
|
||||||
} from "drizzle-orm";
|
} 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 { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
@@ -89,38 +93,47 @@ const listClientsParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listClientsSchema = z.object({
|
const listClientsSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().positive()),
|
page: z.coerce
|
||||||
offset: z
|
.number<string>() // for prettier formatting
|
||||||
.string()
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1),
|
||||||
.pipe(z.int().nonnegative()),
|
query: z.string().optional(),
|
||||||
filter: z.enum(["user", "machine"]).optional()
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
status: z.preprocess(
|
||||||
|
(val: string | undefined) => {
|
||||||
|
if (val) {
|
||||||
|
return val.split(","); // the search query array is an array joined by commas
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.array(z.enum(["active", "blocked", "archived"]))
|
||||||
|
.optional()
|
||||||
|
.default(["active"])
|
||||||
|
.catch(["active"])
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryClients(
|
function queryClientsBase() {
|
||||||
orgId: string,
|
|
||||||
accessibleClientIds: number[],
|
|
||||||
filter?: "user" | "machine"
|
|
||||||
) {
|
|
||||||
const conditions = [
|
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
|
||||||
eq(clients.orgId, orgId)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add filter condition based on filter type
|
|
||||||
if (filter === "user") {
|
|
||||||
conditions.push(isNotNull(clients.userId));
|
|
||||||
} else if (filter === "machine") {
|
|
||||||
conditions.push(isNull(clients.userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
@@ -142,22 +155,13 @@ function queryClients(
|
|||||||
approvalState: clients.approvalState,
|
approvalState: clients.approvalState,
|
||||||
olmArchived: olms.archived,
|
olmArchived: olms.archived,
|
||||||
archived: clients.archived,
|
archived: clients.archived,
|
||||||
blocked: clients.blocked,
|
blocked: clients.blocked
|
||||||
deviceModel: currentFingerprint.deviceModel,
|
|
||||||
fingerprintPlatform: currentFingerprint.platform,
|
|
||||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
|
||||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
|
||||||
fingerprintArch: currentFingerprint.arch,
|
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
|
||||||
fingerprintUsername: currentFingerprint.username,
|
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(users, eq(clients.userId, users.userId))
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||||
.where(and(...conditions));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSiteAssociations(clientIds: number[]) {
|
async function getSiteAssociations(clientIds: number[]) {
|
||||||
@@ -175,7 +179,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
|||||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||||
sites: Array<{
|
sites: Array<{
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
@@ -186,10 +190,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
|||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
|
|
||||||
export type ListClientsResponse = {
|
export type ListClientsResponse = PaginatedResponse<{
|
||||||
clients: Array<ClientWithSites>;
|
clients: Array<ClientWithSites>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -218,7 +221,8 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, filter } = parsedQuery.data;
|
const { page, pageSize, online, query, status, sort_by, order } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -267,28 +271,62 @@ export async function listClients(
|
|||||||
const accessibleClientIds = accessibleClients.map(
|
const accessibleClientIds = accessibleClients.map(
|
||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const countConditions = [
|
const conditions = [
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
and(
|
||||||
eq(clients.orgId, orgId)
|
inArray(clients.clientId, accessibleClientIds),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNull(clients.userId)
|
||||||
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filter === "user") {
|
if (typeof online !== "undefined") {
|
||||||
countConditions.push(isNotNull(clients.userId));
|
conditions.push(eq(clients.online, online));
|
||||||
} else if (filter === "machine") {
|
|
||||||
countConditions.push(isNull(clients.userId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const countQuery = db
|
if (status.length > 0) {
|
||||||
.select({ count: count() })
|
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||||
.from(clients)
|
|
||||||
.where(and(...countConditions));
|
|
||||||
|
|
||||||
const clientsList = await baseQuery.limit(limit).offset(offset);
|
if (status.includes("active")) {
|
||||||
const totalCountResult = await countQuery;
|
filterAggregates.push(
|
||||||
const totalCount = totalCountResult[0].count;
|
and(eq(clients.archived, false), eq(clients.blocked, false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("archived")) {
|
||||||
|
filterAggregates.push(eq(clients.archived, true));
|
||||||
|
}
|
||||||
|
if (status.includes("blocked")) {
|
||||||
|
filterAggregates.push(eq(clients.blocked, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...filterAggregates));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(ilike(clients.name, "%" + query + "%")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const listMachinesQuery = baseQuery
|
||||||
|
.limit(page)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(clients[sort_by])
|
||||||
|
: desc(clients[sort_by])
|
||||||
|
: asc(clients.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [clientsList, totalCount] = await Promise.all([
|
||||||
|
listMachinesQuery,
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// Get associated sites for all clients
|
// Get associated sites for all clients
|
||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
@@ -319,14 +357,8 @@ export async function listClients(
|
|||||||
|
|
||||||
// Merge clients with their site associations and replace name with device name
|
// Merge clients with their site associations and replace name with device name
|
||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
const model = client.deviceModel || null;
|
|
||||||
let newName = client.name;
|
|
||||||
if (filter === "user") {
|
|
||||||
newName = getUserDeviceName(model, client.name);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
name: newName,
|
|
||||||
sites: sitesByClient[client.clientId] || []
|
sites: sitesByClient[client.clientId] || []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -371,8 +403,8 @@ export async function listClients(
|
|||||||
clients: olmsWithUpdates,
|
clients: olmsWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
page,
|
||||||
offset
|
pageSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { build } from "@server/build";
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
currentFingerprint,
|
||||||
|
db,
|
||||||
|
olms,
|
||||||
|
orgs,
|
||||||
|
roleClients,
|
||||||
|
userClients,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
type SQL
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import semver from "semver";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
|
async function getLatestOlmVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/olm/tags",
|
||||||
|
{
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await response.json();
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
logger.warn("No tags found for Olm repository");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
|
olmVersionCache.set("latestOlmVersion", latestVersion);
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||||
|
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
logger.warn("Connection timeout while fetching latest Olm version");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching latest Olm version:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUserDevicesParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listUserDevicesSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
agent: z
|
||||||
|
.enum([
|
||||||
|
"windows",
|
||||||
|
"android",
|
||||||
|
"cli",
|
||||||
|
"olm",
|
||||||
|
"macos",
|
||||||
|
"ios",
|
||||||
|
"ipados",
|
||||||
|
"unknown"
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
status: z.preprocess(
|
||||||
|
(val: string | undefined) => {
|
||||||
|
if (val) {
|
||||||
|
return val.split(","); // the search query array is an array joined by commas
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.array(
|
||||||
|
z.enum(["active", "pending", "denied", "blocked", "archived"])
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default(["active", "pending"])
|
||||||
|
.catch(["active", "pending"])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryUserDevicesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
orgId: clients.orgId,
|
||||||
|
name: clients.name,
|
||||||
|
pubKey: clients.pubKey,
|
||||||
|
subnet: clients.subnet,
|
||||||
|
megabytesIn: clients.megabytesIn,
|
||||||
|
megabytesOut: clients.megabytesOut,
|
||||||
|
orgName: orgs.name,
|
||||||
|
type: clients.type,
|
||||||
|
online: clients.online,
|
||||||
|
olmVersion: olms.version,
|
||||||
|
userId: clients.userId,
|
||||||
|
username: users.username,
|
||||||
|
userEmail: users.email,
|
||||||
|
niceId: clients.niceId,
|
||||||
|
agent: olms.agent,
|
||||||
|
approvalState: clients.approvalState,
|
||||||
|
olmArchived: olms.archived,
|
||||||
|
archived: clients.archived,
|
||||||
|
blocked: clients.blocked,
|
||||||
|
deviceModel: currentFingerprint.deviceModel,
|
||||||
|
fingerprintPlatform: currentFingerprint.platform,
|
||||||
|
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||||
|
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||||
|
fingerprintArch: currentFingerprint.arch,
|
||||||
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
|
fingerprintUsername: currentFingerprint.username,
|
||||||
|
fingerprintHostname: currentFingerprint.hostname
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||||
|
}
|
||||||
|
|
||||||
|
type OlmWithUpdateAvailable = Awaited<
|
||||||
|
ReturnType<typeof queryUserDevicesBase>
|
||||||
|
>[0] & {
|
||||||
|
olmUpdateAvailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListUserDevicesResponse = PaginatedResponse<{
|
||||||
|
devices: Array<OlmWithUpdateAvailable>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/user-devices",
|
||||||
|
description: "List all user devices for an organization.",
|
||||||
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
query: listUserDevicesSchema,
|
||||||
|
params: listUserDevicesParamsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listUserDevices(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { page, pageSize, query, sort_by, online, status, agent, order } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessibleClients;
|
||||||
|
if (req.user) {
|
||||||
|
accessibleClients = await db
|
||||||
|
.select({
|
||||||
|
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||||
|
})
|
||||||
|
.from(userClients)
|
||||||
|
.fullJoin(
|
||||||
|
roleClients,
|
||||||
|
eq(userClients.clientId, roleClients.clientId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userClients.userId, req.user!.userId),
|
||||||
|
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
accessibleClients = await db
|
||||||
|
.select({ clientId: clients.clientId })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleClientIds = accessibleClients.map(
|
||||||
|
(client) => client.clientId
|
||||||
|
);
|
||||||
|
// Get client count with filter
|
||||||
|
const conditions = [
|
||||||
|
and(
|
||||||
|
inArray(clients.clientId, accessibleClientIds),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNotNull(clients.userId)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(clients.name, "%" + query + "%"),
|
||||||
|
ilike(users.email, "%" + query + "%")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof online !== "undefined") {
|
||||||
|
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)[] = [];
|
||||||
|
|
||||||
|
if (status.includes("active")) {
|
||||||
|
filterAggregates.push(
|
||||||
|
and(
|
||||||
|
eq(clients.archived, false),
|
||||||
|
eq(clients.blocked, false),
|
||||||
|
build !== "oss"
|
||||||
|
? or(
|
||||||
|
eq(clients.approvalState, "approved"),
|
||||||
|
isNull(clients.approvalState) // approval state of `NULL` means approved by default
|
||||||
|
)
|
||||||
|
: undefined // undefined are automatically ignored by `drizzle-orm`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("archived")) {
|
||||||
|
filterAggregates.push(eq(clients.archived, true));
|
||||||
|
}
|
||||||
|
if (status.includes("blocked")) {
|
||||||
|
filterAggregates.push(eq(clients.blocked, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build !== "oss") {
|
||||||
|
if (status.includes("pending")) {
|
||||||
|
filterAggregates.push(eq(clients.approvalState, "pending"));
|
||||||
|
}
|
||||||
|
if (status.includes("denied")) {
|
||||||
|
filterAggregates.push(eq(clients.approvalState, "denied"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...filterAggregates));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryUserDevicesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const listDevicesQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(clients[sort_by])
|
||||||
|
: desc(clients[sort_by])
|
||||||
|
: asc(clients.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [clientsList, totalCount] = await Promise.all([
|
||||||
|
listDevicesQuery,
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge clients with their site associations and replace name with device name
|
||||||
|
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
|
||||||
|
(client) => {
|
||||||
|
const model = client.deviceModel || null;
|
||||||
|
const newName = getUserDeviceName(model, client.name);
|
||||||
|
const OlmWithUpdate: OlmWithUpdateAvailable = {
|
||||||
|
...client,
|
||||||
|
name: newName
|
||||||
|
};
|
||||||
|
// Initially set to false, will be updated if version check succeeds
|
||||||
|
OlmWithUpdate.olmUpdateAvailable = false;
|
||||||
|
return OlmWithUpdate;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to get the latest version, but don't block if it fails
|
||||||
|
try {
|
||||||
|
const latestOlmVersion = await getLatestOlmVersion();
|
||||||
|
|
||||||
|
if (latestOlmVersion) {
|
||||||
|
olmsWithUpdates.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.olmUpdateAvailable = semver.lt(
|
||||||
|
client.olmVersion ? client.olmVersion : "",
|
||||||
|
latestOlmVersion
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
client.olmUpdateAvailable = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log the error but don't let it block the response
|
||||||
|
logger.warn(
|
||||||
|
"Failed to check for OLM updates, continuing without update info:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<ListUserDevicesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
devices: olmsWithUpdates,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Clients retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
|
|||||||
@@ -866,6 +866,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
|||||||
@@ -17,58 +17,59 @@ import {
|
|||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
import {
|
||||||
|
sql,
|
||||||
|
eq,
|
||||||
|
or,
|
||||||
|
inArray,
|
||||||
|
and,
|
||||||
|
count,
|
||||||
|
ilike,
|
||||||
|
asc,
|
||||||
|
not,
|
||||||
|
isNull,
|
||||||
|
type SQL
|
||||||
|
} from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
|
||||||
const listResourcesParamsSchema = z.strictObject({
|
const listResourcesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listResourcesSchema = z.object({
|
const listResourcesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().nonnegative()),
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
offset: z
|
.int()
|
||||||
.string()
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1),
|
||||||
.pipe(z.int().nonnegative())
|
query: z.string().optional(),
|
||||||
|
enabled: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
authState: z
|
||||||
|
.enum(["protected", "not_protected", "none"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
healthStatus: z
|
||||||
|
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
// (resource fields + a single joined target)
|
|
||||||
type JoinedRow = {
|
|
||||||
resourceId: number;
|
|
||||||
niceId: string;
|
|
||||||
name: string;
|
|
||||||
ssl: boolean;
|
|
||||||
fullDomain: string | null;
|
|
||||||
passwordId: number | null;
|
|
||||||
sso: boolean;
|
|
||||||
pincodeId: number | null;
|
|
||||||
whitelist: boolean;
|
|
||||||
http: boolean;
|
|
||||||
protocol: string;
|
|
||||||
proxyPort: number | null;
|
|
||||||
enabled: boolean;
|
|
||||||
domainId: string | null;
|
|
||||||
headerAuthId: number | null;
|
|
||||||
|
|
||||||
targetId: number | null;
|
|
||||||
targetIp: string | null;
|
|
||||||
targetPort: number | null;
|
|
||||||
targetEnabled: boolean | null;
|
|
||||||
|
|
||||||
hcHealth: string | null;
|
|
||||||
hcEnabled: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
export type ResourceWithTargets = {
|
export type ResourceWithTargets = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
@@ -91,11 +92,32 @@ export type ResourceWithTargets = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
// Aggregate filters
|
||||||
|
const total_targets = count(targets.targetId);
|
||||||
|
const healthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unknown_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unhealthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
|
||||||
|
function queryResourcesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -114,14 +136,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
targetId: targets.targetId,
|
|
||||||
targetIp: targets.ip,
|
|
||||||
targetPort: targets.port,
|
|
||||||
targetEnabled: targets.enabled,
|
|
||||||
|
|
||||||
hcHealth: targetHealthCheck.hcHealth,
|
|
||||||
hcEnabled: targetHealthCheck.hcEnabled
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -148,18 +163,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
)
|
)
|
||||||
.where(
|
.groupBy(
|
||||||
and(
|
resources.resourceId,
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
resourcePassword.passwordId,
|
||||||
eq(resources.orgId, orgId)
|
resourcePincode.pincodeId,
|
||||||
)
|
resourceHeaderAuth.headerAuthId,
|
||||||
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResourcesResponse = {
|
export type ListResourcesResponse = PaginatedResponse<{
|
||||||
resources: ResourceWithTargets[];
|
resources: ResourceWithTargets[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -190,7 +205,8 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, authState, enabled, query, healthStatus } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -252,14 +268,123 @@ export async function listResources(
|
|||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
const countQuery: any = db
|
const conditions = [
|
||||||
.select({ count: count() })
|
and(
|
||||||
.from(resources)
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(resources.name, "%" + query + "%"),
|
||||||
|
ilike(resources.fullDomain, "%" + query + "%")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof enabled !== "undefined") {
|
||||||
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
if (typeof authState !== "undefined") {
|
||||||
|
switch (authState) {
|
||||||
|
case "none":
|
||||||
|
conditions.push(eq(resources.http, false));
|
||||||
|
break;
|
||||||
|
case "protected":
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(resources.sso, true),
|
||||||
|
eq(resources.emailWhitelistEnabled, true),
|
||||||
|
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||||
|
not(isNull(resourcePincode.pincodeId)),
|
||||||
|
not(isNull(resourcePassword.passwordId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "not_protected":
|
||||||
|
conditions.push(
|
||||||
|
not(eq(resources.sso, true)),
|
||||||
|
not(eq(resources.emailWhitelistEnabled, true)),
|
||||||
|
isNull(resourceHeaderAuth.headerAuthId),
|
||||||
|
isNull(resourcePincode.pincodeId),
|
||||||
|
isNull(resourcePassword.passwordId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
|
||||||
|
|
||||||
|
if (typeof healthStatus !== "undefined") {
|
||||||
|
switch (healthStatus) {
|
||||||
|
case "healthy":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "degraded":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unhealthy_targets} > 0`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "no_targets":
|
||||||
|
aggregateFilters = sql`${total_targets} = 0`;
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = 0`,
|
||||||
|
sql`${unhealthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "unknown":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unknown_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourcesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.having(aggregateFilters);
|
||||||
|
|
||||||
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_resources"));
|
||||||
|
|
||||||
|
const [rows, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(resources.resourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
const allResourceTargets =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
targetId: targets.targetId,
|
||||||
|
resourceId: targets.resourceId,
|
||||||
|
ip: targets.ip,
|
||||||
|
port: targets.port,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
healthStatus: targetHealthCheck.hcHealth,
|
||||||
|
hcEnabled: targetHealthCheck.hcEnabled
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(inArray(targets.resourceId, resourceIdList))
|
||||||
|
.leftJoin(
|
||||||
|
targetHealthCheck,
|
||||||
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
|
);
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
@@ -288,44 +413,20 @@ export async function listResources(
|
|||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
entry.targets = allResourceTargets.filter(
|
||||||
row.targetId != null &&
|
(t) => t.resourceId === entry.resourceId
|
||||||
row.targetIp &&
|
);
|
||||||
row.targetPort != null &&
|
|
||||||
row.targetEnabled != null
|
|
||||||
) {
|
|
||||||
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
if (row.hcEnabled && row.hcHealth) {
|
|
||||||
healthStatus = row.hcHealth as
|
|
||||||
| "healthy"
|
|
||||||
| "unhealthy"
|
|
||||||
| "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.targets.push({
|
|
||||||
targetId: row.targetId,
|
|
||||||
ip: row.targetIp,
|
|
||||||
port: row.targetPort,
|
|
||||||
enabled: row.targetEnabled,
|
|
||||||
healthStatus: healthStatus
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||||
|
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
|
||||||
|
|
||||||
return response<ListResourcesResponse>(res, {
|
return response<ListResourcesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
resources: resourcesList,
|
resources: resourcesList,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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, 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";
|
||||||
@@ -12,6 +22,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -74,21 +85,34 @@ const listSitesParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listSitesSchema = z.object({
|
const listSitesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().positive()),
|
page: z.coerce
|
||||||
offset: z
|
.number<string>() // for prettier formatting
|
||||||
.string()
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1),
|
||||||
.pipe(z.int().nonnegative())
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc").catch("asc"),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
function querySites(orgId: string, accessibleSiteIds: number[]) {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -115,23 +139,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
remoteExitNodes,
|
remoteExitNodes,
|
||||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = {
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
sites: SiteWithUpdateAvailable[];
|
sites: SiteWithUpdateAvailable[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -160,7 +177,6 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -203,34 +219,61 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const { pageSize, page, query, sort_by, order, online } =
|
||||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
parsedQuery.data;
|
||||||
|
|
||||||
const countQuery = db
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
const conditions = [
|
||||||
.where(
|
and(
|
||||||
and(
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
eq(sites.orgId, orgId)
|
||||||
eq(sites.orgId, orgId)
|
)
|
||||||
|
];
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(sites.name, "%" + query + "%"),
|
||||||
|
ilike(sites.niceId, "%" + query + "%")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (typeof online !== "undefined") {
|
||||||
|
conditions.push(eq(sites.online, online));
|
||||||
|
}
|
||||||
|
|
||||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0].count;
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySitesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteListQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(sites[sort_by])
|
||||||
|
: desc(sites[sort_by])
|
||||||
|
: asc(sites.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
siteListQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// 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[] = rows.map((site) => {
|
||||||
(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;
|
||||||
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 {
|
||||||
@@ -267,8 +310,8 @@ export async function listSites(
|
|||||||
sites: sitesWithUpdates,
|
sites: sitesWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export async function createSiteResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode,
|
mode: mode as "host" | "cidr",
|
||||||
// protocol: mode === "port" ? protocol : null,
|
// protocol: mode === "port" ? protocol : null,
|
||||||
// proxyPort: mode === "port" ? proxyPort : null,
|
// proxyPort: mode === "port" ? proxyPort : null,
|
||||||
// destinationPort: mode === "port" ? destinationPort : null,
|
// destinationPort: mode === "port" ? destinationPort : null,
|
||||||
|
|||||||
@@ -1,41 +1,73 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
import { siteResources, sites, SiteResource } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, asc, ilike, or } from "drizzle-orm";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().positive()),
|
page: z.coerce
|
||||||
offset: z
|
.number<string>() // for prettier formatting
|
||||||
.string()
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1),
|
||||||
.pipe(z.int().nonnegative())
|
query: z.string().optional(),
|
||||||
|
mode: z.enum(["host", "cidr"]).optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = {
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
siteResources: (SiteResource & {
|
siteResources: (SiteResource & {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
siteAddress: string | null;
|
siteAddress: string | null;
|
||||||
})[];
|
})[];
|
||||||
};
|
}>;
|
||||||
|
|
||||||
|
function querySiteResourcesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
siteId: siteResources.siteId,
|
||||||
|
orgId: siteResources.orgId,
|
||||||
|
niceId: siteResources.niceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
mode: siteResources.mode,
|
||||||
|
protocol: siteResources.protocol,
|
||||||
|
proxyPort: siteResources.proxyPort,
|
||||||
|
destinationPort: siteResources.destinationPort,
|
||||||
|
destination: siteResources.destination,
|
||||||
|
enabled: siteResources.enabled,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
aliasAddress: siteResources.aliasAddress,
|
||||||
|
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||||
|
udpPortRangeString: siteResources.udpPortRangeString,
|
||||||
|
disableIcmp: siteResources.disableIcmp,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteAddress: sites.address
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||||
|
}
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -80,39 +112,48 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, query, mode } = parsedQuery.data;
|
||||||
|
|
||||||
// Get all site resources for the org with site names
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
const siteResourcesList = await db
|
if (query) {
|
||||||
.select({
|
conditions.push(
|
||||||
siteResourceId: siteResources.siteResourceId,
|
or(
|
||||||
siteId: siteResources.siteId,
|
ilike(siteResources.name, "%" + query + "%"),
|
||||||
orgId: siteResources.orgId,
|
ilike(siteResources.destination, "%" + query + "%"),
|
||||||
niceId: siteResources.niceId,
|
ilike(siteResources.alias, "%" + query + "%"),
|
||||||
name: siteResources.name,
|
ilike(siteResources.aliasAddress, "%" + query + "%"),
|
||||||
mode: siteResources.mode,
|
ilike(sites.name, "%" + query + "%")
|
||||||
protocol: siteResources.protocol,
|
)
|
||||||
proxyPort: siteResources.proxyPort,
|
);
|
||||||
destinationPort: siteResources.destinationPort,
|
}
|
||||||
destination: siteResources.destination,
|
|
||||||
enabled: siteResources.enabled,
|
|
||||||
alias: siteResources.alias,
|
|
||||||
aliasAddress: siteResources.aliasAddress,
|
|
||||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
|
||||||
udpPortRangeString: siteResources.udpPortRangeString,
|
|
||||||
disableIcmp: siteResources.disableIcmp,
|
|
||||||
siteName: sites.name,
|
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteAddress: sites.address
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
|
||||||
.where(eq(siteResources.orgId, orgId))
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return response(res, {
|
if (mode) {
|
||||||
data: { siteResources: siteResourcesList },
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySiteResourcesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [siteResourcesList, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(siteResources.siteResourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
|
data: {
|
||||||
|
siteResources: siteResourcesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Site resources retrieved successfully",
|
message: "Site resources retrieved successfully",
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
await db
|
await db
|
||||||
.update(targetHealthCheck)
|
.update(targetHealthCheck)
|
||||||
.set({
|
.set({
|
||||||
hcHealth: healthStatus.status
|
hcHealth: healthStatus.status as
|
||||||
|
| "unknown"
|
||||||
|
| "healthy"
|
||||||
|
| "unhealthy"
|
||||||
})
|
})
|
||||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type Pagination = { total: number; pageSize: number; page: number };
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = T & {
|
||||||
|
pagination: Pagination;
|
||||||
|
};
|
||||||
@@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { Pagination } from "@server/types/Pagination";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let machineClients: ListClientsResponse["clients"] = [];
|
let machineClients: ListClientsResponse["clients"] = [];
|
||||||
|
let pagination: Pagination = {
|
||||||
|
page: 1,
|
||||||
|
total: 0,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const machineRes = await internal.get<
|
const machineRes = await internal.get<
|
||||||
AxiosResponse<ListClientsResponse>
|
AxiosResponse<ListClientsResponse>
|
||||||
>(
|
>(
|
||||||
`/org/${params.orgId}/clients?filter=machine`,
|
`/org/${params.orgId}/clients?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
machineClients = machineRes.data.data.clients;
|
const responseData = machineRes.data.data;
|
||||||
|
machineClients = responseData.clients;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
function formatSize(mb: number): string {
|
||||||
@@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
<MachineClientsTable
|
<MachineClientsTable
|
||||||
machineClients={machineClientRows}
|
machineClients={machineClientRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -602,7 +602,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.biometricsEnabled
|
.biometricsEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -622,7 +623,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.diskEncrypted
|
.diskEncrypted ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -642,7 +644,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.firewallEnabled
|
.firewallEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -663,7 +666,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.autoUpdatesEnabled
|
.autoUpdatesEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -683,7 +687,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.tpmAvailable
|
.tpmAvailable ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -707,7 +712,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.windowsAntivirusEnabled
|
.windowsAntivirusEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -727,7 +733,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosSipEnabled
|
.macosSipEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -751,7 +758,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosGatekeeperEnabled
|
.macosGatekeeperEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -775,7 +783,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosFirewallStealthMode
|
.macosFirewallStealthMode ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -796,7 +805,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.linuxAppArmorEnabled
|
.linuxAppArmorEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -817,7 +827,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.linuxSELinuxEnabled
|
.linuxSELinuxEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import type { ClientRow } from "@app/components/UserDevicesTable";
|
import type { ClientRow } from "@app/components/UserDevicesTable";
|
||||||
import UserDevicesTable from "@app/components/UserDevicesTable";
|
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { type ListUserDevicesResponse } from "@server/routers/client";
|
||||||
|
import type { Pagination } from "@server/types/Pagination";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let userClients: ListClientsResponse["clients"] = [];
|
let userClients: ListUserDevicesResponse["devices"] = [];
|
||||||
|
|
||||||
|
let pagination: Pagination = {
|
||||||
|
page: 1,
|
||||||
|
total: 0,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
|
const userRes = await internal.get<
|
||||||
`/org/${params.orgId}/clients?filter=user`,
|
AxiosResponse<ListUserDevicesResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/user-devices?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
userClients = userRes.data.data.clients;
|
const responseData = userRes.data.data;
|
||||||
|
userClients = responseData.devices;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
function formatSize(mb: number): string {
|
||||||
@@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapClientToRow = (
|
const mapClientToRow = (
|
||||||
client: ListClientsResponse["clients"][0]
|
client: ListUserDevicesResponse["devices"][number]
|
||||||
): ClientRow => {
|
): ClientRow => {
|
||||||
// Build fingerprint object if any fingerprint data exists
|
// Build fingerprint object if any fingerprint data exists
|
||||||
const hasFingerprintData =
|
const hasFingerprintData =
|
||||||
(client as any).fingerprintPlatform ||
|
client.fingerprintPlatform ||
|
||||||
(client as any).fingerprintOsVersion ||
|
client.fingerprintOsVersion ||
|
||||||
(client as any).fingerprintKernelVersion ||
|
client.fingerprintKernelVersion ||
|
||||||
(client as any).fingerprintArch ||
|
client.fingerprintArch ||
|
||||||
(client as any).fingerprintSerialNumber ||
|
client.fingerprintSerialNumber ||
|
||||||
(client as any).fingerprintUsername ||
|
client.fingerprintUsername ||
|
||||||
(client as any).fingerprintHostname ||
|
client.fingerprintHostname ||
|
||||||
(client as any).deviceModel;
|
client.deviceModel;
|
||||||
|
|
||||||
const fingerprint = hasFingerprintData
|
const fingerprint = hasFingerprintData
|
||||||
? {
|
? {
|
||||||
platform: (client as any).fingerprintPlatform || null,
|
platform: client.fingerprintPlatform,
|
||||||
osVersion: (client as any).fingerprintOsVersion || null,
|
osVersion: client.fingerprintOsVersion,
|
||||||
kernelVersion:
|
kernelVersion: client.fingerprintKernelVersion,
|
||||||
(client as any).fingerprintKernelVersion || null,
|
arch: client.fingerprintArch,
|
||||||
arch: (client as any).fingerprintArch || null,
|
deviceModel: client.deviceModel,
|
||||||
deviceModel: (client as any).deviceModel || null,
|
serialNumber: client.fingerprintSerialNumber,
|
||||||
serialNumber:
|
username: client.fingerprintUsername,
|
||||||
(client as any).fingerprintSerialNumber || null,
|
hostname: client.fingerprintHostname
|
||||||
username: (client as any).fingerprintUsername || null,
|
|
||||||
hostname: (client as any).fingerprintHostname || null
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
name: client.name,
|
name: client.name,
|
||||||
id: client.clientId,
|
id: client.clientId,
|
||||||
subnet: client.subnet.split("/")[0],
|
subnet: client.subnet.split("/")[0],
|
||||||
mbIn: formatSize(client.megabytesIn || 0),
|
mbIn: formatSize(client.megabytesIn ?? 0),
|
||||||
mbOut: formatSize(client.megabytesOut || 0),
|
mbOut: formatSize(client.megabytesOut ?? 0),
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
online: client.online,
|
online: client.online,
|
||||||
olmVersion: client.olmVersion || undefined,
|
olmVersion: client.olmVersion || undefined,
|
||||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
olmUpdateAvailable: Boolean(client.olmUpdateAvailable),
|
||||||
userId: client.userId,
|
userId: client.userId,
|
||||||
username: client.username,
|
username: client.username,
|
||||||
userEmail: client.userEmail,
|
userEmail: client.userEmail,
|
||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: Boolean(client.archived),
|
||||||
blocked: client.blocked || false,
|
blocked: Boolean(client.blocked),
|
||||||
approvalState: client.approvalState,
|
approvalState: client.approvalState,
|
||||||
fingerprint
|
fingerprint
|
||||||
};
|
};
|
||||||
@@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
<UserDevicesTable
|
<UserDevicesTable
|
||||||
userClients={userClientRows}
|
userClients={userClientRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
export interface ClientResourcesPageProps {
|
export interface ClientResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
@@ -22,22 +22,24 @@ export default async function ClientResourcesPage(
|
|||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
|
||||||
`/org/${params.orgId}/resources`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
resources = res.data.data.resources;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
>(
|
||||||
siteResources = res.data.data.siteResources;
|
`/org/${params.orgId}/site-resources?${searchParams.toString()}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const responseData = res.data.data;
|
||||||
|
siteResources = responseData.siteResources;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
@@ -89,9 +91,10 @@ export default async function ClientResourcesPage(
|
|||||||
<ClientResourcesTable
|
<ClientResourcesTable
|
||||||
internalResources={internalResourceRows}
|
internalResources={internalResourceRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
defaultSort={{
|
rowCount={pagination.total}
|
||||||
id: "name",
|
pagination={{
|
||||||
desc: false
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { cache } from "react";
|
|||||||
|
|
||||||
export interface ProxyResourcesPageProps {
|
export interface ProxyResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProxyResourcesPage(
|
export default async function ProxyResourcesPage(
|
||||||
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
|
|||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||||
`/org/${params.orgId}/resources`,
|
`/org/${params.orgId}/resources?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
resources = res.data.data.resources;
|
const responseData = res.data.data;
|
||||||
|
resources = responseData.resources;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
|
|||||||
<ProxyResourcesTable
|
<ProxyResourcesTable
|
||||||
resources={resourceRows}
|
resources={resourceRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
defaultSort={{
|
rowCount={pagination.total}
|
||||||
id: "name",
|
pagination={{
|
||||||
desc: false
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|||||||
@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
|
|||||||
|
|
||||||
type SitesPageProps = {
|
type SitesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SitesPage(props: SitesPageProps) {
|
export default async function SitesPage(props: SitesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let sites: ListSitesResponse["sites"] = [];
|
let sites: ListSitesResponse["sites"] = [];
|
||||||
|
let pagination: ListSitesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||||
`/org/${params.orgId}/sites`,
|
`/org/${params.orgId}/sites?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
sites = res.data.data.sites;
|
const responseData = res.data.data;
|
||||||
|
sites = responseData.sites;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* <SitesSplashCard /> */}
|
|
||||||
|
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={t("siteManageSites")}
|
title={t("siteManageSites")}
|
||||||
description={t("siteDescription")}
|
description={t("siteDescription")}
|
||||||
@@ -69,7 +78,15 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
|
|
||||||
<SitesBanner />
|
<SitesBanner />
|
||||||
|
|
||||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
<SitesTable
|
||||||
|
sites={siteRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
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 { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import {
|
import {
|
||||||
approvalFiltersSchema,
|
approvalFiltersSchema,
|
||||||
approvalQueries,
|
approvalQueries,
|
||||||
type ApprovalItem
|
type ApprovalItem
|
||||||
} from "@app/lib/queries";
|
} from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
|
import { Ban, Check, Loader, RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -54,12 +54,20 @@ export function ApprovalFeed({
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const { data, isFetching, refetch } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage
|
||||||
|
} = useInfiniteQuery({
|
||||||
...approvalQueries.listApprovals(orgId, filters),
|
...approvalQueries.listApprovals(orgId, filters),
|
||||||
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
||||||
});
|
});
|
||||||
|
|
||||||
const approvals = data?.approvals ?? [];
|
const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];
|
||||||
|
|
||||||
// Show empty state if no approvals are enabled for any role
|
// Show empty state if no approvals are enabled for any role
|
||||||
if (!hasApprovalsEnabled) {
|
if (!hasApprovalsEnabled) {
|
||||||
@@ -115,13 +123,13 @@ export function ApprovalFeed({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isFetching}
|
disabled={isFetching || isLoading}
|
||||||
className="lg:static gap-2"
|
className="lg:static gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4",
|
"size-4",
|
||||||
isFetching && "animate-spin"
|
(isFetching || isLoading) && "animate-spin"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t("refresh")}
|
{t("refresh")}
|
||||||
@@ -145,13 +153,30 @@ export function ApprovalFeed({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{approvals.length === 0 && (
|
{approvals.length === 0 && (
|
||||||
<li className="flex justify-center items-center p-4 text-muted-foreground">
|
<li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
|
||||||
{t("approvalListEmpty")}
|
{isLoading
|
||||||
|
? t("loadingApprovals")
|
||||||
|
: t("approvalListEmpty")}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Loader className="size-4 flex-none animate-spin" />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="self-center"
|
||||||
|
size="lg"
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
>
|
||||||
|
{t("approvalLoadMore")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource
|
|||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
|
||||||
export type InternalResourceRow = {
|
export type InternalResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -51,18 +56,22 @@ export type InternalResourceRow = {
|
|||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
defaultSort?: {
|
pagination: PaginationState;
|
||||||
id: string;
|
rowCount: number;
|
||||||
desc: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientResourcesTable({
|
export default function ClientResourcesTable({
|
||||||
internalResources,
|
internalResources,
|
||||||
orgId,
|
orgId,
|
||||||
defaultSort
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ClientResourcesTableProps) {
|
}: ClientResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -122,19 +131,7 @@ export default function ClientResourcesTable({
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: t("name"),
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="p-3">{t("name")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "niceId",
|
id: "niceId",
|
||||||
@@ -180,9 +177,24 @@ export default function ClientResourcesTable({
|
|||||||
accessorKey: "mode",
|
accessorKey: "mode",
|
||||||
friendlyName: t("editInternalResourceDialogMode"),
|
friendlyName: t("editInternalResourceDialogMode"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">
|
<ColumnFilterButton
|
||||||
{t("editInternalResourceDialogMode")}
|
options={[
|
||||||
</span>
|
{
|
||||||
|
value: "host",
|
||||||
|
label: t("editInternalResourceDialogModeHost")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cidr",
|
||||||
|
label: t("editInternalResourceDialogModeCidr")
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
|
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("editInternalResourceDialogMode")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
@@ -300,6 +312,37 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function handleFilterChange(
|
||||||
|
column: string,
|
||||||
|
value: string | undefined | null
|
||||||
|
) {
|
||||||
|
searchParams.delete(column);
|
||||||
|
searchParams.delete("page");
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
searchParams.set(column, value);
|
||||||
|
}
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedInternalResource && (
|
{selectedInternalResource && (
|
||||||
@@ -327,19 +370,20 @@ export default function ClientResourcesTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={internalColumns}
|
columns={internalColumns}
|
||||||
data={internalResources}
|
rows={internalResources}
|
||||||
persistPageSize="internal-resources"
|
tableId="internal-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
|
||||||
onAdd={() => setIsCreateDialogOpen(true)}
|
onAdd={() => setIsCreateDialogOpen(true)}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
onPaginationChange={handlePaginationChange}
|
||||||
defaultSort={defaultSort}
|
pagination={pagination}
|
||||||
enableColumnVisibility={true}
|
rowCount={rowCount}
|
||||||
persistColumnVisibility="internal-resources"
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
aliasAddress: false
|
aliasAddress: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -61,16 +62,19 @@ export function ColumnFilter({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
<span className="truncate">
|
|
||||||
{selectedOption
|
{selectedOption && (
|
||||||
? selectedOption.label
|
<Badge className="truncate" variant="secondary">
|
||||||
: placeholder}
|
{selectedOption
|
||||||
</span>
|
? selectedOption.label
|
||||||
|
: placeholder}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-[200px]" align="start">
|
<PopoverContent className="p-0 w-50" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnFilterButtonProps {
|
||||||
|
options: FilterOption[];
|
||||||
|
selectedValue?: string;
|
||||||
|
onValueChange: (value: string | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
className?: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnFilterButton({
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onValueChange,
|
||||||
|
placeholder,
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
emptyMessage = "No options found",
|
||||||
|
className,
|
||||||
|
label
|
||||||
|
}: ColumnFilterButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedOption = options.find(
|
||||||
|
(option) => option.value === selectedValue
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2",
|
||||||
|
!selectedValue && "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{label}
|
||||||
|
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
|
||||||
|
{selectedOption && (
|
||||||
|
<Badge className="truncate" variant="secondary">
|
||||||
|
{selectedOption.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-50" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* Clear filter option */}
|
||||||
|
{selectedValue && (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(undefined);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(
|
||||||
|
selectedValue === option.value
|
||||||
|
? undefined
|
||||||
|
: option.value
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedValue === option.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({
|
|||||||
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||||
const { data: clientsResponse = [] } = useQuery(
|
const { data: clientsResponse = [] } = useQuery(
|
||||||
orgQueries.clients({
|
orgQueries.clients({
|
||||||
orgId,
|
orgId
|
||||||
filters: {
|
|
||||||
filter: "machine"
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({
|
|||||||
orgQueries.roles({ orgId }),
|
orgQueries.roles({ orgId }),
|
||||||
orgQueries.users({ orgId }),
|
orgQueries.users({ orgId }),
|
||||||
orgQueries.clients({
|
orgQueries.clients({
|
||||||
orgId,
|
orgId
|
||||||
filters: {
|
|
||||||
filter: "machine"
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
|
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
|
||||||
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),
|
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CircleSlash
|
CircleSlash,
|
||||||
|
ArrowDown01Icon,
|
||||||
|
ArrowUp10Icon,
|
||||||
|
ChevronsUpDownIcon
|
||||||
} 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";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -48,14 +58,24 @@ export type ClientRow = {
|
|||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
machineClients: ClientRow[];
|
machineClients: ClientRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
pagination: PaginationState;
|
||||||
|
rowCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MachineClientsTable({
|
export default function MachineClientsTable({
|
||||||
machineClients,
|
machineClients,
|
||||||
orgId
|
orgId,
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
@@ -65,6 +85,7 @@ export default function MachineClientsTable({
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const defaultMachineColumnVisibility = {
|
const defaultMachineColumnVisibility = {
|
||||||
subnet: false,
|
subnet: false,
|
||||||
@@ -182,22 +203,8 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: "Name",
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("name")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "niceId",
|
accessorKey: "niceId",
|
||||||
friendlyName: "Identifier",
|
friendlyName: "Identifier",
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("identifier")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: "Connectivity",
|
friendlyName: t("online"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ColumnFilterButton
|
||||||
variant="ghost"
|
options={[
|
||||||
onClick={() =>
|
{
|
||||||
column.toggleSorting(
|
value: "true",
|
||||||
column.getIsSorted() === "asc"
|
label: t("connected")
|
||||||
)
|
},
|
||||||
|
{
|
||||||
|
value: "false",
|
||||||
|
label: t("disconnected")
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={
|
||||||
|
searchParams.get("online") ?? undefined
|
||||||
}
|
}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
Connectivity
|
handleFilterChange("online", value)
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
}
|
||||||
</Button>
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("online")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -279,38 +283,52 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: "Data In",
|
friendlyName: t("dataIn"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataInOrder = getSortDirection(
|
||||||
|
"megabytesIn",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("megabytesIn")}
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Data In
|
{t("dataIn")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbOut",
|
accessorKey: "mbOut",
|
||||||
friendlyName: "Data Out",
|
friendlyName: t("dataOut"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataOutOrder = getSortDirection(
|
||||||
|
"megabytesOut",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("megabytesOut")}
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Data Out
|
{t("dataOut")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -318,21 +336,7 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "client",
|
accessorKey: "client",
|
||||||
friendlyName: t("agent"),
|
friendlyName: t("agent"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("agent")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("agent")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
|
||||||
@@ -356,22 +360,8 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "subnet",
|
accessorKey: "subnet",
|
||||||
friendlyName: "Address",
|
friendlyName: t("address"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("address")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Address
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -455,7 +445,56 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, t]);
|
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||||
|
|
||||||
|
const booleanSearchFilterSchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
|
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());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -478,20 +517,25 @@ export default function MachineClientsTable({
|
|||||||
title="Delete Client"
|
title="Delete Client"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={machineClients || []}
|
rows={machineClients}
|
||||||
persistPageSize="machine-clients"
|
tableId="machine-clients"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
startNavigation(() =>
|
||||||
|
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
addButtonText={t("createClient")}
|
addButtonText={t("createClient")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
enableColumnVisibility={true}
|
onSearch={handleSearchChange}
|
||||||
persistColumnVisibility="machine-clients"
|
onPaginationChange={handlePaginationChange}
|
||||||
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
|
enableColumnVisibility
|
||||||
columnVisibility={defaultMachineColumnVisibility}
|
columnVisibility={defaultMachineColumnVisibility}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -518,30 +562,10 @@ export default function MachineClientsTable({
|
|||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (
|
onValueChange(selectedValues: string[]) {
|
||||||
row: ClientRow,
|
handleFilterChange("status", selectedValues);
|
||||||
selectedValues: (string | number | boolean)[]
|
|
||||||
) => {
|
|
||||||
if (selectedValues.length === 0) return true;
|
|
||||||
const rowArchived = row.archived || false;
|
|
||||||
const rowBlocked = row.blocked || false;
|
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("archived") &&
|
|
||||||
rowArchived
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("blocked") &&
|
|
||||||
rowBlocked
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
values: searchParams.getAll("status")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function OrgSelector({
|
|||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
<Command className="rounded-lg">
|
<Command className="rounded-lg">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t("searchProgress")}
|
placeholder={t("searchPlaceholder")}
|
||||||
className="border-0 focus:ring-0"
|
className="border-0 focus:ring-0"
|
||||||
/>
|
/>
|
||||||
<CommandEmpty className="py-6 text-center">
|
<CommandEmpty className="py-6 text-center">
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { DataTable } from "@app/components/ui/data-table";
|
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -14,13 +13,14 @@ import {
|
|||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
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 { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -32,14 +32,24 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import {
|
||||||
|
useOptimistic,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
type ComponentRef
|
||||||
|
} from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceRow = {
|
export type ResourceRow = {
|
||||||
@@ -117,18 +127,22 @@ function StatusIcon({
|
|||||||
type ProxyResourcesTableProps = {
|
type ProxyResourcesTableProps = {
|
||||||
resources: ResourceRow[];
|
resources: ResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
defaultSort?: {
|
pagination: PaginationState;
|
||||||
id: string;
|
rowCount: number;
|
||||||
desc: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProxyResourcesTable({
|
export default function ProxyResourcesTable({
|
||||||
resources,
|
resources,
|
||||||
orgId,
|
orgId,
|
||||||
defaultSort
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ProxyResourcesTableProps) {
|
}: ProxyResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -140,6 +154,7 @@ export default function ProxyResourcesTable({
|
|||||||
useState<ResourceRow | null>();
|
useState<ResourceRow | null>();
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -174,23 +189,24 @@ export default function ProxyResourcesTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||||
await api
|
try {
|
||||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
await api.post<AxiosResponse<UpdateResourceResponse>>(
|
||||||
`resource/${resourceId}`,
|
`resource/${resourceId}`,
|
||||||
{
|
{
|
||||||
enabled: val
|
enabled: val
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.catch((e) => {
|
router.refresh();
|
||||||
toast({
|
} catch (e) {
|
||||||
variant: "destructive",
|
toast({
|
||||||
title: t("resourcesErrorUpdate"),
|
variant: "destructive",
|
||||||
description: formatAxiosError(
|
title: t("resourcesErrorUpdate"),
|
||||||
e,
|
description: formatAxiosError(
|
||||||
t("resourcesErrorUpdateDescription")
|
e,
|
||||||
)
|
t("resourcesErrorUpdateDescription")
|
||||||
});
|
)
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
|
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
|
||||||
@@ -236,7 +252,7 @@ export default function ProxyResourcesTable({
|
|||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-[280px]">
|
<DropdownMenuContent align="start" className="min-w-70">
|
||||||
{monitoredTargets.length > 0 && (
|
{monitoredTargets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{monitoredTargets.map((target) => (
|
{monitoredTargets.map((target) => (
|
||||||
@@ -302,38 +318,14 @@ export default function ProxyResourcesTable({
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: t("name"),
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="p-3">{t("name")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "niceId",
|
id: "niceId",
|
||||||
accessorKey: "nice",
|
accessorKey: "nice",
|
||||||
friendlyName: t("identifier"),
|
friendlyName: t("identifier"),
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
header: ({ column }) => {
|
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||||
return (
|
|
||||||
<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>;
|
||||||
}
|
}
|
||||||
@@ -359,19 +351,33 @@ export default function ProxyResourcesTable({
|
|||||||
id: "status",
|
id: "status",
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
friendlyName: t("status"),
|
friendlyName: t("status"),
|
||||||
header: ({ column }) => {
|
header: () => (
|
||||||
return (
|
<ColumnFilterButton
|
||||||
<Button
|
options={[
|
||||||
variant="ghost"
|
{ value: "healthy", label: t("resourcesTableHealthy") },
|
||||||
onClick={() =>
|
{
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
value: "degraded",
|
||||||
}
|
label: t("resourcesTableDegraded")
|
||||||
>
|
},
|
||||||
{t("status")}
|
{ value: "offline", label: t("resourcesTableOffline") },
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
{
|
||||||
</Button>
|
value: "no_targets",
|
||||||
);
|
label: t("resourcesTableNoTargets")
|
||||||
},
|
},
|
||||||
|
{ value: "unknown", label: t("resourcesTableUnknown") }
|
||||||
|
]}
|
||||||
|
selectedValue={
|
||||||
|
searchParams.get("healthStatus") ?? undefined
|
||||||
|
}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("healthStatus", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("status")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return <TargetStatusCell targets={resourceRow.targets} />;
|
return <TargetStatusCell targets={resourceRow.targets} />;
|
||||||
@@ -419,19 +425,23 @@ export default function ProxyResourcesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "authState",
|
accessorKey: "authState",
|
||||||
friendlyName: t("authentication"),
|
friendlyName: t("authentication"),
|
||||||
header: ({ column }) => {
|
header: () => (
|
||||||
return (
|
<ColumnFilterButton
|
||||||
<Button
|
options={[
|
||||||
variant="ghost"
|
{ value: "protected", label: t("protected") },
|
||||||
onClick={() =>
|
{ value: "not_protected", label: t("notProtected") },
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
{ value: "none", label: t("none") }
|
||||||
}
|
]}
|
||||||
>
|
selectedValue={searchParams.get("authState") ?? undefined}
|
||||||
{t("authentication")}
|
onValueChange={(value) =>
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
handleFilterChange("authState", value)
|
||||||
</Button>
|
}
|
||||||
);
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
},
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("authentication")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -456,20 +466,28 @@ export default function ProxyResourcesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "enabled",
|
accessorKey: "enabled",
|
||||||
friendlyName: t("enabled"),
|
friendlyName: t("enabled"),
|
||||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
header: () => (
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: t("enabled") },
|
||||||
|
{ value: "false", label: t("disabled") }
|
||||||
|
]}
|
||||||
|
selectedValue={booleanSearchFilterSchema.parse(
|
||||||
|
searchParams.get("enabled")
|
||||||
|
)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("enabled", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("enabled")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<ResourceEnabledForm
|
||||||
defaultChecked={
|
resource={row.original}
|
||||||
row.original.http
|
onToggleResourceEnabled={toggleResourceEnabled}
|
||||||
? !!row.original.domainId && row.original.enabled
|
|
||||||
: row.original.enabled
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
row.original.http ? !row.original.domainId : false
|
|
||||||
}
|
|
||||||
onCheckedChange={(val) =>
|
|
||||||
toggleResourceEnabled(val, row.original.id)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -525,6 +543,42 @@ export default function ProxyResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const booleanSearchFilterSchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
|
function handleFilterChange(
|
||||||
|
column: string,
|
||||||
|
value: string | undefined | null
|
||||||
|
) {
|
||||||
|
searchParams.delete(column);
|
||||||
|
searchParams.delete("page");
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
searchParams.set(column, value);
|
||||||
|
}
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedResource && (
|
{selectedResource && (
|
||||||
@@ -547,21 +601,25 @@ export default function ProxyResourcesTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={proxyColumns}
|
columns={proxyColumns}
|
||||||
data={resources}
|
rows={resources}
|
||||||
persistPageSize="proxy-resources"
|
tableId="proxy-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
router.push(`/${orgId}/settings/resources/proxy/create`)
|
startNavigation(() =>
|
||||||
|
router.push(`/${orgId}/settings/resources/proxy/create`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
defaultSort={defaultSort}
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility
|
||||||
persistColumnVisibility="proxy-resources"
|
|
||||||
columnVisibility={{ niceId: false }}
|
columnVisibility={{ niceId: false }}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -569,3 +627,43 @@ export default function ProxyResourcesTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResourceEnabledFormProps = {
|
||||||
|
resource: ResourceRow;
|
||||||
|
onToggleResourceEnabled: (
|
||||||
|
val: boolean,
|
||||||
|
resourceId: number
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResourceEnabledForm({
|
||||||
|
resource,
|
||||||
|
onToggleResourceEnabled
|
||||||
|
}: ResourceEnabledFormProps) {
|
||||||
|
const enabled = resource.http
|
||||||
|
? !!resource.domainId && resource.enabled
|
||||||
|
: resource.enabled;
|
||||||
|
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||||
|
|
||||||
|
const formRef = useRef<ComponentRef<"form">>(null);
|
||||||
|
|
||||||
|
async function submitAction(formData: FormData) {
|
||||||
|
const newEnabled = !(formData.get("enabled") === "on");
|
||||||
|
setOptimisticEnabled(newEnabled);
|
||||||
|
await onToggleResourceEnabled(newEnabled, resource.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={submitAction} ref={formRef}>
|
||||||
|
<Switch
|
||||||
|
checked={optimisticEnabled}
|
||||||
|
disabled={
|
||||||
|
(resource.http && !resource.domainId) ||
|
||||||
|
optimisticEnabled !== enabled
|
||||||
|
}
|
||||||
|
name="enabled"
|
||||||
|
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { DataTable } from "@app/components/ui/data-table";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
|
||||||
columns: ColumnDef<TData, TValue>[];
|
|
||||||
data: TData[];
|
|
||||||
createSite?: () => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
isRefreshing?: boolean;
|
|
||||||
columnVisibility?: Record<string, boolean>;
|
|
||||||
enableColumnVisibility?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SitesDataTable<TData, TValue>({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
createSite,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
columnVisibility,
|
|
||||||
enableColumnVisibility
|
|
||||||
}: DataTableProps<TData, TValue>) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={data}
|
|
||||||
persistPageSize="sites-table"
|
|
||||||
title={t("sites")}
|
|
||||||
searchPlaceholder={t("searchSitesProgress")}
|
|
||||||
searchColumn="name"
|
|
||||||
onAdd={createSite}
|
|
||||||
addButtonText={t("siteAdd")}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
defaultSort={{
|
|
||||||
id: "name",
|
|
||||||
desc: false
|
|
||||||
}}
|
|
||||||
columnVisibility={columnVisibility}
|
|
||||||
enableColumnVisibility={enableColumnVisibility}
|
|
||||||
stickyLeftColumn="name"
|
|
||||||
stickyRightColumn="actions"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+192
-158
@@ -1,37 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Column, ColumnDef } from "@tanstack/react-table";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
|
||||||
import { SitesDataTable } from "@app/components/SitesDataTable";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUpRight,
|
|
||||||
Check,
|
|
||||||
MoreHorizontal,
|
|
||||||
X
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { parseDataSize } from "@app/lib/dataSize";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { type PaginationState } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp10Icon,
|
||||||
|
ArrowUpRight,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
MoreHorizontal
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import {
|
||||||
|
ControlledDataTable,
|
||||||
|
type ExtendedColumnDef
|
||||||
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -52,79 +57,91 @@ export type SiteRow = {
|
|||||||
|
|
||||||
type SitesTableProps = {
|
type SitesTableProps = {
|
||||||
sites: SiteRow[];
|
sites: SiteRow[];
|
||||||
|
pagination: PaginationState;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
rowCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
export default function SitesTable({
|
||||||
|
sites,
|
||||||
|
orgId,
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
|
}: SitesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
const [rows, setRows] = useState<SiteRow[]>(sites);
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
|
||||||
|
|
||||||
// Update local state when props change (e.g., after refresh)
|
const booleanSearchFilterSchema = z
|
||||||
useEffect(() => {
|
.enum(["true", "false"])
|
||||||
setRows(sites);
|
.optional()
|
||||||
}, [sites]);
|
.catch(undefined);
|
||||||
|
|
||||||
const refreshData = async () => {
|
function handleFilterChange(
|
||||||
console.log("Data refreshed");
|
column: string,
|
||||||
setIsRefreshing(true);
|
value: string | undefined | null
|
||||||
try {
|
) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
const sp = new URLSearchParams(searchParams);
|
||||||
router.refresh();
|
sp.delete(column);
|
||||||
} catch (error) {
|
sp.delete("page");
|
||||||
toast({
|
|
||||||
title: t("error"),
|
if (value) {
|
||||||
description: t("refreshError"),
|
sp.set(column, value);
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||||
|
}
|
||||||
|
|
||||||
const deleteSite = (siteId: number) => {
|
function refreshData() {
|
||||||
api.delete(`/site/${siteId}`)
|
startTransition(async () => {
|
||||||
.catch((e) => {
|
try {
|
||||||
console.error(t("siteErrorDelete"), e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("siteErrorDelete"),
|
|
||||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setIsDeleteModalOpen(false);
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newRows = rows.filter((row) => row.id !== siteId);
|
function deleteSite(siteId: number) {
|
||||||
|
startTransition(async () => {
|
||||||
setRows(newRows);
|
await api
|
||||||
});
|
.delete(`/site/${siteId}`)
|
||||||
};
|
.catch((e) => {
|
||||||
|
console.error(t("siteErrorDelete"), e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("siteErrorDelete"),
|
||||||
|
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
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>;
|
||||||
@@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("online"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ColumnFilterButton
|
||||||
variant="ghost"
|
options={[
|
||||||
onClick={() =>
|
{ value: "true", label: t("online") },
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
{ value: "false", label: t("offline") }
|
||||||
|
]}
|
||||||
|
selectedValue={booleanSearchFilterSchema.parse(
|
||||||
|
searchParams.get("online")
|
||||||
|
)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("online", value)
|
||||||
}
|
}
|
||||||
>
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
{t("online")}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
label={t("online")}
|
||||||
</Button>
|
className="p-3"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -194,58 +208,59 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: t("dataIn"),
|
friendlyName: t("dataIn"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataInOrder = getSortDirection(
|
||||||
|
"megabytesIn",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("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>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
sortingFn: (rowA, rowB) =>
|
|
||||||
parseDataSize(rowA.original.mbIn) -
|
|
||||||
parseDataSize(rowB.original.mbIn)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbOut",
|
accessorKey: "mbOut",
|
||||||
friendlyName: t("dataOut"),
|
friendlyName: t("dataOut"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataOutOrder = getSortDirection(
|
||||||
|
"megabytesOut",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("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>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
sortingFn: (rowA, rowB) =>
|
|
||||||
parseDataSize(rowA.original.mbOut) -
|
|
||||||
parseDataSize(rowB.original.mbOut)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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;
|
||||||
@@ -290,18 +305,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
{
|
{
|
||||||
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;
|
||||||
@@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
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;
|
||||||
@@ -428,6 +423,30 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedSite && (
|
{selectedSite && (
|
||||||
@@ -444,27 +463,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText={t("siteConfirmDelete")}
|
buttonText={t("siteConfirmDelete")}
|
||||||
onConfirm={async () => deleteSite(selectedSite!.id)}
|
onConfirm={async () =>
|
||||||
|
startTransition(() => deleteSite(selectedSite!.id))
|
||||||
|
}
|
||||||
string={selectedSite.name}
|
string={selectedSite.name}
|
||||||
title={t("siteDelete")}
|
title={t("siteDelete")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SitesDataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
rows={sites}
|
||||||
createSite={() =>
|
tableId="sites-table"
|
||||||
router.push(`/${orgId}/settings/sites/create`)
|
searchPlaceholder={t("searchSitesProgress")}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
onAdd={() =>
|
||||||
|
startNavigation(() =>
|
||||||
|
router.push(`/${orgId}/settings/sites/create`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
addButtonText={t("siteAdd")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
|
rowCount={rowCount}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
nice: false,
|
nice: false,
|
||||||
exitNode: false,
|
exitNode: false,
|
||||||
address: false
|
address: false
|
||||||
}}
|
}}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility
|
||||||
|
stickyLeftColumn="name"
|
||||||
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import * as React from "react";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { durationToMs } from "@app/lib/durationToMs";
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
export type ReactQueryProviderProps = {
|
export type ReactQueryProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
meta: {
|
meta: {
|
||||||
api
|
api
|
||||||
}
|
},
|
||||||
|
placeholderData: keepPreviousData
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
meta: { api }
|
meta: { api }
|
||||||
|
|||||||
+233
-178
@@ -2,34 +2,41 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
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 { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
|
import { build } from "@server/build";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUp10Icon,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
MoreHorizontal,
|
ChevronsUpDownIcon,
|
||||||
CircleSlash
|
CircleSlash,
|
||||||
|
MoreHorizontal
|
||||||
} 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";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import ClientDownloadBanner from "./ClientDownloadBanner";
|
import ClientDownloadBanner from "./ClientDownloadBanner";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { build } from "@server/build";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -65,9 +72,15 @@ export type ClientRow = {
|
|||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
userClients: ClientRow[];
|
userClients: ClientRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
pagination: PaginationState;
|
||||||
|
rowCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
export default function UserDevicesTable({
|
||||||
|
userClients,
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
|
}: ClientTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -77,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const defaultUserColumnVisibility = {
|
const defaultUserColumnVisibility = {
|
||||||
@@ -188,8 +206,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
try {
|
try {
|
||||||
// Fetch approvalId for this client using clientId query parameter
|
// Fetch approvalId for this client using clientId query parameter
|
||||||
const approvalsRes = await api.get<{
|
const approvalsRes = await api.get<{
|
||||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
data: {
|
||||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
approvals: Array<{ approvalId: number; clientId: number }>;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
|
||||||
|
);
|
||||||
|
|
||||||
const approval = approvalsRes.data.data.approvals[0];
|
const approval = approvalsRes.data.data.approvals[0];
|
||||||
|
|
||||||
@@ -202,9 +224,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
|
await api.put(
|
||||||
decision: "approved"
|
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
|
||||||
});
|
{
|
||||||
|
decision: "approved"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("accessApprovalUpdated"),
|
title: t("accessApprovalUpdated"),
|
||||||
@@ -230,8 +255,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
try {
|
try {
|
||||||
// Fetch approvalId for this client using clientId query parameter
|
// Fetch approvalId for this client using clientId query parameter
|
||||||
const approvalsRes = await api.get<{
|
const approvalsRes = await api.get<{
|
||||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
data: {
|
||||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
approvals: Array<{ approvalId: number; clientId: number }>;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
|
||||||
|
);
|
||||||
|
|
||||||
const approval = approvalsRes.data.data.approvals[0];
|
const approval = approvalsRes.data.data.approvals[0];
|
||||||
|
|
||||||
@@ -244,9 +273,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
|
await api.put(
|
||||||
decision: "denied"
|
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
|
||||||
});
|
{
|
||||||
|
decision: "denied"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("accessApprovalUpdated"),
|
title: t("accessApprovalUpdated"),
|
||||||
@@ -279,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: t("name"),
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("name")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
const fingerprintInfo = r.fingerprint
|
const fingerprintInfo = r.fingerprint
|
||||||
@@ -343,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "niceId",
|
accessorKey: "niceId",
|
||||||
friendlyName: t("identifier"),
|
friendlyName: t("identifier"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("identifier")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
friendlyName: t("users"),
|
friendlyName: t("users"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("users")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("users")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return r.userId ? (
|
return r.userId ? (
|
||||||
@@ -398,20 +388,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("connectivity"),
|
friendlyName: t("online"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ColumnFilterButton
|
||||||
variant="ghost"
|
options={[
|
||||||
onClick={() =>
|
{
|
||||||
column.toggleSorting(
|
value: "true",
|
||||||
column.getIsSorted() === "asc"
|
label: t("connected")
|
||||||
)
|
},
|
||||||
|
{
|
||||||
|
value: "false",
|
||||||
|
label: t("disconnected")
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={
|
||||||
|
searchParams.get("online") ?? undefined
|
||||||
}
|
}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
{t("online")}
|
handleFilterChange("online", value)
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
}
|
||||||
</Button>
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("online")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -436,18 +437,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: t("dataIn"),
|
friendlyName: t("dataIn"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataInOrder = getSortDirection(
|
||||||
|
"megabytesIn",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -455,18 +463,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "mbOut",
|
accessorKey: "mbOut",
|
||||||
friendlyName: t("dataOut"),
|
friendlyName: t("dataOut"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataOutOrder = getSortDirection(
|
||||||
|
"megabytesOut",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "client",
|
accessorKey: "client",
|
||||||
friendlyName: t("agent"),
|
friendlyName: t("agent"),
|
||||||
header: ({ column }) => {
|
header: () => (
|
||||||
return (
|
<ColumnFilterButton
|
||||||
<Button
|
options={[
|
||||||
variant="ghost"
|
{
|
||||||
onClick={() =>
|
value: "macos",
|
||||||
column.toggleSorting(
|
label: "Pangolin macOS"
|
||||||
column.getIsSorted() === "asc"
|
},
|
||||||
)
|
{
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
>
|
]}
|
||||||
{t("agent")}
|
selectedValue={searchParams.get("agent") ?? undefined}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
onValueChange={(value) =>
|
||||||
</Button>
|
handleFilterChange("agent", value)
|
||||||
);
|
}
|
||||||
},
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("agent")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
|
||||||
@@ -514,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "subnet",
|
accessorKey: "subnet",
|
||||||
friendlyName: t("address"),
|
friendlyName: t("address"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("address")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("address")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{clientRow.approvalState === "pending" && (
|
{clientRow.approvalState === "pending" &&
|
||||||
<>
|
build !== "oss" && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={() => approveDevice(clientRow)}
|
<DropdownMenuItem
|
||||||
>
|
onClick={() =>
|
||||||
<span>{t("approve")}</span>
|
approveDevice(clientRow)
|
||||||
</DropdownMenuItem>
|
}
|
||||||
<DropdownMenuItem
|
>
|
||||||
onClick={() => denyDevice(clientRow)}
|
<span>{t("approve")}</span>
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<span>{t("deny")}</span>
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() =>
|
||||||
</>
|
denyDevice(clientRow)
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
|
<span>{t("deny")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (clientRow.archived) {
|
if (clientRow.archived) {
|
||||||
@@ -621,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, t]);
|
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||||
|
|
||||||
const statusFilterOptions = useMemo(() => {
|
const statusFilterOptions = useMemo(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
@@ -652,12 +689,59 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (build === "oss") {
|
||||||
|
return allOptions.filter(
|
||||||
|
(option) =>
|
||||||
|
option.value !== "pending" && option.value !== "denied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return allOptions;
|
return allOptions;
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const statusFilterDefaultValues = useMemo(() => {
|
function handleFilterChange(
|
||||||
return ["active", "pending"];
|
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());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -682,17 +766,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
)}
|
)}
|
||||||
<ClientDownloadBanner />
|
<ClientDownloadBanner />
|
||||||
|
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={userClients || []}
|
rows={userClients || []}
|
||||||
persistPageSize="user-clients"
|
tableId="user-clients"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility
|
||||||
persistColumnVisibility="user-clients"
|
|
||||||
columnVisibility={defaultUserColumnVisibility}
|
columnVisibility={defaultUserColumnVisibility}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
filters={[
|
filters={[
|
||||||
@@ -702,41 +788,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
displayMode: "calculated",
|
displayMode: "calculated",
|
||||||
options: statusFilterOptions,
|
options: statusFilterOptions,
|
||||||
filterFn: (
|
onValueChange: (selectedValues: string[]) => {
|
||||||
row: ClientRow,
|
handleFilterChange("status", selectedValues);
|
||||||
selectedValues: (string | number | boolean)[]
|
|
||||||
) => {
|
|
||||||
if (selectedValues.length === 0) return true;
|
|
||||||
const rowArchived = row.archived;
|
|
||||||
const rowBlocked = row.blocked;
|
|
||||||
const approvalState = row.approvalState;
|
|
||||||
const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied";
|
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("pending") &&
|
|
||||||
approvalState === "pending"
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("denied") &&
|
|
||||||
approvalState === "denied"
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("archived") &&
|
|
||||||
rowArchived
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("blocked") &&
|
|
||||||
rowBlocked
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
defaultValues: statusFilterDefaultValues
|
values: searchParams.getAll("status")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||||
|
|
||||||
|
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
|
||||||
|
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
|
||||||
|
TData,
|
||||||
|
TValue
|
||||||
|
> & {
|
||||||
|
friendlyName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataTableFilter = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
multiSelect?: boolean;
|
||||||
|
onValueChange: (selectedValues: string[]) => void;
|
||||||
|
values?: string[];
|
||||||
|
displayMode?: "label" | "calculated"; // How to display the filter button text
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
||||||
|
|
||||||
|
type ControlledDataTableProps<TData, TValue> = {
|
||||||
|
columns: ExtendedColumnDef<TData, TValue>[];
|
||||||
|
rows: TData[];
|
||||||
|
tableId: string;
|
||||||
|
addButtonText?: string;
|
||||||
|
onAdd?: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
isNavigatingToAddPage?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
filters?: DataTableFilter[];
|
||||||
|
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
|
||||||
|
columnVisibility?: Record<string, boolean>;
|
||||||
|
enableColumnVisibility?: boolean;
|
||||||
|
onSearch?: (input: string) => void;
|
||||||
|
searchQuery?: string;
|
||||||
|
onPaginationChange: DataTablePaginationUpdateFn;
|
||||||
|
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
||||||
|
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
|
||||||
|
rowCount: number;
|
||||||
|
pagination: PaginationState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ControlledDataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
addButtonText,
|
||||||
|
onAdd,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
filters,
|
||||||
|
filterDisplayMode = "label",
|
||||||
|
columnVisibility: defaultColumnVisibility,
|
||||||
|
enableColumnVisibility = false,
|
||||||
|
tableId,
|
||||||
|
pagination,
|
||||||
|
stickyLeftColumn,
|
||||||
|
onSearch,
|
||||||
|
searchQuery,
|
||||||
|
onPaginationChange,
|
||||||
|
stickyRightColumn,
|
||||||
|
rowCount,
|
||||||
|
isNavigatingToAddPage
|
||||||
|
}: ControlledDataTableProps<TData, TValue>) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility(
|
||||||
|
tableId,
|
||||||
|
defaultColumnVisibility
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: filters
|
||||||
|
const activeFilters = useMemo(() => {
|
||||||
|
const initial: Record<string, string[]> = {};
|
||||||
|
filters?.forEach((filter) => {
|
||||||
|
initial[filter.id] = filter.values || [];
|
||||||
|
});
|
||||||
|
return initial;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onPaginationChange: (state) => {
|
||||||
|
const newState =
|
||||||
|
typeof state === "function" ? state(pagination) : state;
|
||||||
|
onPaginationChange(newState);
|
||||||
|
},
|
||||||
|
manualFiltering: true,
|
||||||
|
manualPagination: true,
|
||||||
|
rowCount,
|
||||||
|
state: {
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
pagination
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate display text for a filter based on selected values
|
||||||
|
const getFilterDisplayText = (filter: DataTableFilter): string => {
|
||||||
|
const selectedValues = activeFilters[filter.id] || [];
|
||||||
|
|
||||||
|
if (selectedValues.length === 0) {
|
||||||
|
return filter.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptions = filter.options.filter((option) =>
|
||||||
|
selectedValues.includes(option.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedOptions.length === 0) {
|
||||||
|
return filter.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOptions.length === 1) {
|
||||||
|
return selectedOptions[0].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple selections: always join with "and"
|
||||||
|
return selectedOptions.map((opt) => opt.label).join(" or ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (
|
||||||
|
filterId: string,
|
||||||
|
optionValue: string,
|
||||||
|
checked: boolean
|
||||||
|
) => {
|
||||||
|
const currentValues = activeFilters[filterId] || [];
|
||||||
|
const filter = filters?.find((f) => f.id === filterId);
|
||||||
|
|
||||||
|
if (!filter) return;
|
||||||
|
|
||||||
|
let newValues: string[];
|
||||||
|
|
||||||
|
if (filter.multiSelect) {
|
||||||
|
// Multi-select: add or remove the value
|
||||||
|
if (checked) {
|
||||||
|
newValues = [...currentValues, optionValue];
|
||||||
|
} else {
|
||||||
|
newValues = currentValues.filter((v) => v !== optionValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-select: replace the value
|
||||||
|
newValues = checked ? [optionValue] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.onValueChange(newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a column should be sticky
|
||||||
|
const isStickyColumn = (
|
||||||
|
columnId: string | undefined,
|
||||||
|
accessorKey: string | undefined,
|
||||||
|
position: "left" | "right"
|
||||||
|
): boolean => {
|
||||||
|
if (position === "left" && stickyLeftColumn) {
|
||||||
|
return (
|
||||||
|
columnId === stickyLeftColumn ||
|
||||||
|
accessorKey === stickyLeftColumn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (position === "right" && stickyRightColumn) {
|
||||||
|
return (
|
||||||
|
columnId === stickyRightColumn ||
|
||||||
|
accessorKey === stickyRightColumn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get sticky column classes
|
||||||
|
const getStickyClasses = (
|
||||||
|
columnId: string | undefined,
|
||||||
|
accessorKey: string | undefined
|
||||||
|
): string => {
|
||||||
|
if (isStickyColumn(columnId, accessorKey, "left")) {
|
||||||
|
return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]";
|
||||||
|
}
|
||||||
|
if (isStickyColumn(columnId, accessorKey, "right")) {
|
||||||
|
return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||||
|
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||||
|
{onSearch && (
|
||||||
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSearch(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
className="w-full pl-8"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const selectedValues =
|
||||||
|
activeFilters[filter.id] || [];
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedValues.length > 0;
|
||||||
|
const displayMode =
|
||||||
|
filter.displayMode || filterDisplayMode;
|
||||||
|
const displayText =
|
||||||
|
displayMode === "calculated"
|
||||||
|
? getFilterDisplayText(filter)
|
||||||
|
: filter.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu key={filter.id}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
{displayText}
|
||||||
|
{displayMode === "label" &&
|
||||||
|
hasActiveFilters && (
|
||||||
|
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||||
|
{
|
||||||
|
selectedValues.length
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-48"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{filter.label}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{filter.options.map(
|
||||||
|
(option) => {
|
||||||
|
const isChecked =
|
||||||
|
selectedValues.includes(
|
||||||
|
option.value
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={option.id}
|
||||||
|
checked={
|
||||||
|
isChecked
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
handleFilterChange(
|
||||||
|
filter.id,
|
||||||
|
option.value,
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onSelect={(e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 sm:justify-end">
|
||||||
|
{onRefresh && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{t("refresh")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onAdd && addButtonText && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={onAdd}
|
||||||
|
loading={isNavigatingToAddPage}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const columnId = header.column.id;
|
||||||
|
const accessorKey = (
|
||||||
|
header.column.columnDef as any
|
||||||
|
).accessorKey as string | undefined;
|
||||||
|
const stickyClasses =
|
||||||
|
getStickyClasses(
|
||||||
|
columnId,
|
||||||
|
accessorKey
|
||||||
|
);
|
||||||
|
const isRightSticky =
|
||||||
|
isStickyColumn(
|
||||||
|
columnId,
|
||||||
|
accessorKey,
|
||||||
|
"right"
|
||||||
|
);
|
||||||
|
const hasHideableColumns =
|
||||||
|
enableColumnVisibility &&
|
||||||
|
table
|
||||||
|
.getAllColumns()
|
||||||
|
.some((col) =>
|
||||||
|
col.getCanHide()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className={`whitespace-nowrap ${stickyClasses}`}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : isRightSticky &&
|
||||||
|
hasHideableColumns ? (
|
||||||
|
<div className="flex flex-col items-end pr-3">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 mb-1"
|
||||||
|
>
|
||||||
|
<Columns className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
{t(
|
||||||
|
"columns"
|
||||||
|
) ||
|
||||||
|
"Columns"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-48"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{t(
|
||||||
|
"toggleColumns"
|
||||||
|
) ||
|
||||||
|
"Toggle columns"}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
column
|
||||||
|
) =>
|
||||||
|
column.getCanHide()
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
column
|
||||||
|
) => {
|
||||||
|
const columnDef =
|
||||||
|
column.columnDef as any;
|
||||||
|
const friendlyName =
|
||||||
|
columnDef.friendlyName;
|
||||||
|
const displayName =
|
||||||
|
friendlyName ||
|
||||||
|
(typeof columnDef.header ===
|
||||||
|
"string"
|
||||||
|
? columnDef.header
|
||||||
|
: column.id);
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={
|
||||||
|
column.id
|
||||||
|
}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(
|
||||||
|
value
|
||||||
|
) =>
|
||||||
|
column.toggleVisibility(
|
||||||
|
!!value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSelect={(
|
||||||
|
e
|
||||||
|
) =>
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<div className="h-0 opacity-0 pointer-events-none overflow-hidden">
|
||||||
|
{flexRender(
|
||||||
|
header
|
||||||
|
.column
|
||||||
|
.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flexRender(
|
||||||
|
header.column
|
||||||
|
.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext()
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={
|
||||||
|
row.getIsSelected() &&
|
||||||
|
"selected"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell) => {
|
||||||
|
const columnId =
|
||||||
|
cell.column.id;
|
||||||
|
const accessorKey = (
|
||||||
|
cell.column
|
||||||
|
.columnDef as any
|
||||||
|
).accessorKey as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const stickyClasses =
|
||||||
|
getStickyClasses(
|
||||||
|
columnId,
|
||||||
|
accessorKey
|
||||||
|
);
|
||||||
|
const isRightSticky =
|
||||||
|
isStickyColumn(
|
||||||
|
columnId,
|
||||||
|
accessorKey,
|
||||||
|
"right"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={`whitespace-nowrap ${stickyClasses} ${isRightSticky ? "text-right" : ""}`}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column
|
||||||
|
.columnDef
|
||||||
|
.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{rowCount > 0 && (
|
||||||
|
<DataTablePagination
|
||||||
|
table={table}
|
||||||
|
totalCount={rowCount}
|
||||||
|
onPageSizeChange={(pageSize) =>
|
||||||
|
onPaginationChange({
|
||||||
|
...pagination,
|
||||||
|
pageSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onPageChange={(pageIndex) => {
|
||||||
|
onPaginationChange({
|
||||||
|
...pagination,
|
||||||
|
pageIndex
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isServerPagination
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageIndex={pagination.pageIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -151,11 +151,20 @@ type DataTableFilter = {
|
|||||||
label: string;
|
label: string;
|
||||||
options: FilterOption[];
|
options: FilterOption[];
|
||||||
multiSelect?: boolean;
|
multiSelect?: boolean;
|
||||||
filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean;
|
filterFn: (
|
||||||
|
row: any,
|
||||||
|
selectedValues: (string | number | boolean)[]
|
||||||
|
) => boolean;
|
||||||
defaultValues?: (string | number | boolean)[];
|
defaultValues?: (string | number | boolean)[];
|
||||||
displayMode?: "label" | "calculated"; // How to display the filter button text
|
displayMode?: "label" | "calculated"; // How to display the filter button text
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataTablePaginationState = PaginationState & {
|
||||||
|
pageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
||||||
|
|
||||||
type DataTableProps<TData, TValue> = {
|
type DataTableProps<TData, TValue> = {
|
||||||
columns: ExtendedColumnDef<TData, TValue>[];
|
columns: ExtendedColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
@@ -178,6 +187,11 @@ type DataTableProps<TData, TValue> = {
|
|||||||
defaultPageSize?: number;
|
defaultPageSize?: number;
|
||||||
columnVisibility?: Record<string, boolean>;
|
columnVisibility?: Record<string, boolean>;
|
||||||
enableColumnVisibility?: boolean;
|
enableColumnVisibility?: boolean;
|
||||||
|
manualFiltering?: boolean;
|
||||||
|
onSearch?: (input: string) => void;
|
||||||
|
searchQuery?: string;
|
||||||
|
pagination?: DataTablePaginationState;
|
||||||
|
onPaginationChange?: DataTablePaginationUpdateFn;
|
||||||
persistColumnVisibility?: boolean | string;
|
persistColumnVisibility?: boolean | string;
|
||||||
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
||||||
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
|
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
|
||||||
@@ -203,7 +217,12 @@ export function DataTable<TData, TValue>({
|
|||||||
columnVisibility: defaultColumnVisibility,
|
columnVisibility: defaultColumnVisibility,
|
||||||
enableColumnVisibility = false,
|
enableColumnVisibility = false,
|
||||||
persistColumnVisibility = false,
|
persistColumnVisibility = false,
|
||||||
|
manualFiltering = false,
|
||||||
|
pagination: paginationState,
|
||||||
stickyLeftColumn,
|
stickyLeftColumn,
|
||||||
|
onSearch,
|
||||||
|
searchQuery,
|
||||||
|
onPaginationChange,
|
||||||
stickyRightColumn
|
stickyRightColumn
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -248,22 +267,25 @@ export function DataTable<TData, TValue>({
|
|||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||||
initialColumnVisibility
|
initialColumnVisibility
|
||||||
);
|
);
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [_pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pagination = paginationState ?? _pagination;
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(
|
const [activeTab, setActiveTab] = useState<string>(
|
||||||
defaultTab || tabs?.[0]?.id || ""
|
defaultTab || tabs?.[0]?.id || ""
|
||||||
);
|
);
|
||||||
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>(
|
const [activeFilters, setActiveFilters] = useState<
|
||||||
() => {
|
Record<string, (string | number | boolean)[]>
|
||||||
const initial: Record<string, (string | number | boolean)[]> = {};
|
>(() => {
|
||||||
filters?.forEach((filter) => {
|
const initial: Record<string, (string | number | boolean)[]> = {};
|
||||||
initial[filter.id] = filter.defaultValues || [];
|
filters?.forEach((filter) => {
|
||||||
});
|
initial[filter.id] = filter.defaultValues || [];
|
||||||
return initial;
|
});
|
||||||
}
|
return initial;
|
||||||
);
|
});
|
||||||
|
|
||||||
// Track initial values to avoid storing defaults on first render
|
// Track initial values to avoid storing defaults on first render
|
||||||
const initialPageSize = useRef(pageSize);
|
const initialPageSize = useRef(pageSize);
|
||||||
@@ -298,6 +320,11 @@ export function DataTable<TData, TValue>({
|
|||||||
return result;
|
return result;
|
||||||
}, [data, tabs, activeTab, filters, activeFilters]);
|
}, [data, tabs, activeTab, filters, activeFilters]);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
pagination,
|
||||||
|
paginationState
|
||||||
|
});
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
@@ -309,12 +336,18 @@ export function DataTable<TData, TValue>({
|
|||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: onPaginationChange
|
||||||
|
? (state) => {
|
||||||
|
const newState =
|
||||||
|
typeof state === "function" ? state(pagination) : state;
|
||||||
|
onPaginationChange(newState);
|
||||||
|
}
|
||||||
|
: setPagination,
|
||||||
|
manualFiltering,
|
||||||
|
manualPagination: Boolean(paginationState),
|
||||||
|
pageCount: paginationState?.pageCount,
|
||||||
initialState: {
|
initialState: {
|
||||||
pagination: {
|
pagination,
|
||||||
pageSize: pageSize,
|
|
||||||
pageIndex: 0
|
|
||||||
},
|
|
||||||
columnVisibility: initialColumnVisibility
|
columnVisibility: initialColumnVisibility
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
@@ -368,11 +401,11 @@ export function DataTable<TData, TValue>({
|
|||||||
setActiveFilters((prev) => {
|
setActiveFilters((prev) => {
|
||||||
const currentValues = prev[filterId] || [];
|
const currentValues = prev[filterId] || [];
|
||||||
const filter = filters?.find((f) => f.id === filterId);
|
const filter = filters?.find((f) => f.id === filterId);
|
||||||
|
|
||||||
if (!filter) return prev;
|
if (!filter) return prev;
|
||||||
|
|
||||||
let newValues: (string | number | boolean)[];
|
let newValues: (string | number | boolean)[];
|
||||||
|
|
||||||
if (filter.multiSelect) {
|
if (filter.multiSelect) {
|
||||||
// Multi-select: add or remove the value
|
// Multi-select: add or remove the value
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -397,7 +430,7 @@ export function DataTable<TData, TValue>({
|
|||||||
// Calculate display text for a filter based on selected values
|
// Calculate display text for a filter based on selected values
|
||||||
const getFilterDisplayText = (filter: DataTableFilter): string => {
|
const getFilterDisplayText = (filter: DataTableFilter): string => {
|
||||||
const selectedValues = activeFilters[filter.id] || [];
|
const selectedValues = activeFilters[filter.id] || [];
|
||||||
|
|
||||||
if (selectedValues.length === 0) {
|
if (selectedValues.length === 0) {
|
||||||
return filter.label;
|
return filter.label;
|
||||||
}
|
}
|
||||||
@@ -477,12 +510,15 @@ export function DataTable<TData, TValue>({
|
|||||||
<div className="relative w-full sm:max-w-sm">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<Input
|
<Input
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={globalFilter ?? ""}
|
defaultValue={searchQuery}
|
||||||
onChange={(e) =>
|
value={onSearch ? undefined : globalFilter}
|
||||||
table.setGlobalFilter(
|
onChange={(e) => {
|
||||||
String(e.target.value)
|
onSearch
|
||||||
)
|
? onSearch(e.currentTarget.value)
|
||||||
}
|
: table.setGlobalFilter(
|
||||||
|
String(e.target.value)
|
||||||
|
);
|
||||||
|
}}
|
||||||
className="w-full pl-8"
|
className="w-full pl-8"
|
||||||
/>
|
/>
|
||||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
@@ -490,13 +526,17 @@ export function DataTable<TData, TValue>({
|
|||||||
{filters && filters.length > 0 && (
|
{filters && filters.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
const selectedValues = activeFilters[filter.id] || [];
|
const selectedValues =
|
||||||
const hasActiveFilters = selectedValues.length > 0;
|
activeFilters[filter.id] || [];
|
||||||
const displayMode = filter.displayMode || filterDisplayMode;
|
const hasActiveFilters =
|
||||||
const displayText = displayMode === "calculated"
|
selectedValues.length > 0;
|
||||||
? getFilterDisplayText(filter)
|
const displayMode =
|
||||||
: filter.label;
|
filter.displayMode || filterDisplayMode;
|
||||||
|
const displayText =
|
||||||
|
displayMode === "calculated"
|
||||||
|
? getFilterDisplayText(filter)
|
||||||
|
: filter.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu key={filter.id}>
|
<DropdownMenu key={filter.id}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -507,37 +547,54 @@ export function DataTable<TData, TValue>({
|
|||||||
>
|
>
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
{displayText}
|
{displayText}
|
||||||
{displayMode === "label" && hasActiveFilters && (
|
{displayMode === "label" &&
|
||||||
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
hasActiveFilters && (
|
||||||
{selectedValues.length}
|
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||||
</span>
|
{
|
||||||
)}
|
selectedValues.length
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-48"
|
||||||
|
>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{filter.options.map((option) => {
|
{filter.options.map(
|
||||||
const isChecked = selectedValues.includes(option.value);
|
(option) => {
|
||||||
return (
|
const isChecked =
|
||||||
<DropdownMenuCheckboxItem
|
selectedValues.includes(
|
||||||
key={option.id}
|
option.value
|
||||||
checked={isChecked}
|
);
|
||||||
onCheckedChange={(checked) =>
|
return (
|
||||||
handleFilterChange(
|
<DropdownMenuCheckboxItem
|
||||||
filter.id,
|
key={option.id}
|
||||||
option.value,
|
checked={
|
||||||
|
isChecked
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
checked
|
checked
|
||||||
)
|
) =>
|
||||||
}
|
handleFilterChange(
|
||||||
onSelect={(e) => e.preventDefault()}
|
filter.id,
|
||||||
>
|
option.value,
|
||||||
{option.label}
|
checked
|
||||||
</DropdownMenuCheckboxItem>
|
)
|
||||||
);
|
}
|
||||||
})}
|
onSelect={(e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
|
||||||
|
export function useNavigationContext() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const path = usePathname();
|
||||||
|
const [isNavigating, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function navigate({
|
||||||
|
searchParams: params,
|
||||||
|
pathname = path,
|
||||||
|
replace = false
|
||||||
|
}: {
|
||||||
|
pathname?: string;
|
||||||
|
searchParams?: URLSearchParams;
|
||||||
|
replace?: boolean;
|
||||||
|
}) {
|
||||||
|
startTransition(() => {
|
||||||
|
const fullPath = pathname + (params ? `?${params.toString()}` : "");
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
router.replace(fullPath);
|
||||||
|
} else {
|
||||||
|
router.push(fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pathname: path,
|
||||||
|
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
|
||||||
|
navigate,
|
||||||
|
isNavigating
|
||||||
|
};
|
||||||
|
}
|
||||||
+51
-17
@@ -18,11 +18,16 @@ import type {
|
|||||||
import type { ListTargetsResponse } from "@server/routers/target";
|
import type { ListTargetsResponse } from "@server/routers/target";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
import {
|
||||||
|
infiniteQueryOptions,
|
||||||
|
keepPreviousData,
|
||||||
|
queryOptions
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
|
import { wait } from "./wait";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -88,8 +93,7 @@ export const productUpdatesQueries = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const clientFilterSchema = z.object({
|
export const clientFilterSchema = z.object({
|
||||||
filter: z.enum(["machine", "user"]),
|
pageSize: z.int().prefault(1000).optional()
|
||||||
limit: z.int().prefault(1000).optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgQueries = {
|
export const orgQueries = {
|
||||||
@@ -98,14 +102,13 @@ export const orgQueries = {
|
|||||||
filters
|
filters
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
filters: z.infer<typeof clientFilterSchema>;
|
filters?: z.infer<typeof clientFilterSchema>;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
|
queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
...filters,
|
pageSize: (filters?.pageSize ?? 1000).toString()
|
||||||
limit: (filters.limit ?? 1000).toString()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
@@ -190,19 +193,16 @@ export const logAnalyticsFiltersSchema = z.object({
|
|||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
error: "timeStart must be a valid ISO date string"
|
error: "timeStart must be a valid ISO date string"
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
timeEnd: z
|
timeEnd: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => !isNaN(Date.parse(val)), {
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
error: "timeEnd must be a valid ISO date string"
|
error: "timeEnd must be a valid ISO date string"
|
||||||
})
|
})
|
||||||
.optional(),
|
|
||||||
resourceId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.int().positive())
|
|
||||||
.optional()
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
resourceId: z.coerce.number().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||||
@@ -363,22 +363,50 @@ export const approvalQueries = {
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
filters: z.infer<typeof approvalFiltersSchema>
|
filters: z.infer<typeof approvalFiltersSchema>
|
||||||
) =>
|
) =>
|
||||||
queryOptions({
|
infiniteQueryOptions({
|
||||||
queryKey: ["APPROVALS", orgId, filters] as const,
|
queryKey: ["APPROVALS", orgId, filters] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, pageParam, meta }) => {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
|
|
||||||
if (filters.approvalState) {
|
if (filters.approvalState) {
|
||||||
sp.set("approvalState", filters.approvalState);
|
sp.set("approvalState", filters.approvalState);
|
||||||
}
|
}
|
||||||
|
if (pageParam) {
|
||||||
|
sp.set("cursorPending", pageParam.cursorPending.toString());
|
||||||
|
sp.set(
|
||||||
|
"cursorTimestamp",
|
||||||
|
pageParam.cursorTimestamp.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<{ approvals: ApprovalItem[]; }>
|
AxiosResponse<{
|
||||||
|
approvals: ApprovalItem[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
cursorPending: number | null;
|
||||||
|
cursorTimestamp: number | null;
|
||||||
|
};
|
||||||
|
}>
|
||||||
>(`/org/${orgId}/approvals?${sp.toString()}`, {
|
>(`/org/${orgId}/approvals?${sp.toString()}`, {
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
},
|
||||||
|
initialPageParam: null as {
|
||||||
|
cursorPending: number;
|
||||||
|
cursorTimestamp: number;
|
||||||
|
} | null,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
getNextPageParam: ({ pagination }) =>
|
||||||
|
pagination.cursorPending != null &&
|
||||||
|
pagination.cursorTimestamp != null
|
||||||
|
? {
|
||||||
|
cursorPending: pagination.cursorPending,
|
||||||
|
cursorTimestamp: pagination.cursorTimestamp
|
||||||
|
}
|
||||||
|
: null
|
||||||
}),
|
}),
|
||||||
pendingCount: (orgId: string) =>
|
pendingCount: (orgId: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -390,6 +418,12 @@ export const approvalQueries = {
|
|||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
return res.data.data.count;
|
return res.data.data.count;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { SortOrder } from "@app/lib/types/sort";
|
||||||
|
|
||||||
|
export function getNextSortOrder(
|
||||||
|
column: string,
|
||||||
|
searchParams: URLSearchParams
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSortDirection(
|
||||||
|
column: string,
|
||||||
|
searchParams: URLSearchParams
|
||||||
|
) {
|
||||||
|
let currentDirection: SortOrder = "indeterminate";
|
||||||
|
|
||||||
|
if (searchParams.get("sort_by") === column) {
|
||||||
|
currentDirection =
|
||||||
|
(searchParams.get("order") as SortOrder) ?? "indeterminate";
|
||||||
|
}
|
||||||
|
return currentDirection;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type SortOrder = "asc" | "desc" | "indeterminate";
|
||||||
Reference in New Issue
Block a user