mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-21 02:49:40 -06:00
Merge branch 'dev' into clients-user
This commit is contained in:
@@ -14,8 +14,9 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
|
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
|
||||||
|
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
|
||||||
|
|
||||||
RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts
|
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
|
||||||
|
|
||||||
# Copy the appropriate TypeScript configuration based on build type
|
# Copy the appropriate TypeScript configuration based on build type
|
||||||
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
|
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
|
||||||
@@ -32,9 +33,9 @@ RUN mkdir -p dist
|
|||||||
RUN npm run next:build
|
RUN npm run next:build
|
||||||
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
|
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
|
||||||
RUN if [ "$DATABASE" = "pg" ]; then \
|
RUN if [ "$DATABASE" = "pg" ]; then \
|
||||||
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
|
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
|
||||||
else \
|
else \
|
||||||
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
|
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# test to make sure the build output is there and error if not
|
# test to make sure the build output is there and error if not
|
||||||
|
|||||||
@@ -438,6 +438,16 @@
|
|||||||
"inviteEmailSent": "Send invite email to user",
|
"inviteEmailSent": "Send invite email to user",
|
||||||
"inviteValid": "Valid For",
|
"inviteValid": "Valid For",
|
||||||
"selectDuration": "Select duration",
|
"selectDuration": "Select duration",
|
||||||
|
"selectResource": "Select Resource",
|
||||||
|
"filterByResource": "Filter By Resource",
|
||||||
|
"resetFilters": "Reset Filters",
|
||||||
|
"totalBlocked": "Requests Blocked By Pangolin",
|
||||||
|
"totalRequests": "Total Requests",
|
||||||
|
"requestsByCountry": "Requests By Country",
|
||||||
|
"requestsByDay": "Requests By Day",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"allowed": "Allowed",
|
||||||
|
"topCountries": "Top Countries",
|
||||||
"accessRoleSelect": "Select role",
|
"accessRoleSelect": "Select role",
|
||||||
"inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.",
|
"inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.",
|
||||||
"inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.",
|
"inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.",
|
||||||
@@ -704,6 +714,7 @@
|
|||||||
"resourceTransferSubmit": "Transfer Resource",
|
"resourceTransferSubmit": "Transfer Resource",
|
||||||
"siteDestination": "Destination Site",
|
"siteDestination": "Destination Site",
|
||||||
"searchSites": "Search sites",
|
"searchSites": "Search sites",
|
||||||
|
"countries": "Countries",
|
||||||
"accessRoleCreate": "Create Role",
|
"accessRoleCreate": "Create Role",
|
||||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
||||||
"accessRoleCreateSubmit": "Create Role",
|
"accessRoleCreateSubmit": "Create Role",
|
||||||
@@ -1158,7 +1169,9 @@
|
|||||||
"sidebarProxyResources": "Proxy Resources",
|
"sidebarProxyResources": "Proxy Resources",
|
||||||
"sidebarClientResources": "Client Resources",
|
"sidebarClientResources": "Client Resources",
|
||||||
"sidebarAccessControl": "Access Control",
|
"sidebarAccessControl": "Access Control",
|
||||||
|
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||||
"sidebarUsers": "Users",
|
"sidebarUsers": "Users",
|
||||||
|
"sidebarAdmin": "Admin",
|
||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Links",
|
"sidebarShareableLinks": "Links",
|
||||||
@@ -1171,7 +1184,11 @@
|
|||||||
"sidebarUserDevices": "User Devices",
|
"sidebarUserDevices": "User Devices",
|
||||||
"sidebarMachineClients": "Machine Clients",
|
"sidebarMachineClients": "Machine Clients",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
|
"sidebarGeneral": "General",
|
||||||
|
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
|
"sidebarOrganization": "Organization",
|
||||||
|
"sidebarLogsAnalytics": "Request Analytics",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
@@ -2028,6 +2045,7 @@
|
|||||||
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
|
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
|
||||||
"sidebarLogs": "Logs",
|
"sidebarLogs": "Logs",
|
||||||
"request": "Request",
|
"request": "Request",
|
||||||
|
"requests": "Requests",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"logsSettingsDescription": "Monitor logs collected from this orginization",
|
"logsSettingsDescription": "Monitor logs collected from this orginization",
|
||||||
"searchLogs": "Search logs...",
|
"searchLogs": "Search logs...",
|
||||||
@@ -2053,6 +2071,7 @@
|
|||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"requestLogs": "Request Logs",
|
"requestLogs": "Request Logs",
|
||||||
|
"requestAnalytics": "Request Analytics",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"actionLogs": "Action Logs",
|
"actionLogs": "Action Logs",
|
||||||
@@ -2062,6 +2081,7 @@
|
|||||||
"logRetention": "Log Retention",
|
"logRetention": "Log Retention",
|
||||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||||
"requestLogsDescription": "View detailed request logs for resources in this organization",
|
"requestLogsDescription": "View detailed request logs for resources in this organization",
|
||||||
|
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
||||||
"logRetentionRequestLabel": "Request Log Retention",
|
"logRetentionRequestLabel": "Request Log Retention",
|
||||||
"logRetentionRequestDescription": "How long to retain request logs",
|
"logRetentionRequestDescription": "How long to retain request logs",
|
||||||
"logRetentionAccessLabel": "Access Log Retention",
|
"logRetentionAccessLabel": "Access Log Retention",
|
||||||
@@ -2212,5 +2232,6 @@
|
|||||||
"enterIdentifier": "Enter identifier",
|
"enterIdentifier": "Enter identifier",
|
||||||
"identifier": "Identifier",
|
"identifier": "Identifier",
|
||||||
"deviceLoginUseDifferentAccount": "Not you? Use a different account.",
|
"deviceLoginUseDifferentAccount": "Not you? Use a different account.",
|
||||||
"deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account."
|
"deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.",
|
||||||
|
"noData": "No Data"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
|
|||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
reactCompiler: true
|
||||||
|
},
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -22,8 +22,8 @@
|
|||||||
"set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
"set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||||
"set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
"set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
"set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
"set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||||
"set:sqlite": "echo 'export * from \"./sqlite\";' > server/db/index.ts",
|
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
||||||
"set:pg": "echo 'export * from \"./pg\";' > server/db/index.ts",
|
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||||
"next:build": "next build",
|
"next:build": "next build",
|
||||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
||||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
@@ -117,15 +118,18 @@
|
|||||||
"react-hook-form": "7.66.0",
|
"react-hook-form": "7.66.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"reodotdev": "^1.0.0",
|
"reodotdev": "^1.0.0",
|
||||||
"resend": "^6.4.2",
|
"resend": "^6.4.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"stripe": "18.2.1",
|
"stripe": "18.2.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
|
"topojson-client": "^3.1.0",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
|
"visionscarto-world-atlas": "^1.0.0",
|
||||||
"winston": "3.18.3",
|
"winston": "3.18.3",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.18.3",
|
"ws": "8.18.3",
|
||||||
@@ -144,6 +148,7 @@
|
|||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
"@types/express": "5.0.5",
|
"@types/express": "5.0.5",
|
||||||
"@types/express-session": "^1.18.2",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
@@ -157,8 +162,10 @@
|
|||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.34",
|
"@types/yargs": "17.0.34",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"drizzle-kit": "0.31.6",
|
"drizzle-kit": "0.31.6",
|
||||||
"esbuild": "0.27.0",
|
"esbuild": "0.27.0",
|
||||||
"esbuild-node-externals": "1.19.1",
|
"esbuild-node-externals": "1.19.1",
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ function createDb() {
|
|||||||
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
||||||
};
|
};
|
||||||
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
||||||
const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map((conn) => ({
|
const replicas =
|
||||||
connection_string: conn.trim()
|
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(
|
||||||
}));
|
","
|
||||||
|
).map((conn) => ({
|
||||||
|
connection_string: conn.trim()
|
||||||
|
}));
|
||||||
config.postgres.replicas = replicas;
|
config.postgres.replicas = replicas;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -40,28 +43,44 @@ function createDb() {
|
|||||||
connectionString,
|
connectionString,
|
||||||
max: poolConfig?.max_connections || 20,
|
max: poolConfig?.max_connections || 20,
|
||||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000,
|
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
const replicas = [];
|
const replicas = [];
|
||||||
|
|
||||||
if (!replicaConnections.length) {
|
if (!replicaConnections.length) {
|
||||||
replicas.push(DrizzlePostgres(primaryPool));
|
replicas.push(
|
||||||
|
DrizzlePostgres(primaryPool, {
|
||||||
|
logger: process.env.NODE_ENV === "development"
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = new Pool({
|
const replicaPool = new Pool({
|
||||||
connectionString: conn.connection_string,
|
connectionString: conn.connection_string,
|
||||||
max: poolConfig?.max_replica_connections || 20,
|
max: poolConfig?.max_replica_connections || 20,
|
||||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000,
|
connectionTimeoutMillis:
|
||||||
|
poolConfig?.connection_timeout_ms || 5000
|
||||||
});
|
});
|
||||||
replicas.push(DrizzlePostgres(replicaPool));
|
replicas.push(
|
||||||
|
DrizzlePostgres(replicaPool, {
|
||||||
|
logger: process.env.NODE_ENV === "development"
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return withReplicas(DrizzlePostgres(primaryPool), replicas as any);
|
return withReplicas(
|
||||||
|
DrizzlePostgres(primaryPool, {
|
||||||
|
logger: process.env.NODE_ENV === "development"
|
||||||
|
}),
|
||||||
|
replicas as any
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
export type Transaction = Parameters<
|
||||||
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
|
>[0];
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog";
|
import {
|
||||||
|
queryAccessAuditLogsQuery,
|
||||||
|
queryRequestAuditLogsParams,
|
||||||
|
queryRequest
|
||||||
|
} from "./queryRequestAuditLog";
|
||||||
import { generateCSV } from "./generateCSV";
|
import { generateCSV } from "./generateCSV";
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -54,10 +58,13 @@ export async function exportRequestAuditLogs(
|
|||||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
const csvData = generateCSV(log);
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader("Content-Type", "text/csv");
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`
|
||||||
|
);
|
||||||
|
|
||||||
return res.send(csvData);
|
return res.send(csvData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./queryRequstAuditLog";
|
export * from "./queryRequestAuditLog";
|
||||||
export * from "./exportRequstAuditLog";
|
export * from "./queryRequestAnalytics";
|
||||||
|
export * from "./exportRequestAuditLog";
|
||||||
|
|||||||
192
server/routers/auditLogs/queryRequestAnalytics.ts
Normal file
192
server/routers/auditLogs/queryRequestAnalytics.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { db, requestAuditLog, driver } from "@server/db";
|
||||||
|
import { registry } from "@server/openApi";
|
||||||
|
import { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { eq, gt, lt, and, count, sql, desc, not, isNull } from "drizzle-orm";
|
||||||
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import { z } from "zod";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const queryAccessAuditLogsQuery = z.object({
|
||||||
|
// iso string just validate its a parseable date
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
|
.optional(),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
|
.optional()
|
||||||
|
.prefault(new Date().toISOString())
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description:
|
||||||
|
"End time as ISO date string (defaults to current time)"
|
||||||
|
}),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryRequestAuditLogsParams = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||||
|
queryRequestAuditLogsParams
|
||||||
|
);
|
||||||
|
|
||||||
|
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||||
|
|
||||||
|
async function query(query: Q) {
|
||||||
|
let baseConditions = and(
|
||||||
|
eq(requestAuditLog.orgId, query.orgId),
|
||||||
|
lt(requestAuditLog.timestamp, query.timeEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query.timeStart) {
|
||||||
|
baseConditions = and(
|
||||||
|
baseConditions,
|
||||||
|
gt(requestAuditLog.timestamp, query.timeStart)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.resourceId) {
|
||||||
|
baseConditions = and(
|
||||||
|
baseConditions,
|
||||||
|
eq(requestAuditLog.resourceId, query.resourceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [all] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
const [blocked] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
||||||
|
|
||||||
|
const totalQ = sql<number>`count(${requestAuditLog.id})`
|
||||||
|
.mapWith(Number)
|
||||||
|
.as("total");
|
||||||
|
|
||||||
|
const requestsPerCountry = await db
|
||||||
|
.selectDistinct({
|
||||||
|
code: requestAuditLog.location,
|
||||||
|
count: totalQ
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(and(baseConditions, not(isNull(requestAuditLog.location))))
|
||||||
|
.groupBy(requestAuditLog.location)
|
||||||
|
.orderBy(desc(totalQ));
|
||||||
|
|
||||||
|
const groupByDayFunction =
|
||||||
|
driver === "pg"
|
||||||
|
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))`
|
||||||
|
: sql<string>`DATE(${requestAuditLog.timestamp}, 'unixepoch')`;
|
||||||
|
|
||||||
|
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
||||||
|
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
||||||
|
|
||||||
|
const requestsPerDay = await db
|
||||||
|
.select({
|
||||||
|
day: groupByDayFunction.as("day"),
|
||||||
|
allowedCount:
|
||||||
|
sql<number>`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanTrue} THEN 1 ELSE 0 END)`.as(
|
||||||
|
"allowed_count"
|
||||||
|
),
|
||||||
|
blockedCount:
|
||||||
|
sql<number>`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanFalse} THEN 1 ELSE 0 END)`.as(
|
||||||
|
"blocked_count"
|
||||||
|
),
|
||||||
|
totalCount: sql<number>`COUNT(*)`.as("total_count")
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(and(baseConditions))
|
||||||
|
.groupBy(groupByDayFunction)
|
||||||
|
.orderBy(groupByDayFunction);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsPerCountry: requestsPerCountry as Array<{
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
}>,
|
||||||
|
requestsPerDay,
|
||||||
|
totalBlocked: blocked.total,
|
||||||
|
totalRequests: all.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/logs/analytics",
|
||||||
|
description: "Query the request audit analytics for an organization",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
query: queryAccessAuditLogsQuery,
|
||||||
|
params: queryRequestAuditLogsParams
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QueryRequestAnalyticsResponse = Awaited<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryRequestAnalytics(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
|
const data = await query(params);
|
||||||
|
|
||||||
|
return response<QueryRequestAnalyticsResponse>(res, {
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Request audit analytics retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
description: "End time as ISO date string (defaults to current time)"
|
description:
|
||||||
|
"End time as ISO date string (defaults to current time)"
|
||||||
}),
|
}),
|
||||||
action: z
|
action: z
|
||||||
.union([z.boolean(), z.string()])
|
.union([z.boolean(), z.string()])
|
||||||
@@ -72,8 +73,9 @@ export const queryRequestAuditLogsParams = z.object({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const queryRequestAuditLogsCombined =
|
export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||||
queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams);
|
queryRequestAuditLogsParams
|
||||||
|
);
|
||||||
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||||
|
|
||||||
function getWhere(data: Q) {
|
function getWhere(data: Q) {
|
||||||
@@ -209,11 +211,21 @@ async function queryUniqueFilterAttributes(
|
|||||||
.where(baseConditions);
|
.where(baseConditions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
actors: uniqueActors
|
||||||
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
|
.map((row) => row.actor)
|
||||||
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null),
|
.filter((actor): actor is string => actor !== null),
|
||||||
hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null),
|
resources: uniqueResources.filter(
|
||||||
paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null)
|
(row): row is { id: number; name: string | null } => row.id !== null
|
||||||
|
),
|
||||||
|
locations: uniqueLocations
|
||||||
|
.map((row) => row.locations)
|
||||||
|
.filter((location): location is string => location !== null),
|
||||||
|
hosts: uniqueHosts
|
||||||
|
.map((row) => row.hosts)
|
||||||
|
.filter((host): host is string => host !== null),
|
||||||
|
paths: uniquePaths
|
||||||
|
.map((row) => row.paths)
|
||||||
|
.filter((path): path is string => path !== null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +282,7 @@ export async function queryRequestAuditLogs(
|
|||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Action audit logs retrieved successfully",
|
message: "Request audit logs retrieved successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -81,7 +81,7 @@ authenticated.post(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateOrg),
|
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||||
logActionAudit(ActionsEnum.updateOrg),
|
logActionAudit(ActionsEnum.updateOrg),
|
||||||
org.updateOrg,
|
org.updateOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -91,7 +91,7 @@ if (build !== "saas") {
|
|||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||||
logActionAudit(ActionsEnum.deleteOrg),
|
logActionAudit(ActionsEnum.deleteOrg),
|
||||||
org.deleteOrg,
|
org.deleteOrg
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createClient),
|
verifyUserHasAction(ActionsEnum.createClient),
|
||||||
logActionAudit(ActionsEnum.createClient),
|
logActionAudit(ActionsEnum.createClient),
|
||||||
client.createClient,
|
client.createClient
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients)
|
// TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients)
|
||||||
@@ -163,7 +163,7 @@ authenticated.delete(
|
|||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteClient),
|
verifyUserHasAction(ActionsEnum.deleteClient),
|
||||||
logActionAudit(ActionsEnum.deleteClient),
|
logActionAudit(ActionsEnum.deleteClient),
|
||||||
client.deleteClient,
|
client.deleteClient
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -171,10 +171,9 @@ authenticated.post(
|
|||||||
verifyClientAccess, // this will check if the user has access to the client
|
verifyClientAccess, // this will check if the user has access to the client
|
||||||
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
|
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
|
||||||
logActionAudit(ActionsEnum.updateClient),
|
logActionAudit(ActionsEnum.updateClient),
|
||||||
client.updateClient,
|
client.updateClient
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
// "/site/:siteId/roles",
|
// "/site/:siteId/roles",
|
||||||
// verifySiteAccess,
|
// verifySiteAccess,
|
||||||
@@ -186,7 +185,7 @@ authenticated.post(
|
|||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateSite),
|
verifyUserHasAction(ActionsEnum.updateSite),
|
||||||
logActionAudit(ActionsEnum.updateSite),
|
logActionAudit(ActionsEnum.updateSite),
|
||||||
site.updateSite,
|
site.updateSite
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
@@ -194,7 +193,7 @@ authenticated.delete(
|
|||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteSite),
|
verifyUserHasAction(ActionsEnum.deleteSite),
|
||||||
logActionAudit(ActionsEnum.deleteSite),
|
logActionAudit(ActionsEnum.deleteSite),
|
||||||
site.deleteSite,
|
site.deleteSite
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
|
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
|
||||||
@@ -214,13 +213,13 @@ authenticated.post(
|
|||||||
"/site/:siteId/docker/check",
|
"/site/:siteId/docker/check",
|
||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getSite),
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
site.checkDockerSocket,
|
site.checkDockerSocket
|
||||||
);
|
);
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site/:siteId/docker/trigger",
|
"/site/:siteId/docker/trigger",
|
||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getSite),
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
site.triggerFetchContainers,
|
site.triggerFetchContainers
|
||||||
);
|
);
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site/:siteId/docker/containers",
|
"/site/:siteId/docker/containers",
|
||||||
@@ -236,7 +235,7 @@ authenticated.put(
|
|||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||||
logActionAudit(ActionsEnum.createSiteResource),
|
logActionAudit(ActionsEnum.createSiteResource),
|
||||||
siteResource.createSiteResource,
|
siteResource.createSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -270,7 +269,7 @@ authenticated.post(
|
|||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||||
logActionAudit(ActionsEnum.updateSiteResource),
|
logActionAudit(ActionsEnum.updateSiteResource),
|
||||||
siteResource.updateSiteResource,
|
siteResource.updateSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
@@ -280,7 +279,7 @@ authenticated.delete(
|
|||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
||||||
logActionAudit(ActionsEnum.deleteSiteResource),
|
logActionAudit(ActionsEnum.deleteSiteResource),
|
||||||
siteResource.deleteSiteResource,
|
siteResource.deleteSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -354,7 +353,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createResource),
|
verifyUserHasAction(ActionsEnum.createResource),
|
||||||
logActionAudit(ActionsEnum.createResource),
|
logActionAudit(ActionsEnum.createResource),
|
||||||
resource.createResource,
|
resource.createResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -370,6 +369,13 @@ authenticated.get(
|
|||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource-names",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResources),
|
||||||
|
resource.listAllResourceNames
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/user-resources",
|
"/org/:orgId/user-resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -416,7 +422,7 @@ authenticated.delete(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.removeInvitation),
|
verifyUserHasAction(ActionsEnum.removeInvitation),
|
||||||
logActionAudit(ActionsEnum.removeInvitation),
|
logActionAudit(ActionsEnum.removeInvitation),
|
||||||
user.removeInvitation,
|
user.removeInvitation
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -424,7 +430,7 @@ authenticated.post(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.inviteUser),
|
verifyUserHasAction(ActionsEnum.inviteUser),
|
||||||
logActionAudit(ActionsEnum.inviteUser),
|
logActionAudit(ActionsEnum.inviteUser),
|
||||||
user.inviteUser,
|
user.inviteUser
|
||||||
); // maybe make this /invite/create instead
|
); // maybe make this /invite/create instead
|
||||||
|
|
||||||
unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
|
unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
|
||||||
@@ -460,14 +466,14 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource),
|
verifyUserHasAction(ActionsEnum.updateResource),
|
||||||
logActionAudit(ActionsEnum.updateResource),
|
logActionAudit(ActionsEnum.updateResource),
|
||||||
resource.updateResource,
|
resource.updateResource
|
||||||
);
|
);
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteResource),
|
verifyUserHasAction(ActionsEnum.deleteResource),
|
||||||
logActionAudit(ActionsEnum.deleteResource),
|
logActionAudit(ActionsEnum.deleteResource),
|
||||||
resource.deleteResource,
|
resource.deleteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
@@ -475,7 +481,7 @@ authenticated.put(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createTarget),
|
verifyUserHasAction(ActionsEnum.createTarget),
|
||||||
logActionAudit(ActionsEnum.createTarget),
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
target.createTarget,
|
target.createTarget
|
||||||
);
|
);
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/targets",
|
"/resource/:resourceId/targets",
|
||||||
@@ -489,7 +495,7 @@ authenticated.put(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createResourceRule),
|
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||||
logActionAudit(ActionsEnum.createResourceRule),
|
logActionAudit(ActionsEnum.createResourceRule),
|
||||||
resource.createResourceRule,
|
resource.createResourceRule
|
||||||
);
|
);
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/rules",
|
"/resource/:resourceId/rules",
|
||||||
@@ -502,14 +508,14 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||||
logActionAudit(ActionsEnum.updateResourceRule),
|
logActionAudit(ActionsEnum.updateResourceRule),
|
||||||
resource.updateResourceRule,
|
resource.updateResourceRule
|
||||||
);
|
);
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/resource/:resourceId/rule/:ruleId",
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||||
logActionAudit(ActionsEnum.deleteResourceRule),
|
logActionAudit(ActionsEnum.deleteResourceRule),
|
||||||
resource.deleteResourceRule,
|
resource.deleteResourceRule
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -523,14 +529,14 @@ authenticated.post(
|
|||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||||
logActionAudit(ActionsEnum.updateTarget),
|
logActionAudit(ActionsEnum.updateTarget),
|
||||||
target.updateTarget,
|
target.updateTarget
|
||||||
);
|
);
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteTarget),
|
verifyUserHasAction(ActionsEnum.deleteTarget),
|
||||||
logActionAudit(ActionsEnum.deleteTarget),
|
logActionAudit(ActionsEnum.deleteTarget),
|
||||||
target.deleteTarget,
|
target.deleteTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
@@ -538,7 +544,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createRole),
|
verifyUserHasAction(ActionsEnum.createRole),
|
||||||
logActionAudit(ActionsEnum.createRole),
|
logActionAudit(ActionsEnum.createRole),
|
||||||
role.createRole,
|
role.createRole
|
||||||
);
|
);
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/roles",
|
"/org/:orgId/roles",
|
||||||
@@ -564,7 +570,7 @@ authenticated.delete(
|
|||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteRole),
|
verifyUserHasAction(ActionsEnum.deleteRole),
|
||||||
logActionAudit(ActionsEnum.deleteRole),
|
logActionAudit(ActionsEnum.deleteRole),
|
||||||
role.deleteRole,
|
role.deleteRole
|
||||||
);
|
);
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/role/:roleId/add/:userId",
|
"/role/:roleId/add/:userId",
|
||||||
@@ -572,7 +578,7 @@ authenticated.post(
|
|||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
verifyUserHasAction(ActionsEnum.addUserRole),
|
verifyUserHasAction(ActionsEnum.addUserRole),
|
||||||
logActionAudit(ActionsEnum.addUserRole),
|
logActionAudit(ActionsEnum.addUserRole),
|
||||||
user.addUserRole,
|
user.addUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -581,7 +587,7 @@ authenticated.post(
|
|||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
resource.setResourceRoles,
|
resource.setResourceRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -590,7 +596,7 @@ authenticated.post(
|
|||||||
verifySetResourceUsers,
|
verifySetResourceUsers,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
resource.setResourceUsers,
|
resource.setResourceUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -598,7 +604,7 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
||||||
logActionAudit(ActionsEnum.setResourcePassword),
|
logActionAudit(ActionsEnum.setResourcePassword),
|
||||||
resource.setResourcePassword,
|
resource.setResourcePassword
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -606,7 +612,7 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
||||||
logActionAudit(ActionsEnum.setResourcePincode),
|
logActionAudit(ActionsEnum.setResourcePincode),
|
||||||
resource.setResourcePincode,
|
resource.setResourcePincode
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -614,7 +620,7 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||||
resource.setResourceHeaderAuth,
|
resource.setResourceHeaderAuth
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -622,7 +628,7 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||||
resource.setResourceWhitelist,
|
resource.setResourceWhitelist
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -637,7 +643,7 @@ authenticated.post(
|
|||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
||||||
logActionAudit(ActionsEnum.generateAccessToken),
|
logActionAudit(ActionsEnum.generateAccessToken),
|
||||||
accessToken.generateAccessToken,
|
accessToken.generateAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
@@ -645,7 +651,7 @@ authenticated.delete(
|
|||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteAcessToken),
|
verifyUserHasAction(ActionsEnum.deleteAcessToken),
|
||||||
logActionAudit(ActionsEnum.deleteAcessToken),
|
logActionAudit(ActionsEnum.deleteAcessToken),
|
||||||
accessToken.deleteAccessToken,
|
accessToken.deleteAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -720,7 +726,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createOrgUser),
|
verifyUserHasAction(ActionsEnum.createOrgUser),
|
||||||
logActionAudit(ActionsEnum.createOrgUser),
|
logActionAudit(ActionsEnum.createOrgUser),
|
||||||
user.createOrgUser,
|
user.createOrgUser
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -729,7 +735,7 @@ authenticated.post(
|
|||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||||
logActionAudit(ActionsEnum.updateOrgUser),
|
logActionAudit(ActionsEnum.updateOrgUser),
|
||||||
user.updateOrgUser,
|
user.updateOrgUser
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||||
@@ -753,7 +759,7 @@ authenticated.delete(
|
|||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
verifyUserHasAction(ActionsEnum.removeUser),
|
verifyUserHasAction(ActionsEnum.removeUser),
|
||||||
logActionAudit(ActionsEnum.removeUser),
|
logActionAudit(ActionsEnum.removeUser),
|
||||||
user.removeUserOrg,
|
user.removeUserOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
// authenticated.put(
|
// authenticated.put(
|
||||||
@@ -910,7 +916,7 @@ authenticated.post(
|
|||||||
verifyApiKeyAccess,
|
verifyApiKeyAccess,
|
||||||
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
||||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||||
apiKeys.setApiKeyActions,
|
apiKeys.setApiKeyActions
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -926,7 +932,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createApiKey),
|
verifyUserHasAction(ActionsEnum.createApiKey),
|
||||||
logActionAudit(ActionsEnum.createApiKey),
|
logActionAudit(ActionsEnum.createApiKey),
|
||||||
apiKeys.createOrgApiKey,
|
apiKeys.createOrgApiKey
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
@@ -935,7 +941,7 @@ authenticated.delete(
|
|||||||
verifyApiKeyAccess,
|
verifyApiKeyAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteApiKey),
|
verifyUserHasAction(ActionsEnum.deleteApiKey),
|
||||||
logActionAudit(ActionsEnum.deleteApiKey),
|
logActionAudit(ActionsEnum.deleteApiKey),
|
||||||
apiKeys.deleteOrgApiKey,
|
apiKeys.deleteOrgApiKey
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -951,7 +957,7 @@ authenticated.put(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
||||||
logActionAudit(ActionsEnum.createOrgDomain),
|
logActionAudit(ActionsEnum.createOrgDomain),
|
||||||
domain.createOrgDomain,
|
domain.createOrgDomain
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -960,7 +966,7 @@ authenticated.post(
|
|||||||
verifyDomainAccess,
|
verifyDomainAccess,
|
||||||
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
||||||
logActionAudit(ActionsEnum.restartOrgDomain),
|
logActionAudit(ActionsEnum.restartOrgDomain),
|
||||||
domain.restartOrgDomain,
|
domain.restartOrgDomain
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
@@ -969,7 +975,7 @@ authenticated.delete(
|
|||||||
verifyDomainAccess,
|
verifyDomainAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
|
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
|
||||||
logActionAudit(ActionsEnum.deleteOrgDomain),
|
logActionAudit(ActionsEnum.deleteOrgDomain),
|
||||||
domain.deleteAccountDomain,
|
domain.deleteAccountDomain
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -979,6 +985,13 @@ authenticated.get(
|
|||||||
logs.queryRequestAuditLogs
|
logs.queryRequestAuditLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/analytics",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||||
|
logs.queryRequestAnalytics
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/request/export",
|
"/org/:orgId/logs/request/export",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ export * from "./addRoleToResource";
|
|||||||
export * from "./removeRoleFromResource";
|
export * from "./removeRoleFromResource";
|
||||||
export * from "./addUserToResource";
|
export * from "./addUserToResource";
|
||||||
export * from "./removeUserFromResource";
|
export * from "./removeUserFromResource";
|
||||||
|
export * from "./listAllResourceNames";
|
||||||
|
|||||||
90
server/routers/resource/listAllResourceNames.ts
Normal file
90
server/routers/resource/listAllResourceNames.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resourceHeaderAuth } from "@server/db";
|
||||||
|
import {
|
||||||
|
resources,
|
||||||
|
userResources,
|
||||||
|
roleResources,
|
||||||
|
resourcePassword,
|
||||||
|
resourcePincode,
|
||||||
|
targets,
|
||||||
|
targetHealthCheck
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import type {
|
||||||
|
ResourceWithTargets,
|
||||||
|
ListResourcesResponse
|
||||||
|
} from "./listResources";
|
||||||
|
|
||||||
|
const listResourcesParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryResourceNames(orgId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
name: resources.name
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
|
||||||
|
.where(eq(resources.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResourceNamesResponse = Awaited<
|
||||||
|
ReturnType<typeof queryResourceNames>
|
||||||
|
>;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/resources-names",
|
||||||
|
description: "List all resource names for an organization.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Resource],
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listAllResourceNames(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = parsedParams.data.orgId;
|
||||||
|
|
||||||
|
const data = await queryResourceNames(orgId);
|
||||||
|
|
||||||
|
return response<ListResourceNamesResponse>(res, {
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource Names retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,21 +8,19 @@ import {
|
|||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
targets,
|
targets,
|
||||||
targetHealthCheck,
|
targetHealthCheck
|
||||||
} from "@server/db";
|
} 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 { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stoi from "@server/lib/stoi";
|
|
||||||
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 { warn } from "console";
|
|
||||||
|
|
||||||
const listResourcesParamsSchema = z.strictObject({
|
const listResourcesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listResourcesSchema = z.object({
|
const listResourcesSchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
@@ -67,7 +65,7 @@ type JoinedRow = {
|
|||||||
hcEnabled: boolean | null;
|
hcEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
export type ResourceWithTargets = {
|
export type ResourceWithTargets = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -89,7 +87,7 @@ export type ResourceWithTargets = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
|
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +116,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
targetEnabled: targets.enabled,
|
targetEnabled: targets.enabled,
|
||||||
|
|
||||||
hcHealth: targetHealthCheck.hcHealth,
|
hcHealth: targetHealthCheck.hcHealth,
|
||||||
hcEnabled: targetHealthCheck.hcEnabled,
|
hcEnabled: targetHealthCheck.hcEnabled
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -273,16 +271,25 @@ export async function listResources(
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
targets: [],
|
targets: []
|
||||||
};
|
};
|
||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) {
|
if (
|
||||||
let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown';
|
row.targetId != null &&
|
||||||
|
row.targetIp &&
|
||||||
|
row.targetPort != null &&
|
||||||
|
row.targetEnabled != null
|
||||||
|
) {
|
||||||
|
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
||||||
|
"unknown";
|
||||||
|
|
||||||
if (row.hcEnabled && row.hcHealth) {
|
if (row.hcEnabled && row.hcHealth) {
|
||||||
healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown';
|
healthStatus = row.hcHealth as
|
||||||
|
| "healthy"
|
||||||
|
| "unhealthy"
|
||||||
|
| "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.targets.push({
|
entry.targets.push({
|
||||||
@@ -290,7 +297,7 @@ export async function listResources(
|
|||||||
ip: row.targetIp,
|
ip: row.targetIp,
|
||||||
port: row.targetPort,
|
port: row.targetPort,
|
||||||
enabled: row.targetEnabled,
|
enabled: row.targetEnabled,
|
||||||
healthStatus: healthStatus,
|
healthStatus: healthStatus
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/app/[orgId]/settings/logs/analytics/page.tsx
Normal file
28
src/app/[orgId]/settings/logs/analytics/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { LogAnalyticsData } from "@app/components/LogAnalyticsData";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export interface AnalyticsPageProps {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnalyticsPage(props: AnalyticsPageProps) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const orgId = (await props.params).orgId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("requestAnalytics")}
|
||||||
|
description={t("requestAnalyticsDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<LogAnalyticsData orgId={orgId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ import { ListTargetsResponse } from "@server/routers/target";
|
|||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
import { toASCII, toUnicode } from "punycode";
|
import { toASCII, toUnicode } from "punycode";
|
||||||
import { DomainRow } from "../../../../../components/DomainsTable";
|
import { DomainRow } from "@app/components/DomainsTable";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|||||||
@@ -136,6 +136,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { build } from "@server/build";
|
|||||||
import { TopLoader } from "@app/components/Toploader";
|
import { TopLoader } from "@app/components/Toploader";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||||
|
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||||
@@ -129,6 +130,10 @@ export default async function RootLayout({
|
|||||||
</ThemeDataProvider>
|
</ThemeDataProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<TailwindIndicator />
|
||||||
|
)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
ScanEye,
|
ScanEye,
|
||||||
GlobeLock,
|
GlobeLock,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Laptop
|
Laptop,
|
||||||
|
ChartLine
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
@@ -40,7 +41,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
|||||||
|
|
||||||
export const orgNavSections = (): SidebarNavSection[] => [
|
export const orgNavSections = (): SidebarNavSection[] => [
|
||||||
{
|
{
|
||||||
heading: "General",
|
heading: "sidebarGeneral",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarSites",
|
title: "sidebarSites",
|
||||||
@@ -103,7 +104,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Access Control",
|
heading: "accessControls",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarUsers",
|
title: "sidebarUsers",
|
||||||
@@ -144,7 +145,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Analytics",
|
heading: "sidebarLogsAndAnalytics",
|
||||||
items: (() => {
|
items: (() => {
|
||||||
const logItems: SidebarNavItem[] = [
|
const logItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -168,13 +169,20 @@ export const orgNavSections = (): SidebarNavSection[] => [
|
|||||||
: [])
|
: [])
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const analytics = {
|
||||||
|
title: "sidebarLogsAnalytics",
|
||||||
|
href: "/{orgId}/settings/logs/analytics",
|
||||||
|
icon: <ChartLine className="h-4 w-4" />
|
||||||
|
};
|
||||||
|
|
||||||
// If only one log item, return it directly without grouping
|
// If only one log item, return it directly without grouping
|
||||||
if (logItems.length === 1) {
|
if (logItems.length === 1) {
|
||||||
return logItems;
|
return [analytics, ...logItems];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If multiple log items, create a group
|
// If multiple log items, create a group
|
||||||
return [
|
return [
|
||||||
|
analytics,
|
||||||
{
|
{
|
||||||
title: "sidebarLogs",
|
title: "sidebarLogs",
|
||||||
icon: <Logs className="size-4 flex-none" />,
|
icon: <Logs className="size-4 flex-none" />,
|
||||||
@@ -184,7 +192,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
|
|||||||
})()
|
})()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Organization",
|
heading: "sidebarOrganization",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
@@ -220,7 +228,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
|
|||||||
|
|
||||||
export const adminNavSections: SidebarNavSection[] = [
|
export const adminNavSections: SidebarNavSection[] = [
|
||||||
{
|
{
|
||||||
heading: "Admin",
|
heading: "sidebarAdmin",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarAllUsers",
|
title: "sidebarAllUsers",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useTranslations } from "next-intl";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
|||||||
@@ -7,209 +7,220 @@ import { Calendar } from "@app/components/ui/calendar";
|
|||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ChangeEvent, useEffect, useState } from "react";
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface DateTimeValue {
|
export interface DateTimeValue {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
time?: string;
|
time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateTimePickerProps {
|
export interface DateTimePickerProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
value?: DateTimeValue;
|
value?: DateTimeValue;
|
||||||
onChange?: (value: DateTimeValue) => void;
|
onChange?: (value: DateTimeValue) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateTimePicker({
|
export function DateTimePicker({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "Select date & time",
|
placeholder = "Select date & time",
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showTime = true,
|
showTime = true
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
|
const [internalDate, setInternalDate] = useState<Date | undefined>(
|
||||||
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
value?.date
|
||||||
|
);
|
||||||
|
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
||||||
|
|
||||||
// Sync internal state with external value prop
|
// Sync internal state with external value prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInternalDate(value?.date);
|
setInternalDate(value?.date);
|
||||||
setInternalTime(value?.time || "");
|
setInternalTime(value?.time || "");
|
||||||
}, [value?.date, value?.time]);
|
}, [value?.date, value?.time]);
|
||||||
|
|
||||||
const handleDateChange = (date: Date | undefined) => {
|
const handleDateChange = (date: Date | undefined) => {
|
||||||
setInternalDate(date);
|
setInternalDate(date);
|
||||||
const newValue = { date, time: internalTime };
|
const newValue = { date, time: internalTime };
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const time = event.target.value;
|
const time = event.target.value;
|
||||||
setInternalTime(time);
|
setInternalTime(time);
|
||||||
const newValue = { date: internalDate, time };
|
const newValue = { date: internalDate, time };
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDisplayText = () => {
|
const getDisplayText = () => {
|
||||||
if (!internalDate) return placeholder;
|
if (!internalDate) return placeholder;
|
||||||
|
|
||||||
const dateStr = internalDate.toLocaleDateString();
|
|
||||||
if (!showTime || !internalTime) return dateStr;
|
|
||||||
|
|
||||||
// Parse time and format in local timezone
|
|
||||||
const [hours, minutes, seconds] = internalTime.split(':');
|
|
||||||
const timeDate = new Date();
|
|
||||||
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
|
|
||||||
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
|
|
||||||
return `${dateStr} ${timeStr}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasValue = internalDate || (showTime && internalTime);
|
const dateStr = internalDate.toLocaleDateString();
|
||||||
|
if (!showTime || !internalTime) return dateStr;
|
||||||
|
|
||||||
return (
|
// Parse time and format in local timezone
|
||||||
<div className={cn("flex gap-4", className)}>
|
const [hours, minutes, seconds] = internalTime.split(":");
|
||||||
<div className="flex flex-col gap-2">
|
const timeDate = new Date();
|
||||||
{label && (
|
timeDate.setHours(
|
||||||
<Label htmlFor="date-picker">
|
parseInt(hours, 10),
|
||||||
{label}
|
parseInt(minutes, 10),
|
||||||
</Label>
|
parseInt(seconds || "0", 10)
|
||||||
)}
|
);
|
||||||
<div className="flex gap-2">
|
const timeStr = timeDate.toLocaleTimeString([], {
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
hour: "2-digit",
|
||||||
<PopoverTrigger asChild>
|
minute: "2-digit"
|
||||||
<Button
|
});
|
||||||
variant="outline"
|
|
||||||
id="date-picker"
|
return `${dateStr} ${timeStr}`;
|
||||||
disabled={disabled}
|
};
|
||||||
className={cn(
|
|
||||||
"justify-between font-normal",
|
const hasValue = internalDate || (showTime && internalTime);
|
||||||
showTime ? "w-48" : "w-32",
|
|
||||||
!hasValue && "text-muted-foreground"
|
return (
|
||||||
)}
|
<div className={cn("flex gap-4", className)}>
|
||||||
>
|
<div className="flex flex-col gap-2">
|
||||||
{getDisplayText()}
|
{label && <Label htmlFor="date-picker">{label}</Label>}
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
<div className="flex gap-2">
|
||||||
</Button>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
</PopoverTrigger>
|
<PopoverTrigger asChild>
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
<Button
|
||||||
{showTime ? (
|
variant="outline"
|
||||||
<div className="flex">
|
id="date-picker"
|
||||||
<Calendar
|
disabled={disabled}
|
||||||
mode="single"
|
className={cn(
|
||||||
selected={internalDate}
|
"justify-between font-normal",
|
||||||
captionLayout="dropdown"
|
showTime ? "w-48" : "w-32",
|
||||||
onSelect={(date) => {
|
!hasValue && "text-muted-foreground"
|
||||||
handleDateChange(date);
|
)}
|
||||||
if (!showTime) {
|
>
|
||||||
setOpen(false);
|
{getDisplayText()}
|
||||||
}
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
}}
|
</Button>
|
||||||
className="flex-grow w-[250px]"
|
</PopoverTrigger>
|
||||||
/>
|
<PopoverContent
|
||||||
<div className="p-3 border-l">
|
className="w-auto overflow-hidden p-0"
|
||||||
<div className="flex flex-col gap-3">
|
align="start"
|
||||||
<Label htmlFor="time-input" className="text-sm font-medium">
|
>
|
||||||
Time
|
{showTime ? (
|
||||||
</Label>
|
<div className="flex">
|
||||||
<Input
|
<Calendar
|
||||||
id="time-input"
|
mode="single"
|
||||||
type="time"
|
selected={internalDate}
|
||||||
step="1"
|
captionLayout="dropdown"
|
||||||
value={internalTime}
|
onSelect={(date) => {
|
||||||
onChange={handleTimeChange}
|
handleDateChange(date);
|
||||||
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
if (!showTime) {
|
||||||
/>
|
setOpen(false);
|
||||||
</div>
|
}
|
||||||
</div>
|
}}
|
||||||
|
className="grow w-[250px]"
|
||||||
|
/>
|
||||||
|
<div className="p-3 border-l">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label
|
||||||
|
htmlFor="time-input"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="time-input"
|
||||||
|
type="time"
|
||||||
|
step="1"
|
||||||
|
value={internalTime}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={internalDate}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
onSelect={(date) => {
|
||||||
|
handleDateChange(date);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={internalDate}
|
|
||||||
captionLayout="dropdown"
|
|
||||||
onSelect={(date) => {
|
|
||||||
handleDateChange(date);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangePickerProps {
|
export interface DateRangePickerProps {
|
||||||
startLabel?: string;
|
startLabel?: string;
|
||||||
endLabel?: string;
|
endLabel?: string;
|
||||||
startValue?: DateTimeValue;
|
startValue?: DateTimeValue;
|
||||||
endValue?: DateTimeValue;
|
endValue?: DateTimeValue;
|
||||||
onStartChange?: (value: DateTimeValue) => void;
|
onStartChange?: (value: DateTimeValue) => void;
|
||||||
onEndChange?: (value: DateTimeValue) => void;
|
onEndChange?: (value: DateTimeValue) => void;
|
||||||
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateRangePicker({
|
export function DateRangePicker({
|
||||||
// startLabel = "From",
|
// startLabel = "From",
|
||||||
// endLabel = "To",
|
// endLabel = "To",
|
||||||
startValue,
|
startValue,
|
||||||
endValue,
|
endValue,
|
||||||
onStartChange,
|
onStartChange,
|
||||||
onEndChange,
|
onEndChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showTime = true,
|
showTime = true
|
||||||
}: DateRangePickerProps) {
|
}: DateRangePickerProps) {
|
||||||
const handleStartChange = (value: DateTimeValue) => {
|
const handleStartChange = (value: DateTimeValue) => {
|
||||||
onStartChange?.(value);
|
onStartChange?.(value);
|
||||||
if (onRangeChange && endValue) {
|
if (onRangeChange && endValue) {
|
||||||
onRangeChange(value, endValue);
|
onRangeChange(value, endValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndChange = (value: DateTimeValue) => {
|
const handleEndChange = (value: DateTimeValue) => {
|
||||||
onEndChange?.(value);
|
onEndChange?.(value);
|
||||||
if (onRangeChange && startValue) {
|
if (onRangeChange && startValue) {
|
||||||
onRangeChange(startValue, value);
|
onRangeChange(startValue, value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-4 items-center", className)}>
|
<div className={cn("flex gap-4 items-center", className)}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
label="Start"
|
label="Start"
|
||||||
value={startValue}
|
value={startValue}
|
||||||
onChange={handleStartChange}
|
onChange={handleStartChange}
|
||||||
placeholder="Start date & time"
|
placeholder="Start date & time"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
/>
|
/>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
label="End"
|
label="End"
|
||||||
value={endValue}
|
value={endValue}
|
||||||
onChange={handleEndChange}
|
onChange={handleEndChange}
|
||||||
placeholder="End date & time"
|
placeholder="End date & time"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function InfoSections({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
|
className={`grid md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start grid-cols-1`}
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||||
// value of a CSS variable at runtime and tailwind will just reuse that value
|
// value of a CSS variable at runtime and tailwind will just reuse that value
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ export function LayoutSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSidebarStateCookie(isSidebarCollapsed);
|
|
||||||
}, [isSidebarCollapsed]);
|
|
||||||
|
|
||||||
// Auto-collapse sidebar at 1650px or less, but only if no cookie preference exists
|
// Auto-collapse sidebar at 1650px or less, but only if no cookie preference exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasManualToggle) {
|
if (hasManualToggle) {
|
||||||
@@ -255,8 +251,10 @@ export function LayoutSidebar({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsSidebarCollapsed(!isSidebarCollapsed);
|
const newCollapsedState = !isSidebarCollapsed;
|
||||||
|
setIsSidebarCollapsed(newCollapsedState);
|
||||||
setHasManualToggle(true);
|
setHasManualToggle(true);
|
||||||
|
setSidebarStateCookie(newCollapsedState);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-1"
|
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-1"
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|||||||
545
src/components/LogAnalyticsData.tsx
Normal file
545
src/components/LogAnalyticsData.tsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
logAnalyticsFiltersSchema,
|
||||||
|
logQueries,
|
||||||
|
resourceQueries
|
||||||
|
} from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||||
|
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
|
||||||
|
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "./ui/select";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Separator } from "./ui/separator";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "./InfoSection";
|
||||||
|
import { WorldMap } from "./WorldMap";
|
||||||
|
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "./ui/tooltip";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig
|
||||||
|
} from "./ui/chart";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
export type AnalyticsContentProps = {
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const path = usePathname();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const filters = logAnalyticsFiltersSchema.parse(
|
||||||
|
Object.fromEntries(searchParams.entries())
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmptySearchParams =
|
||||||
|
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
|
||||||
|
|
||||||
|
const env = useEnvContext();
|
||||||
|
const [api] = useState(() => createApiClient(env));
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const dateRange = {
|
||||||
|
startDate: filters.timeStart ? new Date(filters.timeStart) : undefined,
|
||||||
|
endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: resources = [], isFetching: isFetchingResources } = useQuery(
|
||||||
|
resourceQueries.listNamesPerOrg(props.orgId, api)
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
isFetching: isFetchingAnalytics,
|
||||||
|
refetch: refreshAnalytics,
|
||||||
|
isLoading: isLoadingAnalytics // only `true` when there is no data yet
|
||||||
|
} = useQuery(
|
||||||
|
logQueries.requestAnalytics({
|
||||||
|
orgId: props.orgId,
|
||||||
|
api,
|
||||||
|
filters
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const percentBlocked = stats
|
||||||
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(
|
||||||
|
stats.totalRequests
|
||||||
|
? (stats.totalBlocked / stats.totalRequests) * 100
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const totalRequests = stats
|
||||||
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(stats.totalRequests)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
|
||||||
|
const newSearch = new URLSearchParams(searchParams);
|
||||||
|
const timeRegex =
|
||||||
|
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
|
||||||
|
|
||||||
|
if (start.date) {
|
||||||
|
const startDate = new Date(start.date);
|
||||||
|
if (start.time) {
|
||||||
|
const time = timeRegex.exec(start.time);
|
||||||
|
const groups = time?.groups ?? {};
|
||||||
|
startDate.setHours(Number(groups.hours));
|
||||||
|
startDate.setMinutes(Number(groups.minutes));
|
||||||
|
if (groups.seconds) {
|
||||||
|
startDate.setSeconds(Number(groups.seconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newSearch.set("timeStart", startDate.toISOString());
|
||||||
|
}
|
||||||
|
if (end.date) {
|
||||||
|
const endDate = new Date(end.date);
|
||||||
|
|
||||||
|
if (end.time) {
|
||||||
|
const time = timeRegex.exec(end.time);
|
||||||
|
const groups = time?.groups ?? {};
|
||||||
|
endDate.setHours(Number(groups.hours));
|
||||||
|
endDate.setMinutes(Number(groups.minutes));
|
||||||
|
if (groups.seconds) {
|
||||||
|
endDate.setSeconds(Number(groups.seconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
newSearch.set("timeEnd", endDate.toISOString());
|
||||||
|
}
|
||||||
|
router.replace(`${path}?${newSearch.toString()}`);
|
||||||
|
}
|
||||||
|
function getDateTime(date: Date) {
|
||||||
|
return `${date.getHours()}:${date.getMinutes()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card className="">
|
||||||
|
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-start lg:items-end sm:justify-between sm:space-y-0 pb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full sm:mr-2 gap-2">
|
||||||
|
<DateRangePicker
|
||||||
|
startValue={{
|
||||||
|
date: dateRange.startDate,
|
||||||
|
time: dateRange.startDate
|
||||||
|
? getDateTime(dateRange.startDate)
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
endValue={{
|
||||||
|
date: dateRange.endDate,
|
||||||
|
time: dateRange.endDate
|
||||||
|
? getDateTime(dateRange.endDate)
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
onRangeChange={handleTimeRangeUpdate}
|
||||||
|
className="flex-wrap gap-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col items-start gap-2 w-48">
|
||||||
|
<Label htmlFor="resourceId">
|
||||||
|
{t("filterByResource")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
const newSearch = new URLSearchParams(
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
newSearch.delete("resourceId");
|
||||||
|
if (newValue !== "all") {
|
||||||
|
newSearch.set(
|
||||||
|
"resourceId",
|
||||||
|
newValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`${path}?${newSearch.toString()}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
filters.resourceId?.toString() ?? "all"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="resourceId"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("selectResource")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-full">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<SelectItem
|
||||||
|
key={resource.resourceId}
|
||||||
|
value={resource.resourceId.toString()}
|
||||||
|
>
|
||||||
|
{resource.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="all">
|
||||||
|
All resources
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEmptySearchParams && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
router.replace(path);
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
{t("resetFilters")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => refreshAnalytics()}
|
||||||
|
disabled={isFetchingAnalytics}
|
||||||
|
className=" relative top-6 lg:static gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
isFetchingAnalytics && "animate-spin"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t("refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4">
|
||||||
|
<InfoSections cols={2}>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("totalRequests")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{totalRequests ?? "--"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("totalBlocked")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span>{stats?.totalBlocked ?? "--"}</span>
|
||||||
|
(
|
||||||
|
<span>{percentBlocked ?? "--"}</span>
|
||||||
|
<span className="text-muted-foreground">%</span>
|
||||||
|
)
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full h-full flex flex-col gap-8">
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">{t("requestsByDay")}</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RequestChart
|
||||||
|
data={stats?.requestsPerDay ?? []}
|
||||||
|
isLoading={isLoadingAnalytics}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card className="w-full h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{t("requestsByCountry")}
|
||||||
|
</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WorldMap
|
||||||
|
data={stats?.requestsPerCountry ?? []}
|
||||||
|
label={{
|
||||||
|
singular: "request",
|
||||||
|
plural: "requests"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">{t("topCountries")}</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex h-full flex-col gap-4">
|
||||||
|
<TopCountriesList
|
||||||
|
countries={stats?.requestsPerCountry ?? []}
|
||||||
|
total={stats?.totalRequests ?? 0}
|
||||||
|
isLoading={isLoadingAnalytics}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestChartProps = {
|
||||||
|
data: {
|
||||||
|
day: string;
|
||||||
|
allowedCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequestChart(props: RequestChartProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat(navigator.language, {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
notation: "compact",
|
||||||
|
compactDisplay: "short"
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
day: {
|
||||||
|
label: t("requestsByDay")
|
||||||
|
},
|
||||||
|
blockedCount: {
|
||||||
|
label: t("blocked"),
|
||||||
|
color: "var(--chart-5)"
|
||||||
|
},
|
||||||
|
allowedCount: {
|
||||||
|
label: t("allowed"),
|
||||||
|
color: "var(--chart-2)"
|
||||||
|
}
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="min-h-[200px] w-full h-80"
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={props.data}>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="dot"
|
||||||
|
labelFormatter={(value, payload) => {
|
||||||
|
const formattedDate = new Date(
|
||||||
|
payload[0].payload.day
|
||||||
|
).toLocaleDateString(navigator.language, {
|
||||||
|
dateStyle: "medium"
|
||||||
|
});
|
||||||
|
return formattedDate;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
domain={[
|
||||||
|
0,
|
||||||
|
Math.max(...props.data.map((datum) => datum.totalCount))
|
||||||
|
]}
|
||||||
|
allowDataOverflow
|
||||||
|
type="number"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
return numberFormatter.format(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
return new Date(value).toLocaleDateString(
|
||||||
|
navigator.language,
|
||||||
|
{
|
||||||
|
dateStyle: "medium"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
dataKey="allowedCount"
|
||||||
|
stroke="var(--color-allowedCount)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="transparent"
|
||||||
|
radius={4}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="blockedCount"
|
||||||
|
stroke="var(--color-blockedCount)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="transparent"
|
||||||
|
radius={4}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopCountriesListProps = {
|
||||||
|
countries: {
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
total: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TopCountriesList(props: TopCountriesListProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const displayNames = new Intl.DisplayNames(navigator.language, {
|
||||||
|
type: "region",
|
||||||
|
fallback: "code"
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat(navigator.language, {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
notation: "compact",
|
||||||
|
compactDisplay: "short"
|
||||||
|
});
|
||||||
|
const percentFormatter = new Intl.NumberFormat(navigator.language, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
style: "percent"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-2">
|
||||||
|
{props.countries.length > 0 && (
|
||||||
|
<div className="grid grid-cols-7 text-sm text-muted-foreground font-semibold h-4">
|
||||||
|
<div className="col-span-5">{t("countries")}</div>
|
||||||
|
<div className="text-end">{t("total")}</div>
|
||||||
|
<div className="text-end">%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* `aspect-475/335` is the same aspect ratio as the world map component */}
|
||||||
|
<ol className="w-full overflow-auto grid gap-1 aspect-475/335">
|
||||||
|
{props.countries.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center size-full text-muted-foreground gap-1">
|
||||||
|
{props.isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />{" "}
|
||||||
|
{t("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("noData")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.countries.map((country) => {
|
||||||
|
const percent = country.count / props.total;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={country.code}
|
||||||
|
className="grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${percent * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="col-span-5 px-2 py-1 relative z-1">
|
||||||
|
<span className="inline-flex gap-2 items-center">
|
||||||
|
{countryCodeToFlagEmoji(country.code)}{" "}
|
||||||
|
{displayNames.of(country.code)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="text-end">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button className="inline">
|
||||||
|
{numberFormatter.format(
|
||||||
|
country.count
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<strong>
|
||||||
|
{Intl.NumberFormat(
|
||||||
|
navigator.language
|
||||||
|
).format(country.count)}
|
||||||
|
</strong>{" "}
|
||||||
|
{country.count === 1
|
||||||
|
? t("request")
|
||||||
|
: t("requests")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-end">
|
||||||
|
{percentFormatter.format(percent)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -194,10 +194,13 @@ export function SidebarNav({
|
|||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const hydratedHref = hydrateHref(item.href);
|
const hydratedHref = hydrateHref(item.href);
|
||||||
const hasNestedItems = item.items && item.items.length > 0;
|
const hasNestedItems = item.items && item.items.length > 0;
|
||||||
const isActive = hydratedHref ? pathname.startsWith(hydratedHref) : false;
|
const isActive = hydratedHref
|
||||||
const isChildActive = hasNestedItems ? isItemOrChildActive(item) : false;
|
? pathname.startsWith(hydratedHref)
|
||||||
const isEE =
|
: false;
|
||||||
build === "enterprise" && item.showEE && !isUnlocked();
|
const isChildActive = hasNestedItems
|
||||||
|
? isItemOrChildActive(item)
|
||||||
|
: false;
|
||||||
|
const isEE = build === "enterprise" && item.showEE && !isUnlocked();
|
||||||
const isDisabled = disabled || isEE;
|
const isDisabled = disabled || isEE;
|
||||||
const tooltipText =
|
const tooltipText =
|
||||||
item.showEE && !isUnlocked()
|
item.showEE && !isUnlocked()
|
||||||
@@ -296,13 +299,9 @@ export function SidebarNav({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{build === "enterprise" &&
|
{build === "enterprise" && item.showEE && !isUnlocked() && (
|
||||||
item.showEE &&
|
<Badge variant="outlinePrimary">{t("licenseBadge")}</Badge>
|
||||||
!isUnlocked() && (
|
)}
|
||||||
<Badge variant="outlinePrimary">
|
|
||||||
{t("licenseBadge")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -321,7 +320,8 @@ export function SidebarNav({
|
|||||||
isChildActive
|
isChildActive
|
||||||
? "text-primary font-medium"
|
? "text-primary font-medium"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled &&
|
||||||
|
"cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
@@ -343,14 +343,14 @@ export function SidebarNav({
|
|||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{item.items!.map((childItem) => {
|
{item.items!.map((childItem) => {
|
||||||
const childHydratedHref = hydrateHref(
|
const childHydratedHref =
|
||||||
childItem.href
|
hydrateHref(childItem.href);
|
||||||
);
|
const childIsActive =
|
||||||
const childIsActive = childHydratedHref
|
childHydratedHref
|
||||||
? pathname.startsWith(
|
? pathname.startsWith(
|
||||||
childHydratedHref
|
childHydratedHref
|
||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
const childIsEE =
|
const childIsEE =
|
||||||
build === "enterprise" &&
|
build === "enterprise" &&
|
||||||
childItem.showEE &&
|
childItem.showEE &&
|
||||||
@@ -381,7 +381,9 @@ export function SidebarNav({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (childIsDisabled) {
|
if (childIsDisabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (onItemClick) {
|
} else if (
|
||||||
|
onItemClick
|
||||||
|
) {
|
||||||
onItemClick();
|
onItemClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -392,7 +394,9 @@ export function SidebarNav({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5 flex-1">
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
<span>{t(childItem.title)}</span>
|
<span>
|
||||||
|
{t(childItem.title)}
|
||||||
|
</span>
|
||||||
{childItem.isBeta && (
|
{childItem.isBeta && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -405,10 +409,10 @@ export function SidebarNav({
|
|||||||
{build === "enterprise" &&
|
{build === "enterprise" &&
|
||||||
childItem.showEE &&
|
childItem.showEE &&
|
||||||
!isUnlocked() && (
|
!isUnlocked() && (
|
||||||
<Badge
|
<Badge variant="outlinePrimary">
|
||||||
variant="outlinePrimary"
|
{t(
|
||||||
>
|
"licenseBadge"
|
||||||
{t("licenseBadge")}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -435,9 +439,7 @@ export function SidebarNav({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <React.Fragment key={item.title}>{itemContent}</React.Fragment>;
|
||||||
<React.Fragment key={item.title}>{itemContent}</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -453,7 +455,7 @@ export function SidebarNav({
|
|||||||
<div key={section.heading} className="mb-2">
|
<div key={section.heading} className="mb-2">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wide">
|
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wide">
|
||||||
{section.heading}
|
{t(`${section.heading}`)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
27
src/components/TailwindIndicator.tsx
Normal file
27
src/components/TailwindIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function TailwindIndicator() {
|
||||||
|
const [mediaSize, setMediaSize] = React.useState(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const listener = () => setMediaSize(window.innerWidth);
|
||||||
|
window.addEventListener("resize", listener);
|
||||||
|
|
||||||
|
listener();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-16 left-5 z-9999999 flex h-6 items-center justify-center gap-2 rounded-full bg-primary p-3 font-mono text-xs text-white">
|
||||||
|
<div className="block sm:hidden">xs</div>
|
||||||
|
<div className="hidden sm:block md:hidden">sm</div>
|
||||||
|
<div className="hidden md:block lg:hidden">md</div>
|
||||||
|
<div className="hidden lg:block xl:hidden">lg</div>
|
||||||
|
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||||
|
<div className="hidden 2xl:block xxl:hidden">2xl</div>| {mediaSize}
|
||||||
|
px
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/components/WorldMap.tsx
Normal file
275
src/components/WorldMap.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx
|
||||||
|
*/
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import worldJson from "visionscarto-world-atlas/world/110m.json";
|
||||||
|
import * as topojson from "topojson-client";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import { useRef, type ComponentRef, useState, useEffect, useMemo } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { COUNTRY_CODE_LIST } from "@app/lib/countryCodeList";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type CountryData = {
|
||||||
|
alpha_3: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorldMapProps = {
|
||||||
|
data: Pick<CountryData, "code" | "count">[];
|
||||||
|
label: {
|
||||||
|
singular: string;
|
||||||
|
plural: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorldMap({ data, label }: WorldMapProps) {
|
||||||
|
const svgRef = useRef<ComponentRef<"svg">>(null);
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hoveredCountryAlpha3Code: string | null;
|
||||||
|
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
const svg = drawInteractiveCountries(svgRef.current, setTooltip);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayNames = new Intl.DisplayNames(navigator.language, {
|
||||||
|
type: "region",
|
||||||
|
fallback: "code"
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxValue = Math.max(...data.map((item) => item.count));
|
||||||
|
const dataByCountryCode = useMemo(() => {
|
||||||
|
const byCountryCode = new Map<string, CountryData>();
|
||||||
|
for (const country of data) {
|
||||||
|
const countryISOData = COUNTRY_CODE_LIST[country.code];
|
||||||
|
|
||||||
|
if (countryISOData) {
|
||||||
|
byCountryCode.set(countryISOData.alpha3, {
|
||||||
|
...country,
|
||||||
|
name: displayNames.of(country.code)!,
|
||||||
|
alpha_3: countryISOData.alpha3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byCountryCode;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (svgRef.current) {
|
||||||
|
const palette =
|
||||||
|
colorScales[theme ?? "light"] ??
|
||||||
|
colorScales[systemTheme ?? "light"];
|
||||||
|
|
||||||
|
const getColorForValue = d3
|
||||||
|
.scaleLinear<string>()
|
||||||
|
.domain([0, maxValue])
|
||||||
|
.range(palette);
|
||||||
|
|
||||||
|
colorInCountriesWithValues(
|
||||||
|
svgRef.current,
|
||||||
|
getColorForValue,
|
||||||
|
dataByCountryCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [theme, systemTheme, maxValue, dataByCountryCode]);
|
||||||
|
|
||||||
|
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
|
||||||
|
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-4 w-full relative">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!hoveredCountryData && (
|
||||||
|
<MapTooltip
|
||||||
|
x={tooltip.x}
|
||||||
|
y={tooltip.y}
|
||||||
|
name={hoveredCountryData.name}
|
||||||
|
value={Intl.NumberFormat(navigator.language).format(
|
||||||
|
hoveredCountryData.count
|
||||||
|
)}
|
||||||
|
label={
|
||||||
|
hoveredCountryData.count === 1
|
||||||
|
? t(label.singular)
|
||||||
|
: t(label.plural)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapTooltipProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapTooltip({ name, value, label, x, y }: MapTooltipProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute z-50 p-2 translate-x-2 translate-y-2",
|
||||||
|
"pointer-events-none rounded-sm",
|
||||||
|
"bg-white dark:bg-popover shadow border border-border"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: x,
|
||||||
|
top: y
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{name}</div>
|
||||||
|
<strong className="text-primary">{value}</strong> {label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 475;
|
||||||
|
const height = 335;
|
||||||
|
const sharedCountryClass = cn("transition-colors");
|
||||||
|
|
||||||
|
const colorScales: Record<string, [string, string]> = {
|
||||||
|
dark: ["#4F4444", "#f36117"],
|
||||||
|
light: ["#FFF5F3", "#f36117"]
|
||||||
|
};
|
||||||
|
|
||||||
|
const countryClass = cn(
|
||||||
|
sharedCountryClass,
|
||||||
|
"stroke-1",
|
||||||
|
"fill-[#fafafa]",
|
||||||
|
"stroke-[#E7DADA]",
|
||||||
|
"dark:fill-[#323236]",
|
||||||
|
"dark:stroke-[#18181b]"
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightedCountryClass = cn(
|
||||||
|
sharedCountryClass,
|
||||||
|
"stroke-2",
|
||||||
|
"fill-[#f4f4f5]",
|
||||||
|
"stroke-[#f36117]",
|
||||||
|
"dark:fill-[#3f3f46]"
|
||||||
|
);
|
||||||
|
|
||||||
|
function setupProjetionPath() {
|
||||||
|
const projection = d3
|
||||||
|
.geoMercator()
|
||||||
|
.scale(75)
|
||||||
|
.translate([width / 2, height / 1.5]);
|
||||||
|
|
||||||
|
const path = d3.geoPath().projection(projection);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns the d3 selected svg element */
|
||||||
|
function drawInteractiveCountries(
|
||||||
|
element: SVGSVGElement,
|
||||||
|
setTooltip: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hoveredCountryAlpha3Code: string | null;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
const path = setupProjetionPath();
|
||||||
|
const data = parseWorldTopoJsonToGeoJsonFeatures();
|
||||||
|
const svg = d3.select(element);
|
||||||
|
|
||||||
|
svg.selectAll("path")
|
||||||
|
.data(data)
|
||||||
|
.enter()
|
||||||
|
.append("path")
|
||||||
|
.attr("class", countryClass)
|
||||||
|
.attr("d", path as never)
|
||||||
|
|
||||||
|
.on("mouseover", function (event, country) {
|
||||||
|
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
|
||||||
|
setTooltip({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hoveredCountryAlpha3Code: country.properties.a3
|
||||||
|
});
|
||||||
|
// brings country to front
|
||||||
|
this.parentNode?.appendChild(this);
|
||||||
|
d3.select(this).attr("class", highlightedCountryClass);
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("mousemove", function (event) {
|
||||||
|
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
|
||||||
|
setTooltip((currentState) => ({ ...currentState, x, y }));
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("mouseout", function () {
|
||||||
|
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||||
|
d3.select(this).attr("class", countryClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorldJsonCountryData = { properties: { name: string; a3: string } };
|
||||||
|
|
||||||
|
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
|
||||||
|
const collection = topojson.feature(
|
||||||
|
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
|
||||||
|
worldJson,
|
||||||
|
worldJson.objects.countries
|
||||||
|
);
|
||||||
|
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
|
||||||
|
return collection.features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to color the countries
|
||||||
|
* @returns the svg elements represeting countries
|
||||||
|
*/
|
||||||
|
function colorInCountriesWithValues(
|
||||||
|
element: SVGSVGElement,
|
||||||
|
getColorForValue: d3.ScaleLinear<string, string, never>,
|
||||||
|
dataByCountryCode: Map<string, CountryData>
|
||||||
|
) {
|
||||||
|
function getCountryByCountryPath(countryPath: unknown) {
|
||||||
|
return dataByCountryCode.get(
|
||||||
|
(countryPath as unknown as WorldJsonCountryData).properties.a3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = d3.select(element);
|
||||||
|
|
||||||
|
return svg
|
||||||
|
.selectAll("path")
|
||||||
|
.style("fill", (countryPath) => {
|
||||||
|
const country = getCountryByCountryPath(countryPath);
|
||||||
|
if (!country?.count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getColorForValue(country.count);
|
||||||
|
})
|
||||||
|
.style("cursor", (countryPath) => {
|
||||||
|
const country = getCountryByCountryPath(countryPath);
|
||||||
|
if (!country?.count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "pointer";
|
||||||
|
});
|
||||||
|
}
|
||||||
420
src/components/ui/chart.tsx
Normal file
420
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ChartContainer.displayName = "Chart";
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(
|
||||||
|
config,
|
||||||
|
item,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
const indicatorColor =
|
||||||
|
color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter &&
|
||||||
|
item?.value !== undefined &&
|
||||||
|
item.name ? (
|
||||||
|
formatter(
|
||||||
|
item.value,
|
||||||
|
item.name,
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
item.payload
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5":
|
||||||
|
indicator ===
|
||||||
|
"dot",
|
||||||
|
"w-1":
|
||||||
|
indicator ===
|
||||||
|
"line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator ===
|
||||||
|
"dashed",
|
||||||
|
"my-0.5":
|
||||||
|
nestLabel &&
|
||||||
|
indicator ===
|
||||||
|
"dashed"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg":
|
||||||
|
indicatorColor,
|
||||||
|
"--color-border":
|
||||||
|
indicatorColor
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none gap-2",
|
||||||
|
nestLabel
|
||||||
|
? "items-end"
|
||||||
|
: "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel
|
||||||
|
? tooltipLabel
|
||||||
|
: null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ||
|
||||||
|
item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{!isNaN(
|
||||||
|
item.value as number
|
||||||
|
)
|
||||||
|
? new Intl.NumberFormat(
|
||||||
|
navigator.language,
|
||||||
|
{
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}
|
||||||
|
).format(
|
||||||
|
item.value as number
|
||||||
|
)
|
||||||
|
: item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltip";
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(
|
||||||
|
config,
|
||||||
|
item,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ChartLegendContent.displayName = "ChartLegend";
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Env } from "@app/lib/types/env";
|
import type { Env } from "@app/lib/types/env";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
interface EnvContextType {
|
export interface EnvContextType {
|
||||||
env: Env;
|
env: Env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1002
src/lib/countryCodeList.ts
Normal file
1002
src/lib/countryCodeList.ts
Normal file
File diff suppressed because it is too large
Load Diff
6
src/lib/countryCodeToFlagEmoji.ts
Normal file
6
src/lib/countryCodeToFlagEmoji.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function countryCodeToFlagEmoji(isoAlpha2: string) {
|
||||||
|
const codePoints = [...isoAlpha2.toUpperCase()].map(
|
||||||
|
(char) => 0x1f1e6 + char.charCodeAt(0) - 65
|
||||||
|
);
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ import type {
|
|||||||
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 { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosInstance, 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 type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
|
import type { ListResourceNamesResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -140,6 +142,59 @@ export const orgQueries = {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const logAnalyticsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||||
|
|
||||||
|
export const logQueries = {
|
||||||
|
requestAnalytics: ({
|
||||||
|
orgId,
|
||||||
|
filters,
|
||||||
|
api
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: LogAnalyticsFilters;
|
||||||
|
api: AxiosInstance;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<QueryRequestAnalyticsResponse>
|
||||||
|
>(`/org/${orgId}/logs/analytics`, {
|
||||||
|
params: filters,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
export const resourceQueries = {
|
export const resourceQueries = {
|
||||||
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -172,5 +227,17 @@ export const resourceQueries = {
|
|||||||
|
|
||||||
return res.data.data.clients;
|
return res.data.data.clients;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
listNamesPerOrg: (orgId: string, api: AxiosInstance) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["RESOURCES_NAMES", orgId] as const,
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListResourceNamesResponse>
|
||||||
|
>(`/org/${orgId}/resource-names`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user