mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-07 11:16:37 +00:00
✨add top countries list
This commit is contained in:
@@ -709,6 +709,7 @@
|
|||||||
"resourceTransferSubmit": "Transfer Resource",
|
"resourceTransferSubmit": "Transfer Resource",
|
||||||
"siteDestination": "Destination Site",
|
"siteDestination": "Destination Site",
|
||||||
"searchSites": "Search sites",
|
"searchSites": "Search sites",
|
||||||
|
"countries": "Countries",
|
||||||
"accessRoleCreate": "Create Role",
|
"accessRoleCreate": "Create Role",
|
||||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
||||||
"accessRoleCreateSubmit": "Create Role",
|
"accessRoleCreateSubmit": "Create Role",
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ function createDb() {
|
|||||||
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
||||||
};
|
};
|
||||||
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
||||||
const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map((conn) => ({
|
const replicas =
|
||||||
connection_string: conn.trim()
|
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(
|
||||||
}));
|
","
|
||||||
|
).map((conn) => ({
|
||||||
|
connection_string: conn.trim()
|
||||||
|
}));
|
||||||
config.postgres.replicas = replicas;
|
config.postgres.replicas = replicas;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -40,28 +43,44 @@ function createDb() {
|
|||||||
connectionString,
|
connectionString,
|
||||||
max: poolConfig?.max_connections || 20,
|
max: poolConfig?.max_connections || 20,
|
||||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000,
|
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
const replicas = [];
|
const replicas = [];
|
||||||
|
|
||||||
if (!replicaConnections.length) {
|
if (!replicaConnections.length) {
|
||||||
replicas.push(DrizzlePostgres(primaryPool));
|
replicas.push(
|
||||||
|
DrizzlePostgres(primaryPool, {
|
||||||
|
logger: process.env.NODE_ENV === "development"
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = new Pool({
|
const replicaPool = new Pool({
|
||||||
connectionString: conn.connection_string,
|
connectionString: conn.connection_string,
|
||||||
max: poolConfig?.max_replica_connections || 20,
|
max: poolConfig?.max_replica_connections || 20,
|
||||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
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 const db = createDb();
|
||||||
export default db;
|
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 { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } 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 { OpenAPITags } from "@server/openApi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -81,19 +81,25 @@ async function query(query: Q) {
|
|||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
||||||
|
|
||||||
|
const totalQ = sql<number>`count(${requestAuditLog.id})`
|
||||||
|
.mapWith(Number)
|
||||||
|
.as("total");
|
||||||
|
|
||||||
const requestsPerCountry = await db
|
const requestsPerCountry = await db
|
||||||
.select({
|
.selectDistinct({
|
||||||
country_code: requestAuditLog.location,
|
code: requestAuditLog.location,
|
||||||
total: sql<number>`count(${requestAuditLog.id})`
|
count: totalQ
|
||||||
.mapWith(Number)
|
|
||||||
.as("total")
|
|
||||||
})
|
})
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(and(baseConditions, not(isNull(requestAuditLog.location))))
|
||||||
.groupBy(requestAuditLog.location);
|
.groupBy(requestAuditLog.location)
|
||||||
|
.orderBy(desc(totalQ));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestsPerCountry,
|
requestsPerCountry: requestsPerCountry as Array<{
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
}>,
|
||||||
totalBlocked: blocked.total,
|
totalBlocked: blocked.total,
|
||||||
totalRequests: all.total
|
totalRequests: all.total
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ import {
|
|||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "./InfoSection";
|
} from "./InfoSection";
|
||||||
import { WorldMap } from "./WorldMap";
|
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 = {
|
export type AnalyticsContentProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -77,8 +85,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
|
|
||||||
const percentBlocked = stats
|
const percentBlocked = stats
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
maximumFractionDigits: 5
|
maximumFractionDigits: 2
|
||||||
}).format(stats.totalBlocked / stats.totalRequests)
|
}).format((stats.totalBlocked / stats.totalRequests) * 100)
|
||||||
: null;
|
: null;
|
||||||
const totalRequests = stats
|
const totalRequests = stats
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
@@ -251,8 +259,8 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row items-stretch gap-5">
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
<Card className="w-full">
|
<Card className="w-full h-full">
|
||||||
<CardHeader className="flex flex-col gap-4">
|
<CardHeader className="flex flex-col gap-4">
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">
|
||||||
{t("requestsByCountry")}
|
{t("requestsByCountry")}
|
||||||
@@ -260,12 +268,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<WorldMap
|
<WorldMap
|
||||||
data={
|
data={stats?.requestsPerCountry ?? []}
|
||||||
stats?.requestsPerCountry.map((item) => ({
|
|
||||||
count: item.total,
|
|
||||||
code: item.country_code ?? "US"
|
|
||||||
})) ?? []
|
|
||||||
}
|
|
||||||
label={{
|
label={{
|
||||||
singular: "request",
|
singular: "request",
|
||||||
plural: "requests"
|
plural: "requests"
|
||||||
@@ -274,15 +277,108 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="w-full">
|
<Card className="w-full h-full">
|
||||||
<CardHeader className="flex flex-col gap-4">
|
<CardHeader className="flex flex-col gap-4">
|
||||||
<h3 className="font-medium">{t("topCountries")}</h3>
|
<h3 className="font-medium">{t("topCountries")}</h3>
|
||||||
</CardHeader>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
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="block sm:hidden">xs</div>
|
||||||
<div className="hidden sm:block md:hidden">sm</div>
|
<div className="hidden sm:block md:hidden">sm</div>
|
||||||
<div className="hidden md:block lg:hidden">md</div>
|
<div className="hidden md:block lg:hidden">md</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user