diff --git a/Dockerfile b/Dockerfile index 1a1493a5..57c40198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,9 @@ RUN npm ci COPY . . RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts +RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts -RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts +RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts # Copy the appropriate TypeScript configuration based on build type RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \ @@ -30,9 +31,9 @@ RUN mkdir -p dist RUN npm run next:build RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD RUN if [ "$DATABASE" = "pg" ]; then \ - node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \ + node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \ else \ - node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \ + node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \ fi # test to make sure the build output is there and error if not diff --git a/messages/en-US.json b/messages/en-US.json index 0a78ccfe..f0494f0a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -436,6 +436,16 @@ "inviteEmailSent": "Send invite email to user", "inviteValid": "Valid For", "selectDuration": "Select duration", + "selectResource": "Select Resource", + "filterByResource": "Filter By Resource", + "resetFilters": "Reset Filters", + "totalBlocked": "Requests Blocked By Pangolin", + "totalRequests": "Total Requests", + "requestsByCountry": "Requests By Country", + "requestsByDay": "Requests By Day", + "blocked": "Blocked", + "allowed": "Allowed", + "topCountries": "Top Countries", "accessRoleSelect": "Select role", "inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.", "inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.", @@ -704,6 +714,7 @@ "resourceTransferSubmit": "Transfer Resource", "siteDestination": "Destination Site", "searchSites": "Search sites", + "countries": "Countries", "accessRoleCreate": "Create Role", "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", "accessRoleCreateSubmit": "Create Role", @@ -1165,7 +1176,11 @@ "sidebarLicense": "License", "sidebarClients": "Clients", "sidebarDomains": "Domains", + "sidebarGeneral": "General", + "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarOrganization": "Organization", + "sidebarLogsAnalytics": "Request Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", @@ -2000,6 +2015,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...", @@ -2025,6 +2041,7 @@ "ip": "IP", "reason": "Reason", "requestLogs": "Request Logs", + "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", "actionLogs": "Action Logs", @@ -2034,6 +2051,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", @@ -2141,5 +2159,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/next.config.ts b/next.config.ts index a211a701..05ed8e62 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,9 @@ const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true }, + experimental: { + reactCompiler: true + }, output: "standalone" }; diff --git a/package-lock.json b/package-lock.json index 45ff4321..9e645a0a 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", @@ -94,15 +95,18 @@ "react-hook-form": "7.66.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", + "recharts": "^2.15.4", "reodotdev": "^1.0.0", "resend": "^6.4.2", "semver": "^7.7.3", "stripe": "18.2.1", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", + "topojson-client": "^3.1.0", "tw-animate-css": "^1.3.8", "uuid": "^13.0.0", "vaul": "1.1.2", + "visionscarto-world-atlas": "^1.0.0", "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", @@ -121,6 +125,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,8 +139,10 @@ "@types/react-dom": "19.2.2", "@types/semver": "^7.7.1", "@types/swagger-ui-express": "^4.1.8", + "@types/topojson-client": "^3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.34", + "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "0.31.6", "esbuild": "0.27.0", "esbuild-node-externals": "1.19.1", @@ -1895,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", @@ -9052,6 +9068,281 @@ "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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", @@ -9116,6 +9407,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", @@ -9317,6 +9615,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", @@ -10558,6 +10877,17 @@ "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", + "peer": true, + "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", @@ -11380,9 +11710,419 @@ "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": { + "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", + "peer": true, + "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", @@ -11501,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", @@ -11597,6 +12343,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", @@ -11678,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", @@ -13399,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", @@ -13534,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", @@ -14567,6 +15347,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", @@ -15667,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", @@ -21152,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", @@ -21174,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", @@ -21226,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", @@ -21399,6 +22263,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", @@ -21438,6 +22308,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", @@ -22918,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", @@ -22971,6 +23853,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", @@ -24061,6 +24963,34 @@ "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", + "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 4488d7da..66b7db3e 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json", "set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", - "set:sqlite": "echo 'export * from \"./sqlite\";' > server/db/index.ts", - "set:pg": "echo 'export * from \"./pg\";' > server/db/index.ts", + "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts", + "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts", "next:build": "next build", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", @@ -79,6 +79,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "d3": "^7.9.0", "date-fns": "4.1.0", "drizzle-orm": "0.44.7", "eslint": "9.39.1", @@ -117,15 +118,18 @@ "react-hook-form": "7.66.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", + "recharts": "^2.15.4", "reodotdev": "^1.0.0", "resend": "^6.4.2", "semver": "^7.7.3", "stripe": "18.2.1", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", + "topojson-client": "^3.1.0", "tw-animate-css": "^1.3.8", "uuid": "^13.0.0", "vaul": "1.1.2", + "visionscarto-world-atlas": "^1.0.0", "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", @@ -138,27 +142,30 @@ "@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", "@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", "@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", "@types/semver": "^7.7.1", "@types/swagger-ui-express": "^4.1.8", + "@types/topojson-client": "^3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.34", + "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "0.31.6", "esbuild": "0.27.0", "esbuild-node-externals": "1.19.1", diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 6dbef7e8..8a614cc3 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -13,9 +13,12 @@ function createDb() { connection_string: process.env.POSTGRES_CONNECTION_STRING }; if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { - const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map((conn) => ({ - connection_string: conn.trim() - })); + const replicas = + process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split( + "," + ).map((conn) => ({ + connection_string: conn.trim() + })); config.postgres.replicas = replicas; } } else { @@ -40,28 +43,44 @@ function createDb() { connectionString, max: poolConfig?.max_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, - connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000, + connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); const replicas = []; if (!replicaConnections.length) { - replicas.push(DrizzlePostgres(primaryPool)); + replicas.push( + DrizzlePostgres(primaryPool, { + logger: process.env.NODE_ENV === "development" + }) + ); } else { for (const conn of replicaConnections) { const replicaPool = new Pool({ connectionString: conn.connection_string, max: poolConfig?.max_replica_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, - connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000, + connectionTimeoutMillis: + poolConfig?.connection_timeout_ms || 5000 }); - replicas.push(DrizzlePostgres(replicaPool)); + replicas.push( + DrizzlePostgres(replicaPool, { + logger: process.env.NODE_ENV === "development" + }) + ); } } - return withReplicas(DrizzlePostgres(primaryPool), replicas as any); + return withReplicas( + DrizzlePostgres(primaryPool, { + logger: process.env.NODE_ENV === "development" + }), + replicas as any + ); } export const db = createDb(); export default db; -export type Transaction = Parameters[0]>[0]; \ No newline at end of file +export type Transaction = Parameters< + Parameters<(typeof db)["transaction"]>[0] +>[0]; diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequestAuditLog.ts similarity index 85% rename from server/routers/auditLogs/exportRequstAuditLog.ts rename to server/routers/auditLogs/exportRequestAuditLog.ts index 89df2d3f..9e55cfc4 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequestAuditLog.ts @@ -6,7 +6,11 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; +import { + queryAccessAuditLogsQuery, + queryRequestAuditLogsParams, + queryRequest +} from "./queryRequestAuditLog"; import { generateCSV } from "./generateCSV"; registry.registerPath({ @@ -54,10 +58,13 @@ export async function exportRequestAuditLogs( const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); - - res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); - + + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"` + ); + return res.send(csvData); } catch (error) { logger.error(error); diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 4823831d..9bea762f 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -1,2 +1,3 @@ -export * from "./queryRequstAuditLog"; -export * from "./exportRequstAuditLog"; \ No newline at end of file +export * from "./queryRequestAuditLog"; +export * from "./queryRequestAnalytics"; +export * from "./exportRequestAuditLog"; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts new file mode 100644 index 00000000..9e4ea17e --- /dev/null +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -0,0 +1,192 @@ +import { db, requestAuditLog, driver } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count, sql, desc, not, isNull } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +const queryAccessAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional(), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .prefault(new Date().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "End time as ISO date string (defaults to current time)" + }), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional() +}); + +const queryRequestAuditLogsParams = z.object({ + orgId: z.string() +}); + +const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryRequestAuditLogsParams +); + +type Q = z.infer; + +async function query(query: Q) { + let baseConditions = and( + eq(requestAuditLog.orgId, query.orgId), + lt(requestAuditLog.timestamp, query.timeEnd) + ); + + if (query.timeStart) { + baseConditions = and( + baseConditions, + gt(requestAuditLog.timestamp, query.timeStart) + ); + } + if (query.resourceId) { + baseConditions = and( + baseConditions, + eq(requestAuditLog.resourceId, query.resourceId) + ); + } + + const [all] = await db + .select({ total: count() }) + .from(requestAuditLog) + .where(baseConditions); + + const [blocked] = await db + .select({ total: count() }) + .from(requestAuditLog) + .where(and(baseConditions, eq(requestAuditLog.action, false))); + + const totalQ = sql`count(${requestAuditLog.id})` + .mapWith(Number) + .as("total"); + + const requestsPerCountry = await db + .selectDistinct({ + code: requestAuditLog.location, + count: totalQ + }) + .from(requestAuditLog) + .where(and(baseConditions, not(isNull(requestAuditLog.location)))) + .groupBy(requestAuditLog.location) + .orderBy(desc(totalQ)); + + const groupByDayFunction = + driver === "pg" + ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))` + : sql`DATE(${requestAuditLog.timestamp}, 'unixepoch')`; + + const booleanTrue = driver === "pg" ? sql`true` : sql`1`; + const booleanFalse = driver === "pg" ? sql`false` : sql`0`; + + const requestsPerDay = await db + .select({ + day: groupByDayFunction.as("day"), + allowedCount: + sql`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanTrue} THEN 1 ELSE 0 END)`.as( + "allowed_count" + ), + blockedCount: + sql`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanFalse} THEN 1 ELSE 0 END)`.as( + "blocked_count" + ), + totalCount: sql`COUNT(*)`.as("total_count") + }) + .from(requestAuditLog) + .where(and(baseConditions)) + .groupBy(groupByDayFunction) + .orderBy(groupByDayFunction); + + return { + requestsPerCountry: requestsPerCountry as Array<{ + code: string; + count: number; + }>, + requestsPerDay, + totalBlocked: blocked.total, + totalRequests: all.total + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/analytics", + description: "Query the request audit analytics for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +export type QueryRequestAnalyticsResponse = Awaited>; + +export async function queryRequestAnalytics( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const params = { ...parsedQuery.data, ...parsedParams.data }; + + const data = await query(params); + + return response(res, { + data, + success: true, + error: false, + message: "Request audit analytics retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts similarity index 90% rename from server/routers/auditLogs/queryRequstAuditLog.ts rename to server/routers/auditLogs/queryRequestAuditLog.ts index 8c9aa902..6c56c186 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -31,7 +31,8 @@ export const queryAccessAuditLogsQuery = z.object({ .openapi({ type: "string", format: "date-time", - description: "End time as ISO date string (defaults to current time)" + description: + "End time as ISO date string (defaults to current time)" }), action: z .union([z.boolean(), z.string()]) @@ -72,8 +73,9 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export const queryRequestAuditLogsCombined = - queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryRequestAuditLogsParams +); type Q = z.infer; function getWhere(data: Q) { @@ -209,11 +211,21 @@ async function queryUniqueFilterAttributes( .where(baseConditions); return { - actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), - resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), - locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null), - hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null), - paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null) + actors: uniqueActors + .map((row) => row.actor) + .filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter( + (row): row is { id: number; name: string | null } => row.id !== null + ), + locations: uniqueLocations + .map((row) => row.locations) + .filter((location): location is string => location !== null), + hosts: uniqueHosts + .map((row) => row.hosts) + .filter((host): host is string => host !== null), + paths: uniquePaths + .map((row) => row.paths) + .filter((path): path is string => path !== null) }; } @@ -270,7 +282,7 @@ export async function queryRequestAuditLogs( }, success: true, error: false, - message: "Action audit logs retrieved successfully", + message: "Request audit logs retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/external.ts b/server/routers/external.ts index f500f483..13553b3f 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( @@ -308,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, @@ -354,7 +360,7 @@ authenticated.delete( verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), - user.removeInvitation, + user.removeInvitation ); authenticated.post( @@ -362,7 +368,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 +404,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 +419,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", @@ -427,7 +433,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), - resource.createResourceRule, + resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", @@ -440,14 +446,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 +467,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 +482,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( "/org/:orgId/roles", @@ -502,7 +508,7 @@ authenticated.delete( verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", @@ -510,7 +516,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); authenticated.post( @@ -519,7 +525,7 @@ authenticated.post( verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -528,7 +534,7 @@ authenticated.post( verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( @@ -536,7 +542,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), - resource.setResourcePassword, + resource.setResourcePassword ); authenticated.post( @@ -544,7 +550,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), - resource.setResourcePincode, + resource.setResourcePincode ); authenticated.post( @@ -552,7 +558,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth, + resource.setResourceHeaderAuth ); authenticated.post( @@ -560,7 +566,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist, + resource.setResourceWhitelist ); authenticated.get( @@ -575,7 +581,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken, + accessToken.generateAccessToken ); authenticated.delete( @@ -583,7 +589,7 @@ authenticated.delete( verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken, + accessToken.deleteAccessToken ); authenticated.get( @@ -657,7 +663,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), - user.createOrgUser, + user.createOrgUser ); authenticated.post( @@ -666,7 +672,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 +696,7 @@ authenticated.delete( verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -821,7 +827,7 @@ authenticated.post( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions, + apiKeys.setApiKeyActions ); authenticated.get( @@ -837,7 +843,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey, + apiKeys.createOrgApiKey ); authenticated.delete( @@ -846,7 +852,7 @@ authenticated.delete( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey, + apiKeys.deleteOrgApiKey ); authenticated.get( @@ -862,7 +868,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), - domain.createOrgDomain, + domain.createOrgDomain ); authenticated.post( @@ -871,7 +877,7 @@ authenticated.post( verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain, + domain.restartOrgDomain ); authenticated.delete( @@ -880,7 +886,7 @@ authenticated.delete( verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain, + domain.deleteAccountDomain ); authenticated.get( @@ -890,6 +896,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 +1252,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); \ No newline at end of file +); diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index d1c7011d..687195c9 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 00000000..80b21fd4 --- /dev/null +++ b/server/routers/resource/listAllResourceNames.ts @@ -0,0 +1,90 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceHeaderAuth } from "@server/db"; +import { + resources, + userResources, + roleResources, + resourcePassword, + resourcePincode, + targets, + targetHealthCheck +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import type { + ResourceWithTargets, + ListResourcesResponse +} from "./listResources"; + +const listResourcesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +function queryResourceNames(orgId: string) { + return db + .select({ + resourceId: resources.resourceId, + name: resources.name + }) + .from(resources) + + .where(eq(resources.orgId, orgId)); +} + +export type ListResourceNamesResponse = Awaited< + ReturnType +>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resources-names", + description: "List all resource names for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: z.object({ + orgId: z.string() + }) + }, + responses: {} +}); + +export async function listAllResourceNames( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const orgId = parsedParams.data.orgId; + + const data = await queryResourceNames(orgId); + + return response(res, { + data, + success: true, + error: false, + message: "Resource Names retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a72dd763..1c8f0864 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -8,21 +8,19 @@ import { resourcePassword, resourcePincode, targets, - targetHealthCheck, + targetHealthCheck } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, eq, or, inArray, and, count } from "drizzle-orm"; import logger from "@server/logger"; -import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { warn } from "console"; const listResourcesParamsSchema = z.strictObject({ - orgId: z.string() - }); + orgId: z.string() +}); const listResourcesSchema = z.object({ limit: z @@ -67,7 +65,7 @@ type JoinedRow = { hcEnabled: boolean | null; }; -// grouped by resource with targets[]) +// grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; name: string; @@ -89,7 +87,7 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: 'healthy' | 'unhealthy' | 'unknown'; + healthStatus?: "healthy" | "unhealthy" | "unknown"; }>; }; @@ -118,7 +116,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { targetEnabled: targets.enabled, hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled, + hcEnabled: targetHealthCheck.hcEnabled }) .from(resources) .leftJoin( @@ -273,16 +271,25 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [], + targets: [] }; map.set(row.resourceId, entry); } - if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) { - let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown'; + if ( + row.targetId != null && + row.targetIp && + row.targetPort != null && + row.targetEnabled != null + ) { + let healthStatus: "healthy" | "unhealthy" | "unknown" = + "unknown"; if (row.hcEnabled && row.hcHealth) { - healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown'; + healthStatus = row.hcHealth as + | "healthy" + | "unhealthy" + | "unknown"; } entry.targets.push({ @@ -290,7 +297,7 @@ export async function listResources( ip: row.targetIp, port: row.targetPort, enabled: row.targetEnabled, - healthStatus: healthStatus, + healthStatus: healthStatus }); } } diff --git a/src/app/[orgId]/settings/logs/analytics/page.tsx b/src/app/[orgId]/settings/logs/analytics/page.tsx new file mode 100644 index 00000000..f5bd4e7a --- /dev/null +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -0,0 +1,28 @@ +import { LogAnalyticsData } from "@app/components/LogAnalyticsData"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; + +export interface AnalyticsPageProps { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +} + +export default async function AnalyticsPage(props: AnalyticsPageProps) { + const t = await getTranslations(); + + const orgId = (await props.params).orgId; + + return ( + <> + + +
+ +
+ + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 1147e37e..221e228e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -136,6 +136,24 @@ } } +@layer base { + :root { + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + } + + .dark { + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + } +} + p { word-break: keep-all; white-space: normal; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8907a49..31650809 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/app/navigation.tsx b/src/app/navigation.tsx index e3478fa1..c3380415 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/BlueprintDetailsForm.tsx b/src/components/BlueprintDetailsForm.tsx index c97ca31a..ae6d5cb1 100644 --- a/src/components/BlueprintDetailsForm.tsx +++ b/src/components/BlueprintDetailsForm.tsx @@ -11,7 +11,6 @@ import { useTranslations } from "next-intl"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index d0b6d40e..150bafdb 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -7,209 +7,220 @@ import { Calendar } from "@app/components/ui/calendar"; import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { ChangeEvent, useEffect, useState } from "react"; export interface DateTimeValue { - date?: Date; - time?: string; + date?: Date; + time?: string; } export interface DateTimePickerProps { - label?: string; - value?: DateTimeValue; - onChange?: (value: DateTimeValue) => void; - placeholder?: string; - className?: string; - disabled?: boolean; - showTime?: boolean; + label?: string; + value?: DateTimeValue; + onChange?: (value: DateTimeValue) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + showTime?: boolean; } export function DateTimePicker({ - label, - value, - onChange, - placeholder = "Select date & time", - className, - disabled = false, - showTime = true, + label, + value, + onChange, + placeholder = "Select date & time", + className, + disabled = false, + showTime = true }: DateTimePickerProps) { - const [open, setOpen] = useState(false); - const [internalDate, setInternalDate] = useState(value?.date); - const [internalTime, setInternalTime] = useState(value?.time || ""); + const [open, setOpen] = useState(false); + const [internalDate, setInternalDate] = useState( + value?.date + ); + const [internalTime, setInternalTime] = useState(value?.time || ""); - // Sync internal state with external value prop - useEffect(() => { - setInternalDate(value?.date); - setInternalTime(value?.time || ""); - }, [value?.date, value?.time]); + // Sync internal state with external value prop + useEffect(() => { + setInternalDate(value?.date); + setInternalTime(value?.time || ""); + }, [value?.date, value?.time]); - const handleDateChange = (date: Date | undefined) => { - setInternalDate(date); - const newValue = { date, time: internalTime }; - onChange?.(newValue); - }; + const handleDateChange = (date: Date | undefined) => { + setInternalDate(date); + const newValue = { date, time: internalTime }; + onChange?.(newValue); + }; - const handleTimeChange = (event: ChangeEvent) => { - const time = event.target.value; - setInternalTime(time); - const newValue = { date: internalDate, time }; - onChange?.(newValue); - }; + const handleTimeChange = (event: ChangeEvent) => { + const time = event.target.value; + setInternalTime(time); + const newValue = { date: internalDate, time }; + onChange?.(newValue); + }; -const getDisplayText = () => { - if (!internalDate) return placeholder; - - const dateStr = internalDate.toLocaleDateString(); - if (!showTime || !internalTime) return dateStr; - - // Parse time and format in local timezone - const [hours, minutes, seconds] = internalTime.split(':'); - const timeDate = new Date(); - timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10)); - const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - - return `${dateStr} ${timeStr}`; -}; + const getDisplayText = () => { + if (!internalDate) return placeholder; - const hasValue = internalDate || (showTime && internalTime); + const dateStr = internalDate.toLocaleDateString(); + if (!showTime || !internalTime) return dateStr; - return ( -
-
- {label && ( - - )} -
- - - - - - {showTime ? ( -
- { - handleDateChange(date); - if (!showTime) { - setOpen(false); - } - }} - className="flex-grow w-[250px]" - /> -
-
- - -
-
+ // Parse time and format in local timezone + const [hours, minutes, seconds] = internalTime.split(":"); + const timeDate = new Date(); + timeDate.setHours( + parseInt(hours, 10), + parseInt(minutes, 10), + parseInt(seconds || "0", 10) + ); + const timeStr = timeDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit" + }); + + return `${dateStr} ${timeStr}`; + }; + + const hasValue = internalDate || (showTime && internalTime); + + return ( +
+
+ {label && } +
+ + + + + + {showTime ? ( +
+ { + handleDateChange(date); + if (!showTime) { + setOpen(false); + } + }} + className="grow w-[250px]" + /> +
+
+ + +
+
+
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )} +
+
- ) : ( - { - handleDateChange(date); - setOpen(false); - }} - /> - )} - - +
-
-
- ); + ); } export interface DateRangePickerProps { - startLabel?: string; - endLabel?: string; - startValue?: DateTimeValue; - endValue?: DateTimeValue; - onStartChange?: (value: DateTimeValue) => void; - onEndChange?: (value: DateTimeValue) => void; - onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; - className?: string; - disabled?: boolean; - showTime?: boolean; + startLabel?: string; + endLabel?: string; + startValue?: DateTimeValue; + endValue?: DateTimeValue; + onStartChange?: (value: DateTimeValue) => void; + onEndChange?: (value: DateTimeValue) => void; + onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; + className?: string; + disabled?: boolean; + showTime?: boolean; } export function DateRangePicker({ -// startLabel = "From", -// endLabel = "To", - startValue, - endValue, - onStartChange, - onEndChange, - onRangeChange, - className, - disabled = false, - showTime = true, + // startLabel = "From", + // endLabel = "To", + startValue, + endValue, + onStartChange, + onEndChange, + onRangeChange, + className, + disabled = false, + showTime = true }: DateRangePickerProps) { - const handleStartChange = (value: DateTimeValue) => { - onStartChange?.(value); - if (onRangeChange && endValue) { - onRangeChange(value, endValue); - } - }; + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; - const handleEndChange = (value: DateTimeValue) => { - onEndChange?.(value); - if (onRangeChange && startValue) { - onRangeChange(startValue, value); - } - }; + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; - return ( -
- - -
- ); -} \ No newline at end of file + return ( +
+ + +
+ ); +} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index 5959bfc3..b1cc74a8 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -11,7 +11,7 @@ export function InfoSections({ }) { return (
createApiClient(env)); + const router = useRouter(); + + const dateRange = { + startDate: filters.timeStart ? new Date(filters.timeStart) : undefined, + endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined + }; + + const { data: resources = [], isFetching: isFetchingResources } = useQuery( + resourceQueries.listNamesPerOrg(props.orgId, api) + ); + + const { + data: stats, + isFetching: isFetchingAnalytics, + refetch: refreshAnalytics, + isLoading: isLoadingAnalytics // only `true` when there is no data yet + } = useQuery( + logQueries.requestAnalytics({ + orgId: props.orgId, + api, + filters + }) + ); + + const percentBlocked = stats + ? new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 2 + }).format((stats.totalBlocked / stats.totalRequests) * 100) + : 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("requestsByDay")}

+
+ + + +
+ +
+ + +

+ {t("requestsByCountry")} +

+
+ + + +
+ + + +

{t("topCountries")}

+
+ + + +
+
+
+ ); +} + +type RequestChartProps = { + data: { + day: string; + allowedCount: number; + blockedCount: number; + totalCount: number; + }[]; + isLoading: boolean; +}; + +function RequestChart(props: RequestChartProps) { + const t = useTranslations(); + + const numberFormatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" + }); + + const chartConfig = { + day: { + label: t("requestsByDay") + }, + blockedCount: { + label: t("blocked"), + color: "var(--chart-5)" + }, + allowedCount: { + label: t("allowed"), + color: "var(--chart-2)" + } + } satisfies ChartConfig; + + return ( + + + } /> + { + const formattedDate = new Date( + payload[0].payload.day + ).toLocaleDateString(navigator.language, { + dateStyle: "medium" + }); + return formattedDate; + }} + /> + } + /> + + + datum.totalCount)) + ]} + allowDataOverflow + type="number" + tickFormatter={(value) => { + return numberFormatter.format(value); + }} + /> + { + return new Date(value).toLocaleDateString( + navigator.language, + { + dateStyle: "medium" + } + ); + }} + /> + + + + + + ); +} + +type TopCountriesListProps = { + countries: { + code: string; + count: number; + }[]; + total: number; + isLoading: boolean; +}; + +function TopCountriesList(props: TopCountriesListProps) { + const t = useTranslations(); + const displayNames = new Intl.DisplayNames(navigator.language, { + type: "region", + fallback: "code" + }); + + const numberFormatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" + }); + const percentFormatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 0, + style: "percent" + }); + + return ( +
+ {props.countries.length > 0 && ( +
+
{t("countries")}
+
{t("total")}
+
%
+
+ )} + {/* `aspect-475/335` is the same aspect ratio as the world map component */} +
    + {props.countries.length === 0 && ( +
    + {props.isLoading ? ( + <> + {" "} + {t("loading")} + + ) : ( + t("noData") + )} +
    + )} + {props.countries.map((country) => { + const percent = country.count / props.total; + return ( +
  1. +
    +
    + + {countryCodeToFlagEmoji(country.code)}{" "} + {displayNames.of(country.code)} + +
    + +
    + + + + + + + {Intl.NumberFormat( + navigator.language + ).format(country.count)} + {" "} + {country.count === 1 + ? t("request") + : t("requests")} + + +
    + +
    + {percentFormatter.format(percent)} +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 7aaebfff..b48e7bbf 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -162,7 +162,7 @@ export function SidebarNav({
{!isCollapsed && (
- {section.heading} + {t(section.heading)}
)}
diff --git a/src/components/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx new file mode 100644 index 00000000..19b84ae5 --- /dev/null +++ b/src/components/TailwindIndicator.tsx @@ -0,0 +1,27 @@ +"use client"; +import * as React from "react"; + +export function TailwindIndicator() { + const [mediaSize, setMediaSize] = React.useState(0); + React.useEffect(() => { + const listener = () => setMediaSize(window.innerWidth); + window.addEventListener("resize", listener); + + listener(); + return () => { + window.removeEventListener("resize", listener); + }; + }, []); + + return ( +
+
xs
+
sm
+
md
+
lg
+
xl
+
2xl
| {mediaSize} + px +
+ ); +} diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx new file mode 100644 index 00000000..c64c3f43 --- /dev/null +++ b/src/components/WorldMap.tsx @@ -0,0 +1,275 @@ +/** + * Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx + */ +import { cn } from "@app/lib/cn"; +import worldJson from "visionscarto-world-atlas/world/110m.json"; +import * as topojson from "topojson-client"; +import * as d3 from "d3"; +import { useRef, type ComponentRef, useState, useEffect, useMemo } from "react"; +import { useTheme } from "next-themes"; +import { COUNTRY_CODE_LIST } from "@app/lib/countryCodeList"; +import { useTranslations } from "next-intl"; + +type CountryData = { + alpha_3: string; + name: string; + count: number; + code: string; +}; + +export type WorldMapProps = { + data: Pick[]; + label: { + singular: string; + plural: string; + }; +}; + +export function WorldMap({ data, label }: WorldMapProps) { + const svgRef = useRef>(null); + const [tooltip, setTooltip] = useState<{ + x: number; + y: number; + hoveredCountryAlpha3Code: string | null; + }>({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); + const { theme, systemTheme } = useTheme(); + + const t = useTranslations(); + + useEffect(() => { + if (!svgRef.current) return; + const svg = drawInteractiveCountries(svgRef.current, setTooltip); + + return () => { + svg.selectAll("*").remove(); + }; + }, []); + + const displayNames = new Intl.DisplayNames(navigator.language, { + type: "region", + fallback: "code" + }); + + const maxValue = Math.max(...data.map((item) => item.count)); + const dataByCountryCode = useMemo(() => { + const byCountryCode = new Map(); + for (const country of data) { + const countryISOData = COUNTRY_CODE_LIST[country.code]; + + if (countryISOData) { + byCountryCode.set(countryISOData.alpha3, { + ...country, + name: displayNames.of(country.code)!, + alpha_3: countryISOData.alpha3 + }); + } + } + return byCountryCode; + }, [data]); + + useEffect(() => { + if (svgRef.current) { + const palette = + colorScales[theme ?? "light"] ?? + colorScales[systemTheme ?? "light"]; + + const getColorForValue = d3 + .scaleLinear() + .domain([0, maxValue]) + .range(palette); + + colorInCountriesWithValues( + svgRef.current, + getColorForValue, + dataByCountryCode + ); + } + }, [theme, systemTheme, maxValue, dataByCountryCode]); + + const hoveredCountryData = tooltip.hoveredCountryAlpha3Code + ? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code) + : undefined; + + return ( +
+ + + {!!hoveredCountryData && ( + + )} +
+ ); +} + +interface MapTooltipProps { + name: string; + value: string; + label: string; + x: number; + y: number; +} + +function MapTooltip({ name, value, label, x, y }: MapTooltipProps) { + return ( +
+
{name}
+ {value} {label} +
+ ); +} + +const width = 475; +const height = 335; +const sharedCountryClass = cn("transition-colors"); + +const colorScales: Record = { + dark: ["#4F4444", "#f36117"], + light: ["#FFF5F3", "#f36117"] +}; + +const countryClass = cn( + sharedCountryClass, + "stroke-1", + "fill-[#fafafa]", + "stroke-[#E7DADA]", + "dark:fill-[#323236]", + "dark:stroke-[#18181b]" +); + +const highlightedCountryClass = cn( + sharedCountryClass, + "stroke-2", + "fill-[#f4f4f5]", + "stroke-[#f36117]", + "dark:fill-[#3f3f46]" +); + +function setupProjetionPath() { + const projection = d3 + .geoMercator() + .scale(75) + .translate([width / 2, height / 1.5]); + + const path = d3.geoPath().projection(projection); + return path; +} + +/** @returns the d3 selected svg element */ +function drawInteractiveCountries( + element: SVGSVGElement, + setTooltip: React.Dispatch< + React.SetStateAction<{ + x: number; + y: number; + hoveredCountryAlpha3Code: string | null; + }> + > +) { + const path = setupProjetionPath(); + const data = parseWorldTopoJsonToGeoJsonFeatures(); + const svg = d3.select(element); + + svg.selectAll("path") + .data(data) + .enter() + .append("path") + .attr("class", countryClass) + .attr("d", path as never) + + .on("mouseover", function (event, country) { + const [x, y] = d3.pointer(event, svg.node()?.parentNode); + setTooltip({ + x, + y, + hoveredCountryAlpha3Code: country.properties.a3 + }); + // brings country to front + this.parentNode?.appendChild(this); + d3.select(this).attr("class", highlightedCountryClass); + }) + + .on("mousemove", function (event) { + const [x, y] = d3.pointer(event, svg.node()?.parentNode); + setTooltip((currentState) => ({ ...currentState, x, y })); + }) + + .on("mouseout", function () { + setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); + d3.select(this).attr("class", countryClass); + }); + + return svg; +} + +type WorldJsonCountryData = { properties: { name: string; a3: string } }; + +function parseWorldTopoJsonToGeoJsonFeatures(): Array { + const collection = topojson.feature( + // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library + worldJson, + worldJson.objects.countries + ); + // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection + return collection.features; +} + +/** + * Used to color the countries + * @returns the svg elements represeting countries + */ +function colorInCountriesWithValues( + element: SVGSVGElement, + getColorForValue: d3.ScaleLinear, + dataByCountryCode: Map +) { + function getCountryByCountryPath(countryPath: unknown) { + return dataByCountryCode.get( + (countryPath as unknown as WorldJsonCountryData).properties.a3 + ); + } + + const svg = d3.select(element); + + return svg + .selectAll("path") + .style("fill", (countryPath) => { + const country = getCountryByCountryPath(countryPath); + if (!country?.count) { + return null; + } + return getColorForValue(country.count); + }) + .style("cursor", (countryPath) => { + const country = getCountryByCountryPath(countryPath); + if (!country?.count) { + return null; + } + return "pointer"; + }); +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 00000000..58d6a270 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ); + + if (!colorConfig.length) { + return null; + } + + return ( +