diff --git a/messages/en-US.json b/messages/en-US.json index 597d1516..6d617279 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -709,6 +709,7 @@ "resourceTransferSubmit": "Transfer Resource", "siteDestination": "Destination Site", "searchSites": "Search sites", + "countries": "Countries", "accessRoleCreate": "Create Role", "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", "accessRoleCreateSubmit": "Create Role", diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 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/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index c9eeaeef..234f498b 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -2,7 +2,7 @@ import { db, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, sql } from "drizzle-orm"; +import { eq, gt, lt, and, count, sql, desc, not, isNull } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -81,19 +81,25 @@ async function query(query: Q) { .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); + const totalQ = sql`count(${requestAuditLog.id})` + .mapWith(Number) + .as("total"); + const requestsPerCountry = await db - .select({ - country_code: requestAuditLog.location, - total: sql`count(${requestAuditLog.id})` - .mapWith(Number) - .as("total") + .selectDistinct({ + code: requestAuditLog.location, + count: totalQ }) .from(requestAuditLog) - .where(baseConditions) - .groupBy(requestAuditLog.location); + .where(and(baseConditions, not(isNull(requestAuditLog.location)))) + .groupBy(requestAuditLog.location) + .orderBy(desc(totalQ)); return { - requestsPerCountry, + requestsPerCountry: requestsPerCountry as Array<{ + code: string; + count: number; + }>, totalBlocked: blocked.total, totalRequests: all.total }; diff --git a/src/components/LogAnalyticsData.tsx b/src/components/LogAnalyticsData.tsx index 458736f7..d480577b 100644 --- a/src/components/LogAnalyticsData.tsx +++ b/src/components/LogAnalyticsData.tsx @@ -33,6 +33,14 @@ import { InfoSectionTitle } from "./InfoSection"; import { WorldMap } from "./WorldMap"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; +import { useTheme } from "next-themes"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; export type AnalyticsContentProps = { orgId: string; @@ -77,8 +85,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { const percentBlocked = stats ? new Intl.NumberFormat(navigator.language, { - maximumFractionDigits: 5 - }).format(stats.totalBlocked / stats.totalRequests) + maximumFractionDigits: 2 + }).format((stats.totalBlocked / stats.totalRequests) * 100) : null; const totalRequests = stats ? new Intl.NumberFormat(navigator.language, { @@ -251,8 +259,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { -
- +
+

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

{t("topCountries")}

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

); } + +type TopCountriesListProps = { + countries: { + code: string; + count: number; + }[]; + total: number; +}; + +function TopCountriesList(props: TopCountriesListProps) { + const t = useTranslations(); + const displayNames = new Intl.DisplayNames(navigator.language, { + type: "region", + fallback: "code" + }); + + const formatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" + }); + const percentFormatter = new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: 0, + style: "percent" + }); + + return ( +
+
+
{t("countries")}
+
{t("total")}
+
%
+
+ {/* `aspect-475/335` is the same aspect ratio as the world map component */} +
    + {props.countries.map((country) => { + const percent = country.count / props.total; + return ( +
  1. +
    +
    + + {countryCodeToFlagEmoji(country.code)}{" "} + {displayNames.of(country.code)} + +
    + +
    + + + + + + + {Intl.NumberFormat( + navigator.language + ).format(country.count)} + {" "} + {country.count === 1 + ? t("request") + : t("requests")} + + +
    + +
    + {percentFormatter.format(percent)} +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx index e6ae59f3..19b84ae5 100644 --- a/src/components/TailwindIndicator.tsx +++ b/src/components/TailwindIndicator.tsx @@ -14,7 +14,7 @@ export function TailwindIndicator() { }, []); return ( -
+
xs
sm
md