From e90bdf8f9758f5c1b21fe1c8055f858a9110a07f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 19 Nov 2025 21:43:23 +0100 Subject: [PATCH 01/28] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20translate=20sidebar?= =?UTF-8?q?=20headings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 ++++ src/app/navigation.tsx | 18 ++++++++++++------ src/components/SidebarNav.tsx | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index cf066c3d7..2009b6eee 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1163,7 +1163,11 @@ "sidebarLicense": "License", "sidebarClients": "Clients", "sidebarDomains": "Domains", + "sidebarGeneral": "General", + "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarOrganization": "Organization", + "sidebarLogsAnalytics": "Log Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index e3478fa19..c3380415f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -17,7 +17,8 @@ import { CreditCard, Logs, SquareMousePointer, - ScanEye + ScanEye, + ChartLine } from "lucide-react"; export type SidebarNavSection = { @@ -39,7 +40,7 @@ export const orgNavSections = ( enableClients: boolean = true ): SidebarNavSection[] => [ { - heading: "General", + heading: "sidebarGeneral", items: [ { title: "sidebarSites", @@ -61,7 +62,7 @@ export const orgNavSections = ( } ] : []), - ...(build == "saas" + ...(build === "saas" ? [ { title: "sidebarRemoteExitNodes", @@ -84,7 +85,7 @@ export const orgNavSections = ( ] }, { - heading: "Access Control", + heading: "sidebarAccessControl", items: [ { title: "sidebarUsers", @@ -119,13 +120,18 @@ export const orgNavSections = ( ] }, { - heading: "Analytics", + heading: "sidebarLogAndAnalytics", items: [ { title: "sidebarLogsRequest", href: "/{orgId}/settings/logs/request", icon: }, + { + title: "sidebarLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }, ...(build != "oss" ? [ { @@ -143,7 +149,7 @@ export const orgNavSections = ( ] }, { - heading: "Organization", + heading: "sidebarOrganization", items: [ { title: "sidebarApiKeys", diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 7aaebfffc..b48e7bbf7 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -162,7 +162,7 @@ export function SidebarNav({
{!isCollapsed && (
- {section.heading} + {t(section.heading)}
)}
From af4b9e83f72446e5f9a7126b3078fea24dbc888e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 02:55:03 +0100 Subject: [PATCH 02/28] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20fix=20typos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...qustAuditLog.ts => exportRequestAuditLog.ts} | 17 ++++++++++++----- ...equstAuditLog.ts => queryRequestAuditLog.ts} | 0 2 files changed, 12 insertions(+), 5 deletions(-) rename server/routers/auditLogs/{exportRequstAuditLog.ts => exportRequestAuditLog.ts} (85%) rename server/routers/auditLogs/{queryRequstAuditLog.ts => queryRequestAuditLog.ts} (100%) 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 89df2d3f0..9e55cfc41 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/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts similarity index 100% rename from server/routers/auditLogs/queryRequstAuditLog.ts rename to server/routers/auditLogs/queryRequestAuditLog.ts From cd76fa01398964bcc7643ec2086cf0a0fff6b030 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 02:55:33 +0100 Subject: [PATCH 03/28] =?UTF-8?q?=E2=9C=A8add=20analytics=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/auditLogs/index.ts | 5 +- .../auditLogs/queryRequestAnalytics.ts | 294 ++++++++++++++++++ server/routers/external.ts | 96 +++--- 3 files changed, 348 insertions(+), 47 deletions(-) create mode 100644 server/routers/auditLogs/queryRequestAnalytics.ts diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 4823831d3..9bea762f7 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 000000000..b5b27b406 --- /dev/null +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -0,0 +1,294 @@ +import { db, requestAuditLog, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count, sql } 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 [totalRequests] = await db + .select({ total: count() }) + .from(requestAuditLog) + .where(baseConditions); + + const [totalBlocked] = await db + .select({ blocked: count() }) + .from(requestAuditLog) + .where(and(baseConditions, eq(requestAuditLog.action, false))); + + const requestsPerCountry = await db + .select({ + country_code: requestAuditLog.location, + total: sql`count(${requestAuditLog.id})` + .mapWith(Number) + .as("total") + }) + .from(requestAuditLog) + .where(baseConditions) + .groupBy(requestAuditLog.location); + + return { requestsPerCountry, totalBlocked, totalRequests }; +} + +// function getWhere(data: Q) { +// return and( +// gt(requestAuditLog.timestamp, data.timeStart), +// lt(requestAuditLog.timestamp, data.timeEnd), +// eq(requestAuditLog.orgId, data.orgId), +// data.resourceId +// ? eq(requestAuditLog.resourceId, data.resourceId) +// : undefined, +// data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, +// data.method ? eq(requestAuditLog.method, data.method) : undefined, +// data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, +// data.host ? eq(requestAuditLog.host, data.host) : undefined, +// data.location ? eq(requestAuditLog.location, data.location) : undefined, +// data.path ? eq(requestAuditLog.path, data.path) : undefined, +// data.action !== undefined +// ? eq(requestAuditLog.action, data.action) +// : undefined +// ); +// } + +// function queryRequest(data: Q) { +// return db +// .select({ +// id: requestAuditLog.id, +// timestamp: requestAuditLog.timestamp, +// orgId: requestAuditLog.orgId, +// action: requestAuditLog.action, +// reason: requestAuditLog.reason, +// actorType: requestAuditLog.actorType, +// actor: requestAuditLog.actor, +// actorId: requestAuditLog.actorId, +// resourceId: requestAuditLog.resourceId, +// ip: requestAuditLog.ip, +// location: requestAuditLog.location, +// userAgent: requestAuditLog.userAgent, +// metadata: requestAuditLog.metadata, +// headers: requestAuditLog.headers, +// query: requestAuditLog.query, +// originalRequestURL: requestAuditLog.originalRequestURL, +// scheme: requestAuditLog.scheme, +// host: requestAuditLog.host, +// path: requestAuditLog.path, +// method: requestAuditLog.method, +// tls: requestAuditLog.tls, +// resourceName: resources.name, +// resourceNiceId: resources.niceId +// }) +// .from(requestAuditLog) +// .leftJoin( +// resources, +// eq(requestAuditLog.resourceId, resources.resourceId) +// ) // TODO: Is this efficient? +// .where(getWhere(data)) +// .orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id)); +// } + +// function countRequestQuery(data: Q) { +// const countQuery = db +// .select({ count: count() }) +// .from(requestAuditLog) +// .where(getWhere(data)); +// return countQuery; +// } + +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: {} +}); + +// async function queryUniqueFilterAttributes( +// timeStart: number, +// timeEnd: number, +// orgId: string +// ) { +// const baseConditions = and( +// gt(requestAuditLog.timestamp, timeStart), +// lt(requestAuditLog.timestamp, timeEnd), +// eq(requestAuditLog.orgId, orgId) +// ); + +// // Get unique actors +// const uniqueActors = await db +// .selectDistinct({ +// actor: requestAuditLog.actor +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique locations +// const uniqueLocations = await db +// .selectDistinct({ +// locations: requestAuditLog.location +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique actors +// const uniqueHosts = await db +// .selectDistinct({ +// hosts: requestAuditLog.host +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique actors +// const uniquePaths = await db +// .selectDistinct({ +// paths: requestAuditLog.path +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique resources with names +// const uniqueResources = await db +// .selectDistinct({ +// id: requestAuditLog.resourceId, +// name: resources.name +// }) +// .from(requestAuditLog) +// .leftJoin( +// resources, +// eq(requestAuditLog.resourceId, resources.resourceId) +// ) +// .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) +// }; +// } + +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: "Action audit logs 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/external.ts b/server/routers/external.ts index f500f483a..0d2186c01 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -80,7 +80,7 @@ authenticated.post( verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), - org.updateOrg, + org.updateOrg ); if (build !== "saas") { @@ -90,7 +90,7 @@ if (build !== "saas") { verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), - org.deleteOrg, + org.deleteOrg ); } @@ -157,7 +157,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), - client.createClient, + client.createClient ); authenticated.delete( @@ -166,7 +166,7 @@ authenticated.delete( verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), - client.deleteClient, + client.deleteClient ); authenticated.post( @@ -175,10 +175,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, @@ -190,7 +189,7 @@ authenticated.post( verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), - site.updateSite, + site.updateSite ); authenticated.delete( @@ -198,7 +197,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" @@ -218,13 +217,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", @@ -240,7 +239,7 @@ authenticated.put( verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), - siteResource.createSiteResource, + siteResource.createSiteResource ); authenticated.get( @@ -274,7 +273,7 @@ authenticated.post( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource, + siteResource.updateSiteResource ); authenticated.delete( @@ -284,7 +283,7 @@ authenticated.delete( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource, + siteResource.deleteSiteResource ); authenticated.put( @@ -292,7 +291,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.get( @@ -354,7 +353,7 @@ authenticated.delete( verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), - user.removeInvitation, + user.removeInvitation ); authenticated.post( @@ -362,7 +361,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 @@ -398,14 +397,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( @@ -413,7 +412,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", @@ -427,7 +426,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), - resource.createResourceRule, + resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", @@ -440,14 +439,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( @@ -461,14 +460,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( @@ -476,7 +475,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( "/org/:orgId/roles", @@ -502,7 +501,7 @@ authenticated.delete( verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", @@ -510,7 +509,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); authenticated.post( @@ -519,7 +518,7 @@ authenticated.post( verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -528,7 +527,7 @@ authenticated.post( verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( @@ -536,7 +535,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), - resource.setResourcePassword, + resource.setResourcePassword ); authenticated.post( @@ -544,7 +543,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), - resource.setResourcePincode, + resource.setResourcePincode ); authenticated.post( @@ -552,7 +551,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth, + resource.setResourceHeaderAuth ); authenticated.post( @@ -560,7 +559,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist, + resource.setResourceWhitelist ); authenticated.get( @@ -575,7 +574,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken, + accessToken.generateAccessToken ); authenticated.delete( @@ -583,7 +582,7 @@ authenticated.delete( verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken, + accessToken.deleteAccessToken ); authenticated.get( @@ -657,7 +656,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), - user.createOrgUser, + user.createOrgUser ); authenticated.post( @@ -666,7 +665,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), - user.updateOrgUser, + user.updateOrgUser ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -690,7 +689,7 @@ authenticated.delete( verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -821,7 +820,7 @@ authenticated.post( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions, + apiKeys.setApiKeyActions ); authenticated.get( @@ -837,7 +836,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey, + apiKeys.createOrgApiKey ); authenticated.delete( @@ -846,7 +845,7 @@ authenticated.delete( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey, + apiKeys.deleteOrgApiKey ); authenticated.get( @@ -862,7 +861,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), - domain.createOrgDomain, + domain.createOrgDomain ); authenticated.post( @@ -871,7 +870,7 @@ authenticated.post( verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain, + domain.restartOrgDomain ); authenticated.delete( @@ -880,7 +879,7 @@ authenticated.delete( verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain, + domain.deleteAccountDomain ); authenticated.get( @@ -890,6 +889,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, @@ -1239,4 +1245,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); \ No newline at end of file +); From 4ed45152624efc3a1b4e6d0439fa8159728f1887 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 02:55:52 +0100 Subject: [PATCH 04/28] =?UTF-8?q?=F0=9F=9A=A7=20starting=20request=20analy?= =?UTF-8?q?tics=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ .../[orgId]/settings/logs/analytics/page.tsx | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/app/[orgId]/settings/logs/analytics/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 2009b6eee..10a35e423 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2027,6 +2027,7 @@ "ip": "IP", "reason": "Reason", "requestLogs": "Request Logs", + "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", "actionLogs": "Action Logs", @@ -2036,6 +2037,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", 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 000000000..ae74ac0c3 --- /dev/null +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -0,0 +1,24 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { getTranslations } from "next-intl/server"; + +export interface AnalyticsPageProps {} + +export default async function AnalyticsPage(props: AnalyticsPageProps) { + const t = await getTranslations(); + return ( + <> + + +
+ + + + +
+ + ); +} From dc237b8052e3c46f9e0a60140dfb0d3e9b2029ff Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 03:19:43 +0100 Subject: [PATCH 05/28] =?UTF-8?q?=F0=9F=92=AC=20update=20text=20message=20?= =?UTF-8?q?from=20the=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auditLogs/queryRequestAnalytics.ts | 141 +----------------- .../routers/auditLogs/queryRequestAuditLog.ts | 30 ++-- 2 files changed, 22 insertions(+), 149 deletions(-) diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index b5b27b406..75bb69018 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -95,70 +95,6 @@ async function query(query: Q) { return { requestsPerCountry, totalBlocked, totalRequests }; } -// function getWhere(data: Q) { -// return and( -// gt(requestAuditLog.timestamp, data.timeStart), -// lt(requestAuditLog.timestamp, data.timeEnd), -// eq(requestAuditLog.orgId, data.orgId), -// data.resourceId -// ? eq(requestAuditLog.resourceId, data.resourceId) -// : undefined, -// data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, -// data.method ? eq(requestAuditLog.method, data.method) : undefined, -// data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, -// data.host ? eq(requestAuditLog.host, data.host) : undefined, -// data.location ? eq(requestAuditLog.location, data.location) : undefined, -// data.path ? eq(requestAuditLog.path, data.path) : undefined, -// data.action !== undefined -// ? eq(requestAuditLog.action, data.action) -// : undefined -// ); -// } - -// function queryRequest(data: Q) { -// return db -// .select({ -// id: requestAuditLog.id, -// timestamp: requestAuditLog.timestamp, -// orgId: requestAuditLog.orgId, -// action: requestAuditLog.action, -// reason: requestAuditLog.reason, -// actorType: requestAuditLog.actorType, -// actor: requestAuditLog.actor, -// actorId: requestAuditLog.actorId, -// resourceId: requestAuditLog.resourceId, -// ip: requestAuditLog.ip, -// location: requestAuditLog.location, -// userAgent: requestAuditLog.userAgent, -// metadata: requestAuditLog.metadata, -// headers: requestAuditLog.headers, -// query: requestAuditLog.query, -// originalRequestURL: requestAuditLog.originalRequestURL, -// scheme: requestAuditLog.scheme, -// host: requestAuditLog.host, -// path: requestAuditLog.path, -// method: requestAuditLog.method, -// tls: requestAuditLog.tls, -// resourceName: resources.name, -// resourceNiceId: resources.niceId -// }) -// .from(requestAuditLog) -// .leftJoin( -// resources, -// eq(requestAuditLog.resourceId, resources.resourceId) -// ) // TODO: Is this efficient? -// .where(getWhere(data)) -// .orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id)); -// } - -// function countRequestQuery(data: Q) { -// const countQuery = db -// .select({ count: count() }) -// .from(requestAuditLog) -// .where(getWhere(data)); -// return countQuery; -// } - registry.registerPath({ method: "get", path: "/org/{orgId}/logs/analytics", @@ -171,81 +107,6 @@ registry.registerPath({ responses: {} }); -// async function queryUniqueFilterAttributes( -// timeStart: number, -// timeEnd: number, -// orgId: string -// ) { -// const baseConditions = and( -// gt(requestAuditLog.timestamp, timeStart), -// lt(requestAuditLog.timestamp, timeEnd), -// eq(requestAuditLog.orgId, orgId) -// ); - -// // Get unique actors -// const uniqueActors = await db -// .selectDistinct({ -// actor: requestAuditLog.actor -// }) -// .from(requestAuditLog) -// .where(baseConditions); - -// // Get unique locations -// const uniqueLocations = await db -// .selectDistinct({ -// locations: requestAuditLog.location -// }) -// .from(requestAuditLog) -// .where(baseConditions); - -// // Get unique actors -// const uniqueHosts = await db -// .selectDistinct({ -// hosts: requestAuditLog.host -// }) -// .from(requestAuditLog) -// .where(baseConditions); - -// // Get unique actors -// const uniquePaths = await db -// .selectDistinct({ -// paths: requestAuditLog.path -// }) -// .from(requestAuditLog) -// .where(baseConditions); - -// // Get unique resources with names -// const uniqueResources = await db -// .selectDistinct({ -// id: requestAuditLog.resourceId, -// name: resources.name -// }) -// .from(requestAuditLog) -// .leftJoin( -// resources, -// eq(requestAuditLog.resourceId, resources.resourceId) -// ) -// .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) -// }; -// } - export type QueryRequestAnalyticsResponse = Awaited>; export async function queryRequestAnalytics( @@ -282,7 +143,7 @@ export async function queryRequestAnalytics( data, success: true, error: false, - message: "Action audit logs retrieved successfully", + message: "Request audit analytics retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 8c9aa902d..6c56c186f 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.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) { From 487985558d3fa5ad0ff856bcd8235313ff08440c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 04:19:58 +0100 Subject: [PATCH 06/28] =?UTF-8?q?=E2=9E=95add=20react=20compiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 3 +++ package-lock.json | 50 +++++++++++++++-------------------------------- package.json | 5 +++-- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/next.config.ts b/next.config.ts index a211a701a..05ed8e620 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-lock.json b/package-lock.json index 45ff43219..7c70fc2cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,7 @@ "@types/swagger-ui-express": "^4.1.8", "@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", @@ -1644,7 +1645,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -4074,7 +4074,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -7241,7 +7240,6 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7447,7 +7445,6 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7458,7 +7455,6 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8893,7 +8889,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -8999,7 +8994,6 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -9086,7 +9080,6 @@ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9180,7 +9173,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9216,7 +9208,6 @@ "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9250,7 +9241,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9261,7 +9251,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9405,7 +9394,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -10079,7 +10067,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10558,6 +10545,16 @@ "node": ">= 0.4" } }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -10609,7 +10606,6 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10722,7 +10718,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -11723,7 +11718,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/domutils": { "version": "3.2.2", @@ -12863,7 +12859,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12960,7 +12955,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13138,7 +13132,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13447,7 +13440,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -16057,6 +16049,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.1.7", "marked": "14.0.0" @@ -16067,6 +16060,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16189,7 +16183,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", @@ -18636,7 +18629,6 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19621,7 +19613,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -19798,7 +19789,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20256,7 +20246,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20287,7 +20276,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -21063,7 +21051,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -21557,7 +21544,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22777,8 +22763,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -23792,7 +23777,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24306,7 +24290,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -24613,7 +24596,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4488d7da6..d1458e35d 100644 --- a/package.json +++ b/package.json @@ -138,8 +138,8 @@ "@dotenvx/dotenvx": "1.51.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "4.3.2", - "@tanstack/react-query-devtools": "^5.90.2", "@tailwindcss/postcss": "^4.1.17", + "@tanstack/react-query-devtools": "^5.90.2", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -149,9 +149,9 @@ "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", - "@types/nprogress": "^0.2.3", "@types/node": "24.10.1", "@types/nodemailer": "7.0.3", + "@types/nprogress": "^0.2.3", "@types/pg": "8.15.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", @@ -159,6 +159,7 @@ "@types/swagger-ui-express": "^4.1.8", "@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", From 2bc82f49ed5dd5dade3522fd7bea86d29c6b030c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 04:20:31 +0100 Subject: [PATCH 07/28] =?UTF-8?q?=E2=9C=A8add=20enpoint=20for=20getting=20?= =?UTF-8?q?all=20resource=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/external.ts | 7 ++ server/routers/resource/index.ts | 1 + .../routers/resource/listAllResourceNames.ts | 90 +++++++++++++++++++ server/routers/resource/listResources.ts | 33 ++++--- 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 server/routers/resource/listAllResourceNames.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 0d2186c01..13553b3f9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -307,6 +307,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/resource-names", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listResources), + resource.listAllResourceNames +); + authenticated.get( "/org/:orgId/user-resources", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index d1c7011df..687195c98 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -25,3 +25,4 @@ export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./listAllResourceNames"; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts new file mode 100644 index 000000000..80b21fd49 --- /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 a72dd7634..1c8f08645 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 }); } } From d6e8eb5307486c6c7bebf135a2f74945454c7d9c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 05:23:16 +0100 Subject: [PATCH 08/28] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BBadd=20ta?= =?UTF-8?q?ilwind=20indicator=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 5 +++++ src/components/TailwindIndicator.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/components/TailwindIndicator.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8907a49e..316508091 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 { ReactQueryProvider } from "@app/components/react-query-provider"; +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/components/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx new file mode 100644 index 000000000..e6ae59f35 --- /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 +
+ ); +} From 5d1f81a92c556011b9776643a5788375e3b9562b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 08:19:11 +0100 Subject: [PATCH 09/28] =?UTF-8?q?=E2=9C=A8=20world=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 10 +- package-lock.json | 783 +++++++++++++ package.json | 5 + .../auditLogs/queryRequestAnalytics.ts | 12 +- .../[orgId]/settings/logs/analytics/page.tsx | 16 +- src/components/BlueprintDetailsForm.tsx | 1 - src/components/DateTimePicker.tsx | 381 ++++--- src/components/InfoSection.tsx | 2 +- src/components/LogAnalyticsData.tsx | 288 +++++ src/components/WorldMap.tsx | 281 +++++ src/contexts/envContext.ts | 4 +- src/lib/countryCodeList.ts | 1002 +++++++++++++++++ src/lib/queries.ts | 66 ++ 13 files changed, 2651 insertions(+), 200 deletions(-) create mode 100644 src/components/LogAnalyticsData.tsx create mode 100644 src/components/WorldMap.tsx create mode 100644 src/lib/countryCodeList.ts diff --git a/messages/en-US.json b/messages/en-US.json index 10a35e423..597d15165 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -436,6 +436,13 @@ "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", + "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.", @@ -1167,7 +1174,7 @@ "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarOrganization": "Organization", - "sidebarLogsAnalytics": "Log Analytics", + "sidebarLogsAnalytics": "Request Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", @@ -2002,6 +2009,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...", diff --git a/package-lock.json b/package-lock.json index 7c70fc2cf..614a17331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,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", @@ -100,9 +101,11 @@ "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", @@ -121,6 +124,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", @@ -134,6 +138,7 @@ "@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", @@ -9046,6 +9051,290 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -9109,6 +9398,13 @@ "@types/express": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -9306,6 +9602,27 @@ "@types/serve-static": "*" } }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -11378,6 +11695,416 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11592,6 +12319,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -14559,6 +15295,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -21386,6 +22131,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -21425,6 +22176,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -22956,6 +23713,26 @@ "node": ">=0.6" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -24045,6 +24822,12 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/visionscarto-world-atlas": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/visionscarto-world-atlas/-/visionscarto-world-atlas-1.0.0.tgz", + "integrity": "sha512-jHl/NQgASfw5ZML3cnbjdfr/gXK5zO8a2xKSoCVe+5+EsIaO9tMTh7SsnfhESnCpZ+Xb6XBeU91wiuyERUPshQ==", + "license": "BSD-3-Clause" + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index d1458e35d..f947d7dd0 100644 --- a/package.json +++ b/package.json @@ -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", @@ -123,9 +124,11 @@ "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 +147,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,6 +161,7 @@ "@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", diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index 75bb69018..c9eeaeef9 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -71,13 +71,13 @@ async function query(query: Q) { ); } - const [totalRequests] = await db + const [all] = await db .select({ total: count() }) .from(requestAuditLog) .where(baseConditions); - const [totalBlocked] = await db - .select({ blocked: count() }) + const [blocked] = await db + .select({ total: count() }) .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); @@ -92,7 +92,11 @@ async function query(query: Q) { .where(baseConditions) .groupBy(requestAuditLog.location); - return { requestsPerCountry, totalBlocked, totalRequests }; + return { + requestsPerCountry, + totalBlocked: blocked.total, + totalRequests: all.total + }; } registry.registerPath({ diff --git a/src/app/[orgId]/settings/logs/analytics/page.tsx b/src/app/[orgId]/settings/logs/analytics/page.tsx index ae74ac0c3..f5bd4e7aa 100644 --- a/src/app/[orgId]/settings/logs/analytics/page.tsx +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -1,11 +1,18 @@ +import { LogAnalyticsData } from "@app/components/LogAnalyticsData"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; -export interface AnalyticsPageProps {} +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/components/BlueprintDetailsForm.tsx b/src/components/BlueprintDetailsForm.tsx index c97ca31a4..ae6d5cb18 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 d0b6d40e6..150bafdb5 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 || ""); - - // 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 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 hasValue = internalDate || (showTime && internalTime); - - return ( -
-
- {label && ( - - )} -
- - - - - - {showTime ? ( -
- { - handleDateChange(date); - if (!showTime) { - setOpen(false); - } - }} - className="flex-grow w-[250px]" - /> -
-
- - -
-
+ 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]); + + 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 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 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 handleEndChange = (value: DateTimeValue) => { - onEndChange?.(value); - if (onRangeChange && startValue) { - onRangeChange(startValue, value); - } - }; - - return ( -
- - -
- ); -} \ No newline at end of file + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; + + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index 5959bfc3f..b1cc74a81 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -11,7 +11,7 @@ export function InfoSections({ }) { return (
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 + } = useQuery( + logQueries.requestAnalytics({ + orgId: props.orgId, + api, + filters + }) + ); + + const percentBlocked = stats + ? new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 5 + }).format(stats.totalBlocked / stats.totalRequests) + : 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 = + /^(?\d{1,2})\:(?\d{1,2})(\:(?\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 ( +
+ + +
+ + + + +
+
+ + +
+ + {!isEmptySearchParams && ( + + )} +
+
+
+ +
+
+
+ + + + + + + {t("totalRequests")} + + + {totalRequests ?? "--"} + + + + + {t("totalBlocked")} + + + {stats?.totalBlocked ?? "--"} +  ( + {percentBlocked ?? "--"} + % + ) + + + + + + +
+ + +

+ {t("requestsByCountry")} +

+
+ + ({ + count: item.total, + code: item.country_code ?? "US" + })) ?? [] + } + label={{ + singular: "request", + plural: "requests" + }} + /> + +
+ + + +

{t("topCountries")}

+
+ + {/* ... */} + +
+
+
+ ); +} diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx new file mode 100644 index 000000000..5b64ac8b4 --- /dev/null +++ b/src/components/WorldMap.tsx @@ -0,0 +1,281 @@ +/** + * 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 + ).on("click", (_event, countryPath) => { + console.log({ + _event, + countryPath + }); + // onCountryClick(countryPath as unknown as WorldJsonCountryData); + }); + } + }, [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-[#dae1e7]", + "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/contexts/envContext.ts b/src/contexts/envContext.ts index f488c71b6..7e3b2fb37 100644 --- a/src/contexts/envContext.ts +++ b/src/contexts/envContext.ts @@ -1,7 +1,7 @@ -import { Env } from "@app/lib/types/env"; +import type { Env } from "@app/lib/types/env"; import { createContext } from "react"; -interface EnvContextType { +export interface EnvContextType { env: Env; } diff --git a/src/lib/countryCodeList.ts b/src/lib/countryCodeList.ts new file mode 100644 index 000000000..929d5082d --- /dev/null +++ b/src/lib/countryCodeList.ts @@ -0,0 +1,1002 @@ +// taken from: https://github.com/zonicdoe/ISO-3166-Country-codes/blob/master/Indented-lists/ISO-3166-ID-SHORT-ALPHA2.json +export const COUNTRY_CODE_LIST: Record< + string, + { alpha3: string; name: string } +> = { + AF: { + alpha3: "AFG", + name: "Afghanistan" + }, + AL: { + alpha3: "ALB", + name: "Albania" + }, + DZ: { + alpha3: "DZA", + name: "Algeria" + }, + AS: { + alpha3: "ASM", + name: "American Samoa" + }, + AD: { + alpha3: "AND", + name: "Andorra" + }, + AO: { + alpha3: "AGO", + name: "Angola" + }, + AI: { + alpha3: "AIA", + name: "Anguilla" + }, + AQ: { + alpha3: "ATA", + name: "Antarctica" + }, + AG: { + alpha3: "ATG", + name: "Antigua and Barbuda" + }, + AR: { + alpha3: "ARG", + name: "Argentina" + }, + AM: { + alpha3: "ARM", + name: "Armenia" + }, + AW: { + alpha3: "ABW", + name: "Aruba" + }, + AU: { + alpha3: "AUS", + name: "Australia" + }, + AT: { + alpha3: "AUT", + name: "Austria" + }, + AZ: { + alpha3: "AZE", + name: "Azerbaijan" + }, + BS: { + alpha3: "BHS", + name: "Bahamas (the)" + }, + BH: { + alpha3: "BHR", + name: "Bahrain" + }, + BD: { + alpha3: "BGD", + name: "Bangladesh" + }, + BB: { + alpha3: "BRB", + name: "Barbados" + }, + BY: { + alpha3: "BLR", + name: "Belarus" + }, + BE: { + alpha3: "BEL", + name: "Belgium" + }, + BZ: { + alpha3: "BLZ", + name: "Belize" + }, + BJ: { + alpha3: "BEN", + name: "Benin" + }, + BM: { + alpha3: "BMU", + name: "Bermuda" + }, + BT: { + alpha3: "BTN", + name: "Bhutan" + }, + BO: { + alpha3: "BOL", + name: "Bolivia (Plurinational State of)" + }, + BQ: { + alpha3: "BES", + name: "Bonaire, Sint Eustatius and Saba" + }, + BA: { + alpha3: "BIH", + name: "Bosnia and Herzegovina" + }, + BW: { + alpha3: "BWA", + name: "Botswana" + }, + BV: { + alpha3: "BVT", + name: "Bouvet Island" + }, + BR: { + alpha3: "BRA", + name: "Brazil" + }, + IO: { + alpha3: "IOT", + name: "British Indian Ocean Territory (the)" + }, + BN: { + alpha3: "BRN", + name: "Brunei Darussalam" + }, + BG: { + alpha3: "BGR", + name: "Bulgaria" + }, + BF: { + alpha3: "BFA", + name: "Burkina Faso" + }, + BI: { + alpha3: "BDI", + name: "Burundi" + }, + CV: { + alpha3: "CPV", + name: "Cabo Verde" + }, + KH: { + alpha3: "KHM", + name: "Cambodia" + }, + CM: { + alpha3: "CMR", + name: "Cameroon" + }, + CA: { + alpha3: "CAN", + name: "Canada" + }, + KY: { + alpha3: "CYM", + name: "Cayman Islands (the)" + }, + CF: { + alpha3: "CAF", + name: "Central African Republic (the)" + }, + TD: { + alpha3: "TCD", + name: "Chad" + }, + CL: { + alpha3: "CHL", + name: "Chile" + }, + CN: { + alpha3: "CHN", + name: "China" + }, + CX: { + alpha3: "CXR", + name: "Christmas Island" + }, + CC: { + alpha3: "CCK", + name: "Cocos (Keeling) Islands (the)" + }, + CO: { + alpha3: "COL", + name: "Colombia" + }, + KM: { + alpha3: "COM", + name: "Comoros (the)" + }, + CD: { + alpha3: "COD", + name: "Congo (the Democratic Republic of the)" + }, + CG: { + alpha3: "COG", + name: "Congo (the)" + }, + CK: { + alpha3: "COK", + name: "Cook Islands (the)" + }, + CR: { + alpha3: "CRI", + name: "Costa Rica" + }, + HR: { + alpha3: "HRV", + name: "Croatia" + }, + CU: { + alpha3: "CUB", + name: "Cuba" + }, + CW: { + alpha3: "CUW", + name: "Curaçao" + }, + CY: { + alpha3: "CYP", + name: "Cyprus" + }, + CZ: { + alpha3: "CZE", + name: "Czechia" + }, + CI: { + alpha3: "CIV", + name: "Côte d'Ivoire" + }, + DK: { + alpha3: "DNK", + name: "Denmark" + }, + DJ: { + alpha3: "DJI", + name: "Djibouti" + }, + DM: { + alpha3: "DMA", + name: "Dominica" + }, + DO: { + alpha3: "DOM", + name: "Dominican Republic (the)" + }, + EC: { + alpha3: "ECU", + name: "Ecuador" + }, + EG: { + alpha3: "EGY", + name: "Egypt" + }, + SV: { + alpha3: "SLV", + name: "El Salvador" + }, + GQ: { + alpha3: "GNQ", + name: "Equatorial Guinea" + }, + ER: { + alpha3: "ERI", + name: "Eritrea" + }, + EE: { + alpha3: "EST", + name: "Estonia" + }, + SZ: { + alpha3: "SWZ", + name: "Eswatini" + }, + ET: { + alpha3: "ETH", + name: "Ethiopia" + }, + FK: { + alpha3: "FLK", + name: "Falkland Islands (the) [Malvinas]" + }, + FO: { + alpha3: "FRO", + name: "Faroe Islands (the)" + }, + FJ: { + alpha3: "FJI", + name: "Fiji" + }, + FI: { + alpha3: "FIN", + name: "Finland" + }, + FR: { + alpha3: "FRA", + name: "France" + }, + GF: { + alpha3: "GUF", + name: "French Guiana" + }, + PF: { + alpha3: "PYF", + name: "French Polynesia" + }, + TF: { + alpha3: "ATF", + name: "French Southern Territories (the)" + }, + GA: { + alpha3: "GAB", + name: "Gabon" + }, + GM: { + alpha3: "GMB", + name: "Gambia (the)" + }, + GE: { + alpha3: "GEO", + name: "Georgia" + }, + DE: { + alpha3: "DEU", + name: "Germany" + }, + GH: { + alpha3: "GHA", + name: "Ghana" + }, + GI: { + alpha3: "GIB", + name: "Gibraltar" + }, + GR: { + alpha3: "GRC", + name: "Greece" + }, + GL: { + alpha3: "GRL", + name: "Greenland" + }, + GD: { + alpha3: "GRD", + name: "Grenada" + }, + GP: { + alpha3: "GLP", + name: "Guadeloupe" + }, + GU: { + alpha3: "GUM", + name: "Guam" + }, + GT: { + alpha3: "GTM", + name: "Guatemala" + }, + GG: { + alpha3: "GGY", + name: "Guernsey" + }, + GN: { + alpha3: "GIN", + name: "Guinea" + }, + GW: { + alpha3: "GNB", + name: "Guinea-Bissau" + }, + GY: { + alpha3: "GUY", + name: "Guyana" + }, + HT: { + alpha3: "HTI", + name: "Haiti" + }, + HM: { + alpha3: "HMD", + name: "Heard Island and McDonald Islands" + }, + VA: { + alpha3: "VAT", + name: "Holy See (the)" + }, + HN: { + alpha3: "HND", + name: "Honduras" + }, + HK: { + alpha3: "HKG", + name: "Hong Kong" + }, + HU: { + alpha3: "HUN", + name: "Hungary" + }, + IS: { + alpha3: "ISL", + name: "Iceland" + }, + IN: { + alpha3: "IND", + name: "India" + }, + ID: { + alpha3: "IDN", + name: "Indonesia" + }, + IR: { + alpha3: "IRN", + name: "Iran (Islamic Republic of)" + }, + IQ: { + alpha3: "IRQ", + name: "Iraq" + }, + IE: { + alpha3: "IRL", + name: "Ireland" + }, + IM: { + alpha3: "IMN", + name: "Isle of Man" + }, + IL: { + alpha3: "ISR", + name: "Israel" + }, + IT: { + alpha3: "ITA", + name: "Italy" + }, + JM: { + alpha3: "JAM", + name: "Jamaica" + }, + JP: { + alpha3: "JPN", + name: "Japan" + }, + JE: { + alpha3: "JEY", + name: "Jersey" + }, + JO: { + alpha3: "JOR", + name: "Jordan" + }, + KZ: { + alpha3: "KAZ", + name: "Kazakhstan" + }, + KE: { + alpha3: "KEN", + name: "Kenya" + }, + KI: { + alpha3: "KIR", + name: "Kiribati" + }, + KP: { + alpha3: "PRK", + name: "Korea (the Democratic People's Republic of)" + }, + KR: { + alpha3: "KOR", + name: "Korea (the Republic of)" + }, + KW: { + alpha3: "KWT", + name: "Kuwait" + }, + KG: { + alpha3: "KGZ", + name: "Kyrgyzstan" + }, + LA: { + alpha3: "LAO", + name: "Lao People's Democratic Republic (the)" + }, + LV: { + alpha3: "LVA", + name: "Latvia" + }, + LB: { + alpha3: "LBN", + name: "Lebanon" + }, + LS: { + alpha3: "LSO", + name: "Lesotho" + }, + LR: { + alpha3: "LBR", + name: "Liberia" + }, + LY: { + alpha3: "LBY", + name: "Libya" + }, + LI: { + alpha3: "LIE", + name: "Liechtenstein" + }, + LT: { + alpha3: "LTU", + name: "Lithuania" + }, + LU: { + alpha3: "LUX", + name: "Luxembourg" + }, + MO: { + alpha3: "MAC", + name: "Macao" + }, + MG: { + alpha3: "MDG", + name: "Madagascar" + }, + MW: { + alpha3: "MWI", + name: "Malawi" + }, + MY: { + alpha3: "MYS", + name: "Malaysia" + }, + MV: { + alpha3: "MDV", + name: "Maldives" + }, + ML: { + alpha3: "MLI", + name: "Mali" + }, + MT: { + alpha3: "MLT", + name: "Malta" + }, + MH: { + alpha3: "MHL", + name: "Marshall Islands (the)" + }, + MQ: { + alpha3: "MTQ", + name: "Martinique" + }, + MR: { + alpha3: "MRT", + name: "Mauritania" + }, + MU: { + alpha3: "MUS", + name: "Mauritius" + }, + YT: { + alpha3: "MYT", + name: "Mayotte" + }, + MX: { + alpha3: "MEX", + name: "Mexico" + }, + FM: { + alpha3: "FSM", + name: "Micronesia (Federated States of)" + }, + MD: { + alpha3: "MDA", + name: "Moldova (the Republic of)" + }, + MC: { + alpha3: "MCO", + name: "Monaco" + }, + MN: { + alpha3: "MNG", + name: "Mongolia" + }, + ME: { + alpha3: "MNE", + name: "Montenegro" + }, + MS: { + alpha3: "MSR", + name: "Montserrat" + }, + MA: { + alpha3: "MAR", + name: "Morocco" + }, + MZ: { + alpha3: "MOZ", + name: "Mozambique" + }, + MM: { + alpha3: "MMR", + name: "Myanmar" + }, + NA: { + alpha3: "NAM", + name: "Namibia" + }, + NR: { + alpha3: "NRU", + name: "Nauru" + }, + NP: { + alpha3: "NPL", + name: "Nepal" + }, + NL: { + alpha3: "NLD", + name: "Netherlands (the)" + }, + NC: { + alpha3: "NCL", + name: "New Caledonia" + }, + NZ: { + alpha3: "NZL", + name: "New Zealand" + }, + NI: { + alpha3: "NIC", + name: "Nicaragua" + }, + NE: { + alpha3: "NER", + name: "Niger (the)" + }, + NG: { + alpha3: "NGA", + name: "Nigeria" + }, + NU: { + alpha3: "NIU", + name: "Niue" + }, + NF: { + alpha3: "NFK", + name: "Norfolk Island" + }, + MK: { + alpha3: "MKD", + name: "North Macedonia" + }, + MP: { + alpha3: "MNP", + name: "Northern Mariana Islands (the)" + }, + NO: { + alpha3: "NOR", + name: "Norway" + }, + OM: { + alpha3: "OMN", + name: "Oman" + }, + PK: { + alpha3: "PAK", + name: "Pakistan" + }, + PW: { + alpha3: "PLW", + name: "Palau" + }, + PS: { + alpha3: "PSE", + name: "Palestine, State of" + }, + PA: { + alpha3: "PAN", + name: "Panama" + }, + PG: { + alpha3: "PNG", + name: "Papua New Guinea" + }, + PY: { + alpha3: "PRY", + name: "Paraguay" + }, + PE: { + alpha3: "PER", + name: "Peru" + }, + PH: { + alpha3: "PHL", + name: "Philippines (the)" + }, + PN: { + alpha3: "PCN", + name: "Pitcairn" + }, + PL: { + alpha3: "POL", + name: "Poland" + }, + PT: { + alpha3: "PRT", + name: "Portugal" + }, + PR: { + alpha3: "PRI", + name: "Puerto Rico" + }, + QA: { + alpha3: "QAT", + name: "Qatar" + }, + RO: { + alpha3: "ROU", + name: "Romania" + }, + RU: { + alpha3: "RUS", + name: "Russian Federation (the)" + }, + RW: { + alpha3: "RWA", + name: "Rwanda" + }, + RE: { + alpha3: "REU", + name: "Réunion" + }, + BL: { + alpha3: "BLM", + name: "Saint Barthélemy" + }, + SH: { + alpha3: "SHN", + name: "Saint Helena, Ascension and Tristan da Cunha" + }, + KN: { + alpha3: "KNA", + name: "Saint Kitts and Nevis" + }, + LC: { + alpha3: "LCA", + name: "Saint Lucia" + }, + MF: { + alpha3: "MAF", + name: "Saint Martin (French part)" + }, + PM: { + alpha3: "SPM", + name: "Saint Pierre and Miquelon" + }, + VC: { + alpha3: "VCT", + name: "Saint Vincent and the Grenadines" + }, + WS: { + alpha3: "WSM", + name: "Samoa" + }, + SM: { + alpha3: "SMR", + name: "San Marino" + }, + ST: { + alpha3: "STP", + name: "Sao Tome and Principe" + }, + SA: { + alpha3: "SAU", + name: "Saudi Arabia" + }, + SN: { + alpha3: "SEN", + name: "Senegal" + }, + RS: { + alpha3: "SRB", + name: "Serbia" + }, + SC: { + alpha3: "SYC", + name: "Seychelles" + }, + SL: { + alpha3: "SLE", + name: "Sierra Leone" + }, + SG: { + alpha3: "SGP", + name: "Singapore" + }, + SX: { + alpha3: "SXM", + name: "Sint Maarten (Dutch part)" + }, + SK: { + alpha3: "SVK", + name: "Slovakia" + }, + SI: { + alpha3: "SVN", + name: "Slovenia" + }, + SB: { + alpha3: "SLB", + name: "Solomon Islands" + }, + SO: { + alpha3: "SOM", + name: "Somalia" + }, + ZA: { + alpha3: "ZAF", + name: "South Africa" + }, + GS: { + alpha3: "SGS", + name: "South Georgia and the South Sandwich Islands" + }, + SS: { + alpha3: "SSD", + name: "South Sudan" + }, + ES: { + alpha3: "ESP", + name: "Spain" + }, + LK: { + alpha3: "LKA", + name: "Sri Lanka" + }, + SD: { + alpha3: "SDN", + name: "Sudan (the)" + }, + SR: { + alpha3: "SUR", + name: "Suriname" + }, + SJ: { + alpha3: "SJM", + name: "Svalbard and Jan Mayen" + }, + SE: { + alpha3: "SWE", + name: "Sweden" + }, + CH: { + alpha3: "CHE", + name: "Switzerland" + }, + SY: { + alpha3: "SYR", + name: "Syrian Arab Republic (the)" + }, + TW: { + alpha3: "TWN", + name: "Taiwan (Province of China)" + }, + TJ: { + alpha3: "TJK", + name: "Tajikistan" + }, + TZ: { + alpha3: "TZA", + name: "Tanzania, the United Republic of" + }, + TH: { + alpha3: "THA", + name: "Thailand" + }, + TL: { + alpha3: "TLS", + name: "Timor-Leste" + }, + TG: { + alpha3: "TGO", + name: "Togo" + }, + TK: { + alpha3: "TKL", + name: "Tokelau" + }, + TO: { + alpha3: "TON", + name: "Tonga" + }, + TT: { + alpha3: "TTO", + name: "Trinidad and Tobago" + }, + TN: { + alpha3: "TUN", + name: "Tunisia" + }, + TR: { + alpha3: "TUR", + name: "Turkey" + }, + TM: { + alpha3: "TKM", + name: "Turkmenistan" + }, + TC: { + alpha3: "TCA", + name: "Turks and Caicos Islands (the)" + }, + TV: { + alpha3: "TUV", + name: "Tuvalu" + }, + UG: { + alpha3: "UGA", + name: "Uganda" + }, + UA: { + alpha3: "UKR", + name: "Ukraine" + }, + AE: { + alpha3: "ARE", + name: "United Arab Emirates (the)" + }, + GB: { + alpha3: "GBR", + name: "United Kingdom of Great Britain and Northern Ireland (the)" + }, + UM: { + alpha3: "UMI", + name: "United States Minor Outlying Islands (the)" + }, + US: { + alpha3: "USA", + name: "United States of America (the)" + }, + UY: { + alpha3: "URY", + name: "Uruguay" + }, + UZ: { + alpha3: "UZB", + name: "Uzbekistan" + }, + VU: { + alpha3: "VUT", + name: "Vanuatu" + }, + VE: { + alpha3: "VEN", + name: "Venezuela (Bolivarian Republic of)" + }, + VN: { + alpha3: "VNM", + name: "Viet Nam" + }, + VG: { + alpha3: "VGB", + name: "Virgin Islands (British)" + }, + VI: { + alpha3: "VIR", + name: "Virgin Islands (U.S.)" + }, + WF: { + alpha3: "WLF", + name: "Wallis and Futuna" + }, + EH: { + alpha3: "ESH", + name: "Western Sahara*" + }, + YE: { + alpha3: "YEM", + name: "Yemen" + }, + ZM: { + alpha3: "ZMB", + name: "Zambia" + }, + ZW: { + alpha3: "ZWE", + name: "Zimbabwe" + }, + AX: { + alpha3: "ALA", + name: "Åland Islands" + } +}; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3ddf32bfb..aacd8fc65 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -3,6 +3,10 @@ import { durationToMs } from "./durationToMs"; import { build } from "@server/build"; import { remote } from "./api"; import type ResponseT from "@server/types/Response"; +import z from "zod"; +import type { AxiosInstance, AxiosResponse } from "axios"; +import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; +import type { ListResourceNamesResponse } from "@server/routers/resource"; export type ProductUpdate = { link: string | null; @@ -65,3 +69,65 @@ export const productUpdatesQueries = { // because we don't need to listen for new versions there }) }; + +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; + +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 + >(`/org/${orgId}/logs/analytics`, { + params: filters, + signal + }); + return res.data.data; + } + }) +}; + +export const resourceQueries = { + listNamesPerOrg: (orgId: string, api: AxiosInstance) => + queryOptions({ + queryKey: ["RESOURCES_NAMES", orgId] as const, + queryFn: async ({ signal }) => { + const res = await api.get< + AxiosResponse + >(`/org/${orgId}/resource-names`, { + signal + }); + return res.data.data; + } + }) +}; From 266fbb176249460b65f554cf21f65eb94404b151 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 08:22:16 +0100 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=92=84nicer=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WorldMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 5b64ac8b4..05b22521b 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -163,7 +163,7 @@ const countryClass = cn( sharedCountryClass, "stroke-1", "fill-[#fafafa]", - "stroke-[#dae1e7]", + "stroke-[#E7DADA]", "dark:fill-[#323236]", "dark:stroke-[#18181b]" ); From 3801354ae63a9dc69794700422c0c22385f5d853 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 08:37:49 +0100 Subject: [PATCH 11/28] =?UTF-8?q?=F0=9F=9A=A7=20add=20country=20code=20fla?= =?UTF-8?q?g=20emoji=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/countryCodeToFlagEmoji.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/lib/countryCodeToFlagEmoji.ts diff --git a/src/lib/countryCodeToFlagEmoji.ts b/src/lib/countryCodeToFlagEmoji.ts new file mode 100644 index 000000000..e459b6548 --- /dev/null +++ b/src/lib/countryCodeToFlagEmoji.ts @@ -0,0 +1,6 @@ +export function countryCodeToFlagEmoji(isoAlpha2: string) { + const codePoints = [...isoAlpha2.toUpperCase()].map( + (char) => 0x1f1e6 + char.charCodeAt(0) - 65 + ); + return String.fromCodePoint(...codePoints); +} From 5fd64596ebe9107c791e68deb157447b2ab15f54 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 02:00:47 +0100 Subject: [PATCH 12/28] =?UTF-8?q?=E2=9C=A8add=20top=20countries=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/db/pg/driver.ts | 37 ++++-- .../auditLogs/queryRequestAnalytics.ts | 24 ++-- src/components/LogAnalyticsData.tsx | 122 ++++++++++++++++-- src/components/TailwindIndicator.tsx | 2 +- 5 files changed, 154 insertions(+), 32 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 597d15165..6d6172795 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -709,6 +709,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", diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 6dbef7e86..8a614cc3e 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/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index c9eeaeef9..234f498b4 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -2,7 +2,7 @@ import { db, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, sql } from "drizzle-orm"; +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"; @@ -81,19 +81,25 @@ async function query(query: Q) { .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); + const totalQ = sql`count(${requestAuditLog.id})` + .mapWith(Number) + .as("total"); + const requestsPerCountry = await db - .select({ - country_code: requestAuditLog.location, - total: sql`count(${requestAuditLog.id})` - .mapWith(Number) - .as("total") + .selectDistinct({ + code: requestAuditLog.location, + count: totalQ }) .from(requestAuditLog) - .where(baseConditions) - .groupBy(requestAuditLog.location); + .where(and(baseConditions, not(isNull(requestAuditLog.location)))) + .groupBy(requestAuditLog.location) + .orderBy(desc(totalQ)); return { - requestsPerCountry, + requestsPerCountry: requestsPerCountry as Array<{ + code: string; + count: number; + }>, totalBlocked: blocked.total, totalRequests: all.total }; diff --git a/src/components/LogAnalyticsData.tsx b/src/components/LogAnalyticsData.tsx index 458736f79..d480577ba 100644 --- a/src/components/LogAnalyticsData.tsx +++ b/src/components/LogAnalyticsData.tsx @@ -33,6 +33,14 @@ import { InfoSectionTitle } from "./InfoSection"; import { WorldMap } from "./WorldMap"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; +import { useTheme } from "next-themes"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; export type AnalyticsContentProps = { orgId: string; @@ -77,8 +85,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { const percentBlocked = stats ? new Intl.NumberFormat(navigator.language, { - maximumFractionDigits: 5 - }).format(stats.totalBlocked / stats.totalRequests) + maximumFractionDigits: 2 + }).format((stats.totalBlocked / stats.totalRequests) * 100) : null; const totalRequests = stats ? new Intl.NumberFormat(navigator.language, { @@ -251,8 +259,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { -
- +
+

{t("requestsByCountry")} @@ -260,12 +268,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { ({ - count: item.total, - code: item.country_code ?? "US" - })) ?? [] - } + data={stats?.requestsPerCountry ?? []} label={{ singular: "request", plural: "requests" @@ -274,15 +277,108 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { - +

{t("topCountries")}

- - {/* ... */} + +

); } + +type TopCountriesListProps = { + countries: { + code: string; + count: number; + }[]; + total: number; +}; + +function TopCountriesList(props: TopCountriesListProps) { + const t = useTranslations(); + const displayNames = new Intl.DisplayNames(navigator.language, { + type: "region", + fallback: "code" + }); + + const formatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" + }); + const percentFormatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 0, + style: "percent" + }); + + return ( +
+
+
{t("countries")}
+
{t("total")}
+
%
+
+ {/* `aspect-475/335` is the same aspect ratio as the world map component */} +
    + {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/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx index e6ae59f35..19b84ae59 100644 --- a/src/components/TailwindIndicator.tsx +++ b/src/components/TailwindIndicator.tsx @@ -14,7 +14,7 @@ export function TailwindIndicator() { }, []); return ( -
+
xs
sm
md
From 87a0dd2d1277617a5b6d71756eaaf30af1c74204 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 02:57:44 +0100 Subject: [PATCH 13/28] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WorldMap.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 05b22521b..c64c3f430 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -82,13 +82,7 @@ export function WorldMap({ data, label }: WorldMapProps) { svgRef.current, getColorForValue, dataByCountryCode - ).on("click", (_event, countryPath) => { - console.log({ - _event, - countryPath - }); - // onCountryClick(countryPath as unknown as WorldJsonCountryData); - }); + ); } }, [theme, systemTheme, maxValue, dataByCountryCode]); From d41bd3023fd85603eb33f08ee2b7038e84d903b1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 03:05:40 +0100 Subject: [PATCH 14/28] =?UTF-8?q?=F0=9F=90=9B=20filter=20by=20resource=20U?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LogAnalyticsData.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/LogAnalyticsData.tsx b/src/components/LogAnalyticsData.tsx index d480577ba..7eafda957 100644 --- a/src/components/LogAnalyticsData.tsx +++ b/src/components/LogAnalyticsData.tsx @@ -170,13 +170,21 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { const newSearch = new URLSearchParams( searchParams ); - newSearch.set("resourceId", newValue); + newSearch.delete("resourceId"); + if (newValue !== "all") { + newSearch.set( + "resourceId", + newValue + ); + } router.replace( `${path}?${newSearch.toString()}` ); }} - value={filters.resourceId?.toString()} + value={ + filters.resourceId?.toString() ?? "all" + } > ))} + + All resources +
From 7924f195aae3eba8edd702c42728806258156bba Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 04:47:13 +0100 Subject: [PATCH 15/28] =?UTF-8?q?=F0=9F=92=84handle=20empty=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 ++- src/components/LogAnalyticsData.tsx | 31 ++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6d6172795..900978607 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2154,5 +2154,6 @@ "niceIdUpdateErrorDescription": "An error occurred while updating the Nice ID.", "niceIdCannotBeEmpty": "Nice ID cannot be empty", "enterIdentifier": "Enter identifier", - "identifier": "Identifier" + "identifier": "Identifier", + "noData": "No Data" } diff --git a/src/components/LogAnalyticsData.tsx b/src/components/LogAnalyticsData.tsx index 7eafda957..f6845742f 100644 --- a/src/components/LogAnalyticsData.tsx +++ b/src/components/LogAnalyticsData.tsx @@ -11,7 +11,7 @@ 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 { RefreshCw, XIcon } from "lucide-react"; +import { LoaderIcon, RefreshCw, XIcon } from "lucide-react"; import { DateRangePicker, type DateTimeValue } from "./DateTimePicker"; import { Button } from "./ui/button"; import { cn } from "@app/lib/cn"; @@ -74,7 +74,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { const { data: stats, isFetching: isFetchingAnalytics, - refetch: refreshAnalytics + refetch: refreshAnalytics, + isLoading: isLoadingAnalytics // only `true` when there is no data yet } = useQuery( logQueries.requestAnalytics({ orgId: props.orgId, @@ -296,6 +297,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { @@ -310,6 +312,7 @@ type TopCountriesListProps = { count: number; }[]; total: number; + isLoading: boolean; }; function TopCountriesList(props: TopCountriesListProps) { @@ -331,13 +334,27 @@ function TopCountriesList(props: TopCountriesListProps) { return (
-
-
{t("countries")}
-
{t("total")}
-
%
-
+ {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 ( From 82cc51424b57e9dcccbc7990469f290f90dda181 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 04:47:42 +0100 Subject: [PATCH 16/28] =?UTF-8?q?=F0=9F=94=A8also=20export=20`driver`=20in?= =?UTF-8?q?=20the=20db=20driver=20generation=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f947d7dd0..b178ed76d 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", From a42d0127881a0bf45cd84b202fb497d84de1e5dd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 04:48:01 +0100 Subject: [PATCH 17/28] =?UTF-8?q?=E2=9C=A8load=20logs=20per=20day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auditLogs/queryRequestAnalytics.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index 234f498b4..adf5db4a9 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, resources } from "@server/db"; +import { db, requestAuditLog, driver } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -95,11 +95,41 @@ async function query(query: Q) { .groupBy(requestAuditLog.location) .orderBy(desc(totalQ)); + const groupByDayFunction = + driver === "pg" + ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))`.as( + "day" + ) + : sql`DATE(${requestAuditLog.timestamp}, 'unixepoch')`.as( + "day" + ); + + const booleanTrue = driver === "pg" ? sql`true` : sql`1`; + const booleanFalse = driver === "pg" ? sql`false` : sql`0`; + + const requestsPerDay = await db + .select({ + day: groupByDayFunction, + 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) + .groupBy(groupByDayFunction) + .orderBy(groupByDayFunction); + return { requestsPerCountry: requestsPerCountry as Array<{ code: string; count: number; }>, + requestsPerDay, totalBlocked: blocked.total, totalRequests: all.total }; From 2082c5eed25cef13029149d1716933b0cbf561ea Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Nov 2025 04:50:06 +0100 Subject: [PATCH 18/28] =?UTF-8?q?=F0=9F=9A=A7=20Add=20shadCN=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 195 +++++++++++++++-- package.json | 1 + src/components/ui/chart.tsx | 408 ++++++++++++++++++++++++++++++++++++ 3 files changed, 589 insertions(+), 15 deletions(-) create mode 100644 src/components/ui/chart.tsx diff --git a/package-lock.json b/package-lock.json index 614a17331..9e645a0a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "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", @@ -1650,6 +1651,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1900,6 +1902,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -4079,6 +4090,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -7245,6 +7257,7 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7450,6 +7463,7 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7460,6 +7474,7 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8894,6 +8909,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -8999,6 +9015,7 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -9094,7 +9111,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-axis": { @@ -9128,7 +9144,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-contour": { @@ -9177,7 +9192,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-fetch": { @@ -9225,7 +9239,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -9235,7 +9248,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-polygon": { @@ -9263,7 +9275,6 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -9287,7 +9298,6 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -9297,7 +9307,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-time-format": { @@ -9311,7 +9320,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-transition": { @@ -9369,6 +9377,7 @@ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9469,6 +9478,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9504,6 +9514,7 @@ "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9537,6 +9548,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9547,6 +9559,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9711,6 +9724,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -10384,6 +10398,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10868,6 +10883,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10923,6 +10939,7 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -11035,6 +11052,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -11692,7 +11710,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/d3": { @@ -12021,6 +12038,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12223,6 +12241,12 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -12409,6 +12433,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -12454,8 +12488,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true + "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/domutils": { "version": "3.2.2", @@ -13595,6 +13628,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13691,6 +13725,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13868,6 +13903,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14128,6 +14164,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -14176,6 +14218,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -14262,6 +14305,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -16404,6 +16456,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -16794,7 +16852,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.1.7", "marked": "14.0.0" @@ -16805,7 +16862,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16928,6 +16984,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", @@ -19374,6 +19431,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -20358,6 +20416,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -20534,6 +20593,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20991,6 +21051,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -21021,6 +21082,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -21796,6 +21858,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -21884,6 +21947,21 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -21906,6 +21984,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -21958,6 +22052,44 @@ "node": ">=0.8.8" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -22301,6 +22433,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23520,7 +23653,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -23660,6 +23794,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tiny-lru": { "version": "11.4.5", "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", @@ -24554,6 +24694,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24822,6 +24963,28 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/visionscarto-world-atlas": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/visionscarto-world-atlas/-/visionscarto-world-atlas-1.0.0.tgz", @@ -25073,6 +25236,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -25379,6 +25543,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b178ed76d..66b7db3ea 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "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", diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 000000000..76f339f0f --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,408 @@ +"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 ( +