diff --git a/Dockerfile b/Dockerfile index a12ddf9c..0644a1c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ RUN npm ci COPY . . 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 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 node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD 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 \ - 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 # test to make sure the build output is there and error if not diff --git a/messages/en-US.json b/messages/en-US.json index 9b14d3f5..8f219a41 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -438,6 +438,16 @@ "inviteEmailSent": "Send invite email to user", "inviteValid": "Valid For", "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", "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.", @@ -704,6 +714,7 @@ "resourceTransferSubmit": "Transfer Resource", "siteDestination": "Destination Site", "searchSites": "Search sites", + "countries": "Countries", "accessRoleCreate": "Create Role", "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", "accessRoleCreateSubmit": "Create Role", @@ -1158,7 +1169,9 @@ "sidebarProxyResources": "Proxy Resources", "sidebarClientResources": "Client Resources", "sidebarAccessControl": "Access Control", + "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarUsers": "Users", + "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Links", @@ -1171,7 +1184,11 @@ "sidebarUserDevices": "User Devices", "sidebarMachineClients": "Machine Clients", "sidebarDomains": "Domains", + "sidebarGeneral": "General", + "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarOrganization": "Organization", + "sidebarLogsAnalytics": "Request Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", @@ -2028,6 +2045,7 @@ "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", "sidebarLogs": "Logs", "request": "Request", + "requests": "Requests", "logs": "Logs", "logsSettingsDescription": "Monitor logs collected from this orginization", "searchLogs": "Search logs...", @@ -2053,6 +2071,7 @@ "ip": "IP", "reason": "Reason", "requestLogs": "Request Logs", + "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", "actionLogs": "Action Logs", @@ -2062,6 +2081,7 @@ "logRetention": "Log Retention", "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", + "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "logRetentionRequestLabel": "Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", "logRetentionAccessLabel": "Access Log Retention", @@ -2212,5 +2232,6 @@ "enterIdentifier": "Enter identifier", "identifier": "Identifier", "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" } diff --git a/next.config.ts b/next.config.ts index a211a701..05ed8e62 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,9 @@ const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true }, + experimental: { + reactCompiler: true + }, output: "standalone" }; diff --git a/package.json b/package.json index 7d7650dd..28df227f 100644 --- a/package.json +++ b/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: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:sqlite": "echo 'export * from \"./sqlite\";' > server/db/index.ts", - "set:pg": "echo 'export * from \"./pg\";' > 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\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts", "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: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", "cors": "2.8.5", "crypto-js": "^4.2.0", + "d3": "^7.9.0", "date-fns": "4.1.0", "drizzle-orm": "0.44.7", "eslint": "9.39.1", @@ -117,15 +118,18 @@ "react-hook-form": "7.66.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", + "recharts": "^2.15.4", "reodotdev": "^1.0.0", "resend": "^6.4.2", "semver": "^7.7.3", "stripe": "18.2.1", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", + "topojson-client": "^3.1.0", "tw-animate-css": "^1.3.8", "uuid": "^13.0.0", "vaul": "1.1.2", + "visionscarto-world-atlas": "^1.0.0", "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", @@ -144,6 +148,7 @@ "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", "@types/crypto-js": "^4.2.2", + "@types/d3": "^7.4.3", "@types/express": "5.0.5", "@types/express-session": "^1.18.2", "@types/jmespath": "^0.15.2", @@ -157,8 +162,10 @@ "@types/react-dom": "19.2.2", "@types/semver": "^7.7.1", "@types/swagger-ui-express": "^4.1.8", + "@types/topojson-client": "^3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.34", + "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "0.31.6", "esbuild": "0.27.0", "esbuild-node-externals": "1.19.1", diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 6dbef7e8..8a614cc3 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -13,9 +13,12 @@ function createDb() { connection_string: process.env.POSTGRES_CONNECTION_STRING }; if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { - const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map((conn) => ({ - connection_string: conn.trim() - })); + const replicas = + process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split( + "," + ).map((conn) => ({ + connection_string: conn.trim() + })); config.postgres.replicas = replicas; } } else { @@ -40,28 +43,44 @@ function createDb() { connectionString, max: poolConfig?.max_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, - connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000, + connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); const replicas = []; if (!replicaConnections.length) { - replicas.push(DrizzlePostgres(primaryPool)); + replicas.push( + DrizzlePostgres(primaryPool, { + logger: process.env.NODE_ENV === "development" + }) + ); } else { for (const conn of replicaConnections) { const replicaPool = new Pool({ connectionString: conn.connection_string, max: poolConfig?.max_replica_connections || 20, 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 default db; -export type Transaction = Parameters[0]>[0]; \ No newline at end of file +export type Transaction = Parameters< + Parameters<(typeof db)["transaction"]>[0] +>[0]; diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequestAuditLog.ts similarity index 85% rename from server/routers/auditLogs/exportRequstAuditLog.ts rename to server/routers/auditLogs/exportRequestAuditLog.ts index 89df2d3f..9e55cfc4 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequestAuditLog.ts @@ -6,7 +6,11 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; +import { + queryAccessAuditLogsQuery, + queryRequestAuditLogsParams, + queryRequest +} from "./queryRequestAuditLog"; import { generateCSV } from "./generateCSV"; registry.registerPath({ @@ -54,10 +58,13 @@ export async function exportRequestAuditLogs( const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); - - res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); - + + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"` + ); + return res.send(csvData); } catch (error) { logger.error(error); diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 4823831d..9bea762f 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -1,2 +1,3 @@ -export * from "./queryRequstAuditLog"; -export * from "./exportRequstAuditLog"; \ No newline at end of file +export * from "./queryRequestAuditLog"; +export * from "./queryRequestAnalytics"; +export * from "./exportRequestAuditLog"; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts new file mode 100644 index 00000000..9e4ea17e --- /dev/null +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -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; + +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`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`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))` + : sql`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`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanTrue} THEN 1 ELSE 0 END)`.as( + "allowed_count" + ), + blockedCount: + sql`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanFalse} THEN 1 ELSE 0 END)`.as( + "blocked_count" + ), + totalCount: sql`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>; + +export async function queryRequestAnalytics( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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(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") + ); + } +} diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts similarity index 89% rename from server/routers/auditLogs/queryRequstAuditLog.ts rename to server/routers/auditLogs/queryRequestAuditLog.ts index 606f1ae8..663ad787 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -31,7 +31,8 @@ export const queryAccessAuditLogsQuery = z.object({ .openapi({ type: "string", 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 .union([z.boolean(), z.string()]) @@ -72,8 +73,9 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export const queryRequestAuditLogsCombined = - queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryRequestAuditLogsParams +); type Q = z.infer; function getWhere(data: Q) { @@ -209,11 +211,21 @@ async function queryUniqueFilterAttributes( .where(baseConditions); return { - actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), - resources: uniqueResources.filter((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) + actors: uniqueActors + .map((row) => row.actor) + .filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter( + (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, error: false, - message: "Action audit logs retrieved successfully", + message: "Request audit logs retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/external.ts b/server/routers/external.ts index 453a706f..857a99b3 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -81,7 +81,7 @@ authenticated.post( verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), - org.updateOrg, + org.updateOrg ); if (build !== "saas") { @@ -91,7 +91,7 @@ if (build !== "saas") { verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), - org.deleteOrg, + org.deleteOrg ); } @@ -154,7 +154,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), - client.createClient, + client.createClient ); // TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients) @@ -163,7 +163,7 @@ authenticated.delete( verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), - client.deleteClient, + client.deleteClient ); authenticated.post( @@ -171,10 +171,9 @@ authenticated.post( 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 logActionAudit(ActionsEnum.updateClient), - client.updateClient, + client.updateClient ); - // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -186,7 +185,7 @@ authenticated.post( verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), - site.updateSite, + site.updateSite ); authenticated.delete( @@ -194,7 +193,7 @@ authenticated.delete( verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), logActionAudit(ActionsEnum.deleteSite), - site.deleteSite, + site.deleteSite ); // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" @@ -214,13 +213,13 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket, + site.checkDockerSocket ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers, + site.triggerFetchContainers ); authenticated.get( "/site/:siteId/docker/containers", @@ -236,7 +235,7 @@ authenticated.put( verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), - siteResource.createSiteResource, + siteResource.createSiteResource ); authenticated.get( @@ -270,7 +269,7 @@ authenticated.post( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource, + siteResource.updateSiteResource ); authenticated.delete( @@ -280,7 +279,7 @@ authenticated.delete( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource, + siteResource.deleteSiteResource ); authenticated.get( @@ -354,7 +353,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.get( @@ -370,6 +369,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/resource-names", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listResources), + resource.listAllResourceNames +); + authenticated.get( "/org/:orgId/user-resources", verifyOrgAccess, @@ -416,7 +422,7 @@ authenticated.delete( verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), - user.removeInvitation, + user.removeInvitation ); authenticated.post( @@ -424,7 +430,7 @@ authenticated.post( verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), - user.inviteUser, + user.inviteUser ); // maybe make this /invite/create instead unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated @@ -460,14 +466,14 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), - resource.updateResource, + resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), - resource.deleteResource, + resource.deleteResource ); authenticated.put( @@ -475,7 +481,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", @@ -489,7 +495,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), - resource.createResourceRule, + resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", @@ -502,14 +508,14 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), - resource.updateResourceRule, + resource.updateResourceRule ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule, + resource.deleteResourceRule ); authenticated.get( @@ -523,14 +529,14 @@ authenticated.post( verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), - target.updateTarget, + target.updateTarget ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), logActionAudit(ActionsEnum.deleteTarget), - target.deleteTarget, + target.deleteTarget ); authenticated.put( @@ -538,7 +544,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( "/org/:orgId/roles", @@ -564,7 +570,7 @@ authenticated.delete( verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", @@ -572,7 +578,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); authenticated.post( @@ -581,7 +587,7 @@ authenticated.post( verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -590,7 +596,7 @@ authenticated.post( verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( @@ -598,7 +604,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), - resource.setResourcePassword, + resource.setResourcePassword ); authenticated.post( @@ -606,7 +612,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), - resource.setResourcePincode, + resource.setResourcePincode ); authenticated.post( @@ -614,7 +620,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth, + resource.setResourceHeaderAuth ); authenticated.post( @@ -622,7 +628,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist, + resource.setResourceWhitelist ); authenticated.get( @@ -637,7 +643,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken, + accessToken.generateAccessToken ); authenticated.delete( @@ -645,7 +651,7 @@ authenticated.delete( verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken, + accessToken.deleteAccessToken ); authenticated.get( @@ -720,7 +726,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), - user.createOrgUser, + user.createOrgUser ); authenticated.post( @@ -729,7 +735,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), - user.updateOrgUser, + user.updateOrgUser ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -753,7 +759,7 @@ authenticated.delete( verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -910,7 +916,7 @@ authenticated.post( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions, + apiKeys.setApiKeyActions ); authenticated.get( @@ -926,7 +932,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey, + apiKeys.createOrgApiKey ); authenticated.delete( @@ -935,7 +941,7 @@ authenticated.delete( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey, + apiKeys.deleteOrgApiKey ); authenticated.get( @@ -951,7 +957,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), - domain.createOrgDomain, + domain.createOrgDomain ); authenticated.post( @@ -960,7 +966,7 @@ authenticated.post( verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain, + domain.restartOrgDomain ); authenticated.delete( @@ -969,7 +975,7 @@ authenticated.delete( verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain, + domain.deleteAccountDomain ); authenticated.get( @@ -979,6 +985,13 @@ authenticated.get( logs.queryRequestAuditLogs ); +authenticated.get( + "/org/:orgId/logs/analytics", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryRequestAnalytics +); + authenticated.get( "/org/:orgId/logs/request/export", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index e6911ffc..e85d30f5 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -29,3 +29,4 @@ export * from "./addRoleToResource"; export * from "./removeRoleFromResource"; export * from "./addUserToResource"; export * from "./removeUserFromResource"; +export * from "./listAllResourceNames"; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts new file mode 100644 index 00000000..80b21fd4 --- /dev/null +++ b/server/routers/resource/listAllResourceNames.ts @@ -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 +>; + +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 { + 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(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") + ); + } +} diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a72dd763..1c8f0864 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -8,21 +8,19 @@ import { resourcePassword, resourcePincode, targets, - targetHealthCheck, + 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 stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { warn } from "console"; const listResourcesParamsSchema = z.strictObject({ - orgId: z.string() - }); + orgId: z.string() +}); const listResourcesSchema = z.object({ limit: z @@ -67,7 +65,7 @@ type JoinedRow = { hcEnabled: boolean | null; }; -// grouped by resource with targets[]) +// grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; name: string; @@ -89,7 +87,7 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: 'healthy' | 'unhealthy' | 'unknown'; + healthStatus?: "healthy" | "unhealthy" | "unknown"; }>; }; @@ -118,7 +116,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { targetEnabled: targets.enabled, hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled, + hcEnabled: targetHealthCheck.hcEnabled }) .from(resources) .leftJoin( @@ -273,16 +271,25 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [], + targets: [] }; map.set(row.resourceId, entry); } - if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) { - let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown'; + if ( + row.targetId != null && + row.targetIp && + row.targetPort != null && + row.targetEnabled != null + ) { + let healthStatus: "healthy" | "unhealthy" | "unknown" = + "unknown"; if (row.hcEnabled && row.hcHealth) { - healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown'; + healthStatus = row.hcHealth as + | "healthy" + | "unhealthy" + | "unknown"; } entry.targets.push({ @@ -290,7 +297,7 @@ export async function listResources( ip: row.targetIp, port: row.targetPort, enabled: row.targetEnabled, - healthStatus: healthStatus, + healthStatus: healthStatus }); } } diff --git a/src/app/[orgId]/settings/logs/analytics/page.tsx b/src/app/[orgId]/settings/logs/analytics/page.tsx new file mode 100644 index 00000000..f5bd4e7a --- /dev/null +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -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>; +} + +export default async function AnalyticsPage(props: AnalyticsPageProps) { + const t = await getTranslations(); + + const orgId = (await props.params).orgId; + + return ( + <> + + +
+ +
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 75e037be..29683a3d 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -98,7 +98,7 @@ import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from "punycode"; -import { DomainRow } from "../../../../../components/DomainsTable"; +import { DomainRow } from "@app/components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { Tooltip, diff --git a/src/app/globals.css b/src/app/globals.css index 1147e37e..221e228e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { word-break: keep-all; white-space: normal; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bdd6c3fe..022a4d7b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,6 +21,7 @@ import { build } from "@server/build"; import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; +import { TailwindIndicator } from "@app/components/TailwindIndicator"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -129,6 +130,10 @@ export default async function RootLayout({ + + {process.env.NODE_ENV === "development" && ( + + )} ); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 3a5b791d..fbccc042 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -20,7 +20,8 @@ import { ScanEye, GlobeLock, Smartphone, - Laptop + Laptop, + ChartLine } from "lucide-react"; export type SidebarNavSection = { @@ -40,7 +41,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [ export const orgNavSections = (): SidebarNavSection[] => [ { - heading: "General", + heading: "sidebarGeneral", items: [ { title: "sidebarSites", @@ -103,7 +104,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ ] }, { - heading: "Access Control", + heading: "accessControls", items: [ { title: "sidebarUsers", @@ -144,7 +145,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ ] }, { - heading: "Analytics", + heading: "sidebarLogsAndAnalytics", items: (() => { const logItems: SidebarNavItem[] = [ { @@ -168,13 +169,20 @@ export const orgNavSections = (): SidebarNavSection[] => [ : []) ]; + const analytics = { + title: "sidebarLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }; + // If only one log item, return it directly without grouping if (logItems.length === 1) { - return logItems; + return [analytics, ...logItems]; } // If multiple log items, create a group return [ + analytics, { title: "sidebarLogs", icon: , @@ -184,7 +192,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ })() }, { - heading: "Organization", + heading: "sidebarOrganization", items: [ { title: "sidebarApiKeys", @@ -220,7 +228,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ export const adminNavSections: SidebarNavSection[] = [ { - heading: "Admin", + heading: "sidebarAdmin", items: [ { title: "sidebarAllUsers", diff --git a/src/components/BlueprintDetailsForm.tsx b/src/components/BlueprintDetailsForm.tsx index c97ca31a..ae6d5cb1 100644 --- a/src/components/BlueprintDetailsForm.tsx +++ b/src/components/BlueprintDetailsForm.tsx @@ -11,7 +11,6 @@ import { useTranslations } from "next-intl"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index d0b6d40e..150bafdb 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -7,209 +7,220 @@ import { Calendar } from "@app/components/ui/calendar"; import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { ChangeEvent, useEffect, useState } from "react"; export interface DateTimeValue { - date?: Date; - time?: string; + date?: Date; + time?: string; } export interface DateTimePickerProps { - label?: string; - value?: DateTimeValue; - onChange?: (value: DateTimeValue) => void; - placeholder?: string; - className?: string; - disabled?: boolean; - showTime?: boolean; + label?: string; + value?: DateTimeValue; + onChange?: (value: DateTimeValue) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + showTime?: boolean; } export function DateTimePicker({ - label, - value, - onChange, - placeholder = "Select date & time", - className, - disabled = false, - showTime = true, + label, + value, + onChange, + placeholder = "Select date & time", + className, + disabled = false, + showTime = true }: DateTimePickerProps) { - const [open, setOpen] = useState(false); - const [internalDate, setInternalDate] = useState(value?.date); - const [internalTime, setInternalTime] = useState(value?.time || ""); + const [open, setOpen] = useState(false); + const [internalDate, setInternalDate] = useState( + value?.date + ); + const [internalTime, setInternalTime] = useState(value?.time || ""); - // Sync internal state with external value prop - useEffect(() => { - setInternalDate(value?.date); - setInternalTime(value?.time || ""); - }, [value?.date, value?.time]); + // Sync internal state with external value prop + useEffect(() => { + setInternalDate(value?.date); + setInternalTime(value?.time || ""); + }, [value?.date, value?.time]); - const handleDateChange = (date: Date | undefined) => { - setInternalDate(date); - const newValue = { date, time: internalTime }; - onChange?.(newValue); - }; + const handleDateChange = (date: Date | undefined) => { + setInternalDate(date); + const newValue = { date, time: internalTime }; + onChange?.(newValue); + }; - const handleTimeChange = (event: ChangeEvent) => { - const time = event.target.value; - setInternalTime(time); - const newValue = { date: internalDate, time }; - onChange?.(newValue); - }; + const handleTimeChange = (event: ChangeEvent) => { + const time = event.target.value; + setInternalTime(time); + const newValue = { date: internalDate, time }; + onChange?.(newValue); + }; -const getDisplayText = () => { - 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 getDisplayText = () => { + if (!internalDate) return placeholder; - const hasValue = internalDate || (showTime && internalTime); + const dateStr = internalDate.toLocaleDateString(); + if (!showTime || !internalTime) return dateStr; - return ( -
-
- {label && ( - - )} -
- - - - - - {showTime ? ( -
- { - handleDateChange(date); - if (!showTime) { - setOpen(false); - } - }} - className="flex-grow w-[250px]" - /> -
-
- - -
-
+ // 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); + + return ( +
+
+ {label && } +
+ + + + + + {showTime ? ( +
+ { + handleDateChange(date); + if (!showTime) { + setOpen(false); + } + }} + className="grow w-[250px]" + /> +
+
+ + +
+
+
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )} +
+
- ) : ( - { - handleDateChange(date); - setOpen(false); - }} - /> - )} - - +
-
-
- ); + ); } export interface DateRangePickerProps { - startLabel?: string; - endLabel?: string; - startValue?: DateTimeValue; - endValue?: DateTimeValue; - onStartChange?: (value: DateTimeValue) => void; - onEndChange?: (value: DateTimeValue) => void; - onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; - className?: string; - disabled?: boolean; - showTime?: boolean; + startLabel?: string; + endLabel?: string; + startValue?: DateTimeValue; + endValue?: DateTimeValue; + onStartChange?: (value: DateTimeValue) => void; + onEndChange?: (value: DateTimeValue) => void; + onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; + className?: string; + disabled?: boolean; + showTime?: boolean; } export function DateRangePicker({ -// startLabel = "From", -// endLabel = "To", - startValue, - endValue, - onStartChange, - onEndChange, - onRangeChange, - className, - disabled = false, - showTime = true, + // startLabel = "From", + // endLabel = "To", + startValue, + endValue, + onStartChange, + onEndChange, + onRangeChange, + className, + disabled = false, + showTime = true }: DateRangePickerProps) { - const handleStartChange = (value: DateTimeValue) => { - onStartChange?.(value); - if (onRangeChange && endValue) { - onRangeChange(value, endValue); - } - }; + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; - const handleEndChange = (value: DateTimeValue) => { - onEndChange?.(value); - if (onRangeChange && startValue) { - onRangeChange(startValue, value); - } - }; + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; - return ( -
- - -
- ); -} \ No newline at end of file + return ( +
+ + +
+ ); +} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index 5959bfc3..b1cc74a8 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -11,7 +11,7 @@ export function InfoSections({ }) { return (
{ - setSidebarStateCookie(isSidebarCollapsed); - }, [isSidebarCollapsed]); - // Auto-collapse sidebar at 1650px or less, but only if no cookie preference exists useEffect(() => { if (hasManualToggle) { @@ -255,8 +251,10 @@ export function LayoutSidebar({ + )} +
+
+
+ +
+ + + + + + + + + {t("totalRequests")} + + + {totalRequests ?? "--"} + + + + + {t("totalBlocked")} + + + {stats?.totalBlocked ?? "--"} +  ( + {percentBlocked ?? "--"} + % + ) + + + + + + + + +

{t("requestsByDay")}

+
+ + + +
+ +
+ + +

+ {t("requestsByCountry")} +

+
+ + + +
+ + + +

{t("topCountries")}

+
+ + + +
+
+
+ ); +} + +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 ( + + + } /> + { + const formattedDate = new Date( + payload[0].payload.day + ).toLocaleDateString(navigator.language, { + dateStyle: "medium" + }); + return formattedDate; + }} + /> + } + /> + + + datum.totalCount)) + ]} + allowDataOverflow + type="number" + tickFormatter={(value) => { + return numberFormatter.format(value); + }} + /> + { + return new Date(value).toLocaleDateString( + navigator.language, + { + dateStyle: "medium" + } + ); + }} + /> + + + + + + ); +} + +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 ( +
+ {props.countries.length > 0 && ( +
+
{t("countries")}
+
{t("total")}
+
%
+
+ )} + {/* `aspect-475/335` is the same aspect ratio as the world map component */} +
    + {props.countries.length === 0 && ( +
    + {props.isLoading ? ( + <> + {" "} + {t("loading")} + + ) : ( + t("noData") + )} +
    + )} + {props.countries.map((country) => { + const percent = country.count / props.total; + return ( +
  1. +
    +
    + + {countryCodeToFlagEmoji(country.code)}{" "} + {displayNames.of(country.code)} + +
    + +
    + + + + + + + {Intl.NumberFormat( + navigator.language + ).format(country.count)} + {" "} + {country.count === 1 + ? t("request") + : t("requests")} + + +
    + +
    + {percentFormatter.format(percent)} +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 243413e5..edc6dcdb 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -194,10 +194,13 @@ export function SidebarNav({ ): React.ReactNode => { const hydratedHref = hydrateHref(item.href); const hasNestedItems = item.items && item.items.length > 0; - const isActive = hydratedHref ? pathname.startsWith(hydratedHref) : false; - const isChildActive = hasNestedItems ? isItemOrChildActive(item) : false; - const isEE = - build === "enterprise" && item.showEE && !isUnlocked(); + const isActive = hydratedHref + ? pathname.startsWith(hydratedHref) + : false; + const isChildActive = hasNestedItems + ? isItemOrChildActive(item) + : false; + const isEE = build === "enterprise" && item.showEE && !isUnlocked(); const isDisabled = disabled || isEE; const tooltipText = item.showEE && !isUnlocked() @@ -296,13 +299,9 @@ export function SidebarNav({ )} - {build === "enterprise" && - item.showEE && - !isUnlocked() && ( - - {t("licenseBadge")} - - )} + {build === "enterprise" && item.showEE && !isUnlocked() && ( + {t("licenseBadge")} + )} ); @@ -321,7 +320,8 @@ export function SidebarNav({ isChildActive ? "text-primary font-medium" : "text-muted-foreground hover:text-foreground", - isDisabled && "cursor-not-allowed opacity-60" + isDisabled && + "cursor-not-allowed opacity-60" )} disabled={isDisabled} > @@ -343,14 +343,14 @@ export function SidebarNav({ >
{item.items!.map((childItem) => { - const childHydratedHref = hydrateHref( - childItem.href - ); - const childIsActive = childHydratedHref - ? pathname.startsWith( - childHydratedHref - ) - : false; + const childHydratedHref = + hydrateHref(childItem.href); + const childIsActive = + childHydratedHref + ? pathname.startsWith( + childHydratedHref + ) + : false; const childIsEE = build === "enterprise" && childItem.showEE && @@ -381,7 +381,9 @@ export function SidebarNav({ onClick={(e) => { if (childIsDisabled) { e.preventDefault(); - } else if (onItemClick) { + } else if ( + onItemClick + ) { onItemClick(); } }} @@ -392,7 +394,9 @@ export function SidebarNav({ )}
- {t(childItem.title)} + + {t(childItem.title)} + {childItem.isBeta && ( - {t("licenseBadge")} + + {t( + "licenseBadge" + )} )} @@ -435,9 +439,7 @@ export function SidebarNav({ ); } - return ( - {itemContent} - ); + return {itemContent}; }; return ( @@ -453,7 +455,7 @@ export function SidebarNav({
{!isCollapsed && (
- {section.heading} + {t(`${section.heading}`)}
)}
diff --git a/src/components/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx new file mode 100644 index 00000000..19b84ae5 --- /dev/null +++ b/src/components/TailwindIndicator.tsx @@ -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 ( +
+
xs
+
sm
+
md
+
lg
+
xl
+
2xl
| {mediaSize} + px +
+ ); +} diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx new file mode 100644 index 00000000..c64c3f43 --- /dev/null +++ b/src/components/WorldMap.tsx @@ -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[]; + label: { + singular: string; + plural: string; + }; +}; + +export function WorldMap({ data, label }: WorldMapProps) { + const svgRef = useRef>(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(); + 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() + .domain([0, maxValue]) + .range(palette); + + colorInCountriesWithValues( + svgRef.current, + getColorForValue, + dataByCountryCode + ); + } + }, [theme, systemTheme, maxValue, dataByCountryCode]); + + const hoveredCountryData = tooltip.hoveredCountryAlpha3Code + ? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code) + : undefined; + + return ( +
+ + + {!!hoveredCountryData && ( + + )} +
+ ); +} + +interface MapTooltipProps { + name: string; + value: string; + label: string; + x: number; + y: number; +} + +function MapTooltip({ name, value, label, x, y }: MapTooltipProps) { + return ( +
+
{name}
+ {value} {label} +
+ ); +} + +const width = 475; +const height = 335; +const sharedCountryClass = cn("transition-colors"); + +const colorScales: Record = { + 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 { + 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, + dataByCountryCode: Map +) { + 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"; + }); +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 00000000..58d6a270 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -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 } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + 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 ( + +
+ + + {children} + +
+
+ ); +}); +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 ( +