mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
✨add top countries list
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user