add top countries list

This commit is contained in:
Fred KISSIE
2025-11-21 02:00:47 +01:00
parent 3801354ae6
commit 5fd64596eb
5 changed files with 154 additions and 32 deletions

View File

@@ -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",

View File

@@ -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<Parameters<typeof db["transaction"]>[0]>[0];
export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0]
>[0];

View File

@@ -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<number>`count(${requestAuditLog.id})`
.mapWith(Number)
.as("total");
const requestsPerCountry = await db
.select({
country_code: requestAuditLog.location,
total: sql<number>`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
};

View File

@@ -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) {
</CardHeader>
</Card>
<div className="flex flex-col lg:flex-row items-stretch gap-5">
<Card className="w-full">
<div className="grid lg:grid-cols-2 gap-5">
<Card className="w-full h-full">
<CardHeader className="flex flex-col gap-4">
<h3 className="font-medium">
{t("requestsByCountry")}
@@ -260,12 +268,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<WorldMap
data={
stats?.requestsPerCountry.map((item) => ({
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) {
</CardContent>
</Card>
<Card className="w-full">
<Card className="w-full h-full">
<CardHeader className="flex flex-col gap-4">
<h3 className="font-medium">{t("topCountries")}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* ... */}
<CardContent className="flex h-full flex-col gap-4">
<TopCountriesList
countries={stats?.requestsPerCountry ?? []}
total={stats?.totalRequests ?? 0}
/>
</CardContent>
</Card>
</div>
</div>
);
}
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 (
<div className="h-full flex flex-col gap-2">
<div className="grid grid-cols-7 text-sm text-muted-foreground font-medium h-4">
<div className="col-span-5">{t("countries")}</div>
<div className="text-end">{t("total")}</div>
<div className="text-end">%</div>
</div>
{/* `aspect-475/335` is the same aspect ratio as the world map component */}
<ol className="w-full overflow-auto grid gap-1 aspect-475/335">
{props.countries.map((country) => {
const percent = country.count / props.total;
return (
<li
key={country.code}
className="grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm"
>
<div
className={cn(
"absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
)}
style={{
width: `${percent * 100}%`
}}
/>
<div className="col-span-5 px-2 py-1 relative z-1">
<span className="inline-flex gap-2 items-center">
{countryCodeToFlagEmoji(country.code)}{" "}
{displayNames.of(country.code)}
</span>
</div>
<TooltipProvider>
<div className="text-end">
<Tooltip>
<TooltipTrigger asChild>
<button className="inline">
{formatter.format(
country.count
)}
</button>
</TooltipTrigger>
<TooltipContent>
<strong>
{Intl.NumberFormat(
navigator.language
).format(country.count)}
</strong>{" "}
{country.count === 1
? t("request")
: t("requests")}
</TooltipContent>
</Tooltip>
</div>
<div className="text-end">
{percentFormatter.format(percent)}
</div>
</TooltipProvider>
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export function TailwindIndicator() {
}, []);
return (
<div className="fixed bottom-12 left-2 z-9999999 flex h-6 items-center justify-center gap-2 rounded-full bg-primary p-3 font-mono text-xs text-white">
<div className="fixed bottom-16 left-5 z-9999999 flex h-6 items-center justify-center gap-2 rounded-full bg-primary p-3 font-mono text-xs text-white">
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden">sm</div>
<div className="hidden md:block lg:hidden">md</div>