Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
Fred KISSIE
2025-12-04 23:56:16 +01:00
45 changed files with 4123 additions and 310 deletions

View File

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

View File

@@ -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.",
@@ -516,6 +526,8 @@
"targetCreatedDescription": "Target has been created successfully",
"targetErrorCreate": "Failed to create target",
"targetErrorCreateDescription": "An error occurred while creating the target",
"tlsServerName": "TLS Server Name",
"tlsServerNameDescription": "The TLS server name to use for SNI",
"save": "Save",
"proxyAdditional": "Additional Proxy Settings",
"proxyAdditionalDescription": "Configure how your resource handles proxy settings",
@@ -702,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",
@@ -1163,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",
@@ -1430,6 +1447,9 @@
"and": "and",
"privacyPolicy": "privacy policy"
},
"signUpMarketing": {
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
},
"siteRequired": "Site is required.",
"olmTunnel": "Olm Tunnel",
"olmTunnelDescription": "Use Olm for client connectivity",
@@ -2015,6 +2035,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...",
@@ -2040,6 +2061,7 @@
"ip": "IP",
"reason": "Reason",
"requestLogs": "Request Logs",
"requestAnalytics": "Request Analytics",
"host": "Host",
"location": "Location",
"actionLogs": "Action Logs",
@@ -2049,6 +2071,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",
@@ -2156,5 +2179,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"
}

View File

@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true
},
experimental: {
reactCompiler: true
},
output: "standalone"
};

932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,8 @@
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > 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",

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

@@ -176,7 +176,8 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
hcMethod: varchar("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy"
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName"),
});
export const exitNodes = pgTable("exitNodes", {

View File

@@ -201,7 +201,8 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
}).default(true),
hcMethod: text("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy"
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName"),
});
export const exitNodes = sqliteTable("exitNodes", {

View File

@@ -16,7 +16,7 @@ import privateConfig from "#private/lib/config";
import logger from "@server/logger";
export enum AudienceIds {
SignUps = "5cfbf99b-c592-40a9-9b8a-577a4681c158",
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"

View File

@@ -40,7 +40,12 @@ export const queryAccessAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description: "End time as ISO date string (defaults to current time)"
}),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))

View File

@@ -40,7 +40,12 @@ export const queryActionAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description: "End time as ISO date string (defaults to current time)"
}),
action: z.string().optional(),
actorType: z.string().optional(),
actorId: z.string().optional(),

View File

@@ -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);

View File

@@ -1,2 +1,3 @@
export * from "./queryRequstAuditLog";
export * from "./exportRequstAuditLog";
export * from "./queryRequestAuditLog";
export * from "./queryRequestAnalytics";
export * from "./exportRequestAuditLog";

View File

@@ -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<typeof queryRequestAuditLogsCombined>;
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<number>`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<string>`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))`
: sql<string>`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<number>`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanTrue} THEN 1 ELSE 0 END)`.as(
"allowed_count"
),
blockedCount:
sql<number>`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanFalse} THEN 1 ELSE 0 END)`.as(
"blocked_count"
),
totalCount: sql<number>`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<ReturnType<typeof query>>;
export async function queryRequestAnalytics(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
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<QueryRequestAnalyticsResponse>(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")
);
}
}

View File

@@ -27,7 +27,13 @@ export const queryAccessAuditLogsQuery = z.object({
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(new Date().toISOString()),
.prefault(new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"End time as ISO date string (defaults to current time)"
}),
action: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))
@@ -67,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<typeof queryRequestAuditLogsCombined>;
function getWhere(data: Q) {
@@ -204,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)
};
}
@@ -265,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) {

View File

@@ -30,7 +30,8 @@ export const signupBodySchema = z.object({
password: passwordSchema,
inviteToken: z.string().optional(),
inviteId: z.string().optional(),
termsAcceptedTimestamp: z.string().nullable().optional()
termsAcceptedTimestamp: z.string().nullable().optional(),
marketingEmailConsent: z.boolean().optional()
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
@@ -55,7 +56,7 @@ export async function signup(
);
}
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } =
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp, marketingEmailConsent } =
parsedBody.data;
const passwordHash = await hashPassword(password);
@@ -220,8 +221,8 @@ export async function signup(
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
if (build == "saas") {
if (build == "saas" && marketingEmailConsent) {
logger.debug(`User ${email} opted in to marketing emails during signup.`);
moveEmailToAudience(email, AudienceIds.SignUps);
}

View File

@@ -178,7 +178,6 @@ authenticated.post(
client.updateClient
);
// authenticated.get(
// "/site/:siteId/roles",
// verifySiteAccess,
@@ -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,
@@ -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,

View File

@@ -272,7 +272,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval,
hcTimeout: targetHealthCheck.hcTimeout,
hcHeaders: targetHealthCheck.hcHeaders,
hcMethod: targetHealthCheck.hcMethod
hcMethod: targetHealthCheck.hcMethod,
hcTlsServerName: targetHealthCheck.hcTlsServerName,
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
@@ -344,7 +345,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds
hcTimeout: target.hcTimeout, // in seconds
hcHeaders: hcHeadersSend,
hcMethod: target.hcMethod
hcMethod: target.hcMethod,
hcTlsServerName: target.hcTlsServerName,
};
});

View File

@@ -66,7 +66,8 @@ export async function addTargets(
hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds
hcTimeout: hc.hcTimeout, // in seconds
hcHeaders: hcHeadersSend,
hcMethod: hc.hcMethod
hcMethod: hc.hcMethod,
hcTlsServerName: hc.hcTlsServerName,
};
});

View File

@@ -25,3 +25,4 @@ export * from "./getUserResources";
export * from "./setResourceHeaderAuth";
export * from "./addEmailToResourceWhitelist";
export * from "./removeEmailFromResourceWhitelist";
export * from "./listAllResourceNames";

View File

@@ -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<typeof queryResourceNames>
>;
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<any> {
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<ListResourceNamesResponse>(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")
);
}
}

View File

@@ -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
});
}
}

View File

@@ -48,6 +48,7 @@ const createTargetSchema = z.strictObject({
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.int().optional().nullable(),
hcTlsServerName: z.string().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
@@ -247,7 +248,8 @@ export async function createTarget(
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
hcHealth: "unknown",
hcTlsServerName: targetData.hcTlsServerName ?? null
})
.returning();

View File

@@ -57,6 +57,7 @@ function queryTargets(resourceId: number) {
hcMethod: targetHealthCheck.hcMethod,
hcStatus: targetHealthCheck.hcStatus,
hcHealth: targetHealthCheck.hcHealth,
hcTlsServerName: targetHealthCheck.hcTlsServerName,
path: targets.path,
pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath,

View File

@@ -42,6 +42,7 @@ const updateTargetBodySchema = z.strictObject({
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.int().optional().nullable(),
hcTlsServerName: z.string().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
@@ -217,7 +218,8 @@ export async function updateTarget(
hcHeaders: hcHeaders,
hcFollowRedirects: parsedBody.data.hcFollowRedirects,
hcMethod: parsedBody.data.hcMethod,
hcStatus: parsedBody.data.hcStatus
hcStatus: parsedBody.data.hcStatus,
hcTlsServerName: parsedBody.data.hcTlsServerName,
})
.where(eq(targetHealthCheck.targetId, targetId))
.returning();

View File

@@ -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<Record<string, string>>;
}
export default async function AnalyticsPage(props: AnalyticsPageProps) {
const t = await getTranslations();
const orgId = (await props.params).orgId;
return (
<>
<SettingsSectionTitle
title={t("requestAnalytics")}
description={t("requestAnalyticsDescription")}
/>
<div className="container mx-auto max-w-12xl">
<LogAnalyticsData orgId={orgId} />
</div>
</>
);
}

View File

@@ -464,6 +464,7 @@ export default function ReverseProxyTargets(props: {
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null,
siteType: sites.length > 0 ? sites[0].type : null,
new: true,
updated: false
@@ -629,7 +630,8 @@ export default function ReverseProxyTargets(props: {
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null
hcUnhealthyInterval: null,
hcTlsServerName: null,
};
setTargets([...targets, newTarget]);
@@ -729,7 +731,8 @@ export default function ReverseProxyTargets(props: {
hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName,
};
// Only include path-related fields for HTTP resources
@@ -1822,7 +1825,9 @@ export default function ReverseProxyTargets(props: {
hcMode: selectedTargetForHealthCheck.hcMode || "http",
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30
30,
hcTlsServerName: selectedTargetForHealthCheck.hcTlsServerName ||
undefined,
}}
onChanges={async (config) => {
console.log("here");

View File

@@ -297,6 +297,7 @@ export default function Page() {
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null,
siteType: sites.length > 0 ? sites[0].type : null,
new: true,
updated: false
@@ -454,7 +455,8 @@ export default function Page() {
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null
hcUnhealthyInterval: null,
hcTlsServerName: null
};
setTargets([...targets, newTarget]);
@@ -576,7 +578,8 @@ export default function Page() {
target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName
};
// Only include path-related fields for HTTP resources
@@ -1800,7 +1803,10 @@ export default function Page() {
"http",
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30
30,
hcTlsServerName:
selectedTargetForHealthCheck.hcTlsServerName ||
undefined
}}
onChanges={async (config) => {
if (selectedTargetForHealthCheck) {

View File

@@ -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;

View File

@@ -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({
</ThemeProvider>
</NextIntlClientProvider>
</ReactQueryProvider>
{process.env.NODE_ENV === "development" && (
<TailwindIndicator />
)}
</body>
</html>
);

View File

@@ -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: <SquareMousePointer className="h-4 w-4" />
},
{
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
},
...(build != "oss"
? [
{
@@ -143,7 +149,7 @@ export const orgNavSections = (
]
},
{
heading: "Organization",
heading: "sidebarOrganization",
items: [
{
title: "sidebarApiKeys",

View File

@@ -11,7 +11,6 @@ import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,

View File

@@ -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<Date | undefined>(value?.date);
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
const [open, setOpen] = useState(false);
const [internalDate, setInternalDate] = useState<Date | undefined>(
value?.date
);
const [internalTime, setInternalTime] = useState<string>(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<HTMLInputElement>) => {
const time = event.target.value;
setInternalTime(time);
const newValue = { date: internalDate, time };
onChange?.(newValue);
};
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-2">
{label && (
<Label htmlFor="date-picker">
{label}
</Label>
)}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="flex-grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
// 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 (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-2">
{label && <Label htmlFor="date-picker">{label}</Label>}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label
htmlFor="time-input"
className="text-sm font-medium"
>
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
);
}
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 (
<div className={cn("flex gap-4 items-center", className)}>
<DateTimePicker
label="Start"
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
label="End"
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}
return (
<div className={cn("flex gap-4 items-center", className)}>
<DateTimePicker
label="Start"
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
label="End"
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}

View File

@@ -51,6 +51,7 @@ type HealthCheckConfig = {
hcFollowRedirects: boolean;
hcMode: string;
hcUnhealthyInterval: number;
hcTlsServerName: string;
};
type HealthCheckDialogProps = {
@@ -93,7 +94,8 @@ export default function HealthCheckDialog({
hcPort: z.number().positive().gt(0).lte(65535),
hcFollowRedirects: z.boolean(),
hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5)
hcUnhealthyInterval: z.int().positive().min(5),
hcTlsServerName: z.string()
});
const form = useForm<z.infer<typeof healthCheckSchema>>({
@@ -129,7 +131,8 @@ export default function HealthCheckDialog({
hcPort: initialConfig?.hcPort,
hcFollowRedirects: initialConfig?.hcFollowRedirects,
hcMode: initialConfig?.hcMode,
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval,
hcTlsServerName: initialConfig?.hcTlsServerName ?? ""
});
}, [open]);
@@ -531,6 +534,37 @@ export default function HealthCheckDialog({
)}
/>
{/*TLS Server Name (SNI)*/}
<FormField
control={form.control}
name="hcTlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("tlsServerName")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(e);
handleFieldChange(
"hcTlsServerName",
e.target.value
);
}}
/>
</FormControl>
<FormDescription>
{t(
"tlsServerNameDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Headers */}
<FormField
control={form.control}

View File

@@ -11,7 +11,7 @@ export function InfoSections({
}) {
return (
<div
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
className={`grid md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start grid-cols-1`}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value

View File

@@ -0,0 +1,541 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import {
logAnalyticsFiltersSchema,
logQueries,
resourceQueries
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Card, CardContent, CardHeader } from "./ui/card";
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
import { Button } from "./ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { Label } from "./ui/label";
import { Separator } from "./ui/separator";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { WorldMap } from "./WorldMap";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig
} from "./ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
export type AnalyticsContentProps = {
orgId: string;
};
export function LogAnalyticsData(props: AnalyticsContentProps) {
const searchParams = useSearchParams();
const path = usePathname();
const t = useTranslations();
const filters = logAnalyticsFiltersSchema.parse(
Object.fromEntries(searchParams.entries())
);
const isEmptySearchParams =
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
const env = useEnvContext();
const [api] = useState(() => 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 =
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\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 (
<div className="flex flex-col gap-5">
<Card className="">
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-start lg:items-end sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full sm:mr-2 gap-2">
<DateRangePicker
startValue={{
date: dateRange.startDate,
time: dateRange.startDate
? getDateTime(dateRange.startDate)
: undefined
}}
endValue={{
date: dateRange.endDate,
time: dateRange.endDate
? getDateTime(dateRange.endDate)
: undefined
}}
onRangeChange={handleTimeRangeUpdate}
className="flex-wrap gap-2"
/>
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
<div className="flex items-end gap-2">
<div className="flex flex-col items-start gap-2 w-48">
<Label htmlFor="resourceId">
{t("filterByResource")}
</Label>
<Select
onValueChange={(newValue) => {
const newSearch = new URLSearchParams(
searchParams
);
newSearch.delete("resourceId");
if (newValue !== "all") {
newSearch.set(
"resourceId",
newValue
);
}
router.replace(
`${path}?${newSearch.toString()}`
);
}}
value={
filters.resourceId?.toString() ?? "all"
}
>
<SelectTrigger
id="resourceId"
className="w-full"
>
<SelectValue
placeholder={t("selectResource")}
/>
</SelectTrigger>
<SelectContent className="w-full">
{resources.map((resource) => (
<SelectItem
key={resource.resourceId}
value={resource.resourceId.toString()}
>
{resource.name}
</SelectItem>
))}
<SelectItem value="all">
All resources
</SelectItem>
</SelectContent>
</Select>
</div>
{!isEmptySearchParams && (
<Button
variant="ghost"
onClick={() => {
router.replace(path);
}}
className="gap-2"
>
<XIcon className="size-4" />
{t("resetFilters")}
</Button>
)}
</div>
</div>
<div className="flex items-start gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => refreshAnalytics()}
disabled={isFetchingAnalytics}
className=" relative top-6 lg:static gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetchingAnalytics && "animate-spin"
)}
/>
{t("refresh")}
</Button>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
{t("totalRequests")}
</InfoSectionTitle>
<InfoSectionContent>
{totalRequests ?? "--"}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
{t("totalBlocked")}
</InfoSectionTitle>
<InfoSectionContent>
<span>{stats?.totalBlocked ?? "--"}</span>
&nbsp;(
<span>{percentBlocked ?? "--"}</span>
<span className="text-muted-foreground">%</span>
)
</InfoSectionContent>
</InfoSection>
</InfoSections>
</CardHeader>
</Card>
<Card className="w-full h-full flex flex-col gap-8">
<CardHeader>
<h3 className="font-medium">{t("requestsByDay")}</h3>
</CardHeader>
<CardContent>
<RequestChart
data={stats?.requestsPerDay ?? []}
isLoading={isLoadingAnalytics}
/>
</CardContent>
</Card>
<div className="grid lg:grid-cols-2 gap-5">
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">
{t("requestsByCountry")}
</h3>
</CardHeader>
<CardContent>
<WorldMap
data={stats?.requestsPerCountry ?? []}
label={{
singular: "request",
plural: "requests"
}}
/>
</CardContent>
</Card>
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">{t("topCountries")}</h3>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
<TopCountriesList
countries={stats?.requestsPerCountry ?? []}
total={stats?.totalRequests ?? 0}
isLoading={isLoadingAnalytics}
/>
</CardContent>
</Card>
</div>
</div>
);
}
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 (
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full h-80"
>
<LineChart accessibilityLayer data={props.data}>
<ChartLegend content={<ChartLegendContent />} />
<ChartTooltip
content={
<ChartTooltipContent
indicator="dot"
labelFormatter={(value, payload) => {
const formattedDate = new Date(
payload[0].payload.day
).toLocaleDateString(navigator.language, {
dateStyle: "medium"
});
return formattedDate;
}}
/>
}
/>
<CartesianGrid vertical={false} />
<YAxis
tickLine={false}
axisLine={false}
domain={[
0,
Math.max(...props.data.map((datum) => datum.totalCount))
]}
allowDataOverflow
type="number"
tickFormatter={(value) => {
return numberFormatter.format(value);
}}
/>
<XAxis
dataKey="day"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => {
return new Date(value).toLocaleDateString(
navigator.language,
{
dateStyle: "medium"
}
);
}}
/>
<Line
dataKey="allowedCount"
stroke="var(--color-allowedCount)"
strokeWidth={2}
fill="transparent"
radius={4}
isAnimationActive={false}
dot={false}
/>
<Line
dataKey="blockedCount"
stroke="var(--color-blockedCount)"
strokeWidth={2}
fill="transparent"
radius={4}
isAnimationActive={false}
dot={false}
/>
</LineChart>
</ChartContainer>
);
}
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 (
<div className="h-full flex flex-col gap-2">
{props.countries.length > 0 && (
<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.length === 0 && (
<div className="flex items-center justify-center size-full text-muted-foreground font-mono gap-1">
{props.isLoading ? (
<>
<LoaderIcon className="size-4 animate-spin" />{" "}
{t("loading")}
</>
) : (
t("noData")
)}
</div>
)}
{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">
{numberFormatter.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

@@ -162,7 +162,7 @@ export function SidebarNav({
<div key={section.heading} className="mb-2">
{!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{section.heading}
{t(section.heading)}
</div>
)}
<div className="flex flex-col gap-1">

View File

@@ -92,7 +92,8 @@ const formSchema = z
message:
"You must agree to the terms of service and privacy policy"
}
)
),
marketingEmailConsent: z.boolean().optional()
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
@@ -123,7 +124,8 @@ export default function SignupForm({
email: emailParam || "",
password: "",
confirmPassword: "",
agreeToTerms: false
agreeToTerms: false,
marketingEmailConsent: false
},
mode: "onChange" // Enable real-time validation
});
@@ -135,7 +137,7 @@ export default function SignupForm({
passwordValue === confirmPasswordValue;
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
const { email, password, marketingEmailConsent } = values;
setLoading(true);
const res = await api
@@ -144,7 +146,8 @@ export default function SignupForm({
password,
inviteId,
inviteToken,
termsAcceptedTimestamp: termsAgreedAt
termsAcceptedTimestamp: termsAgreedAt,
marketingEmailConsent: build === "saas" ? marketingEmailConsent : undefined
})
.catch((e) => {
console.error(e);
@@ -489,56 +492,78 @@ export default function SignupForm({
)}
/>
{build === "saas" && (
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.termsOfService"
"signUpTerms.IAgreeToThe"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpMarketing.keepMeInTheLoop")}
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</>
)}
{error && (

View File

@@ -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 (
<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>
<div className="hidden lg:block xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block xxl:hidden">2xl</div>| {mediaSize}
px
</div>
);
}

275
src/components/WorldMap.tsx Normal file
View File

@@ -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<CountryData, "code" | "count">[];
label: {
singular: string;
plural: string;
};
};
export function WorldMap({ data, label }: WorldMapProps) {
const svgRef = useRef<ComponentRef<"svg">>(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<string, CountryData>();
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<string>()
.domain([0, maxValue])
.range(palette);
colorInCountriesWithValues(
svgRef.current,
getColorForValue,
dataByCountryCode
);
}
}, [theme, systemTheme, maxValue, dataByCountryCode]);
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
: undefined;
return (
<div className="mx-auto mt-4 w-full relative">
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className="w-full"
/>
{!!hoveredCountryData && (
<MapTooltip
x={tooltip.x}
y={tooltip.y}
name={hoveredCountryData.name}
value={Intl.NumberFormat(navigator.language).format(
hoveredCountryData.count
)}
label={
hoveredCountryData.count === 1
? t(label.singular)
: t(label.plural)
}
/>
)}
</div>
);
}
interface MapTooltipProps {
name: string;
value: string;
label: string;
x: number;
y: number;
}
function MapTooltip({ name, value, label, x, y }: MapTooltipProps) {
return (
<div
className={cn(
"absolute z-50 p-2 translate-x-2 translate-y-2",
"pointer-events-none rounded-sm",
"bg-white dark:bg-popover shadow border border-border"
)}
style={{
left: x,
top: y
}}
>
<div className="font-semibold">{name}</div>
<strong className="text-primary">{value}</strong> {label}
</div>
);
}
const width = 475;
const height = 335;
const sharedCountryClass = cn("transition-colors");
const colorScales: Record<string, [string, string]> = {
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<WorldJsonCountryData> {
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<string, string, never>,
dataByCountryCode: Map<string, CountryData>
) {
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";
});
}

420
src/components/ui/chart.tsx Normal file
View File

@@ -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<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n")
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return (
<div className={cn("font-medium", labelClassName)}>{value}</div>
);
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(
config,
item,
key
);
const indicatorColor =
color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter &&
item?.value !== undefined &&
item.name ? (
formatter(
item.value,
item.name,
item,
index,
item.payload
)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5":
indicator ===
"dot",
"w-1":
indicator ===
"line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator ===
"dashed",
"my-0.5":
nestLabel &&
indicator ===
"dashed"
}
)}
style={
{
"--color-bg":
indicatorColor,
"--color-border":
indicatorColor
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none gap-2",
nestLabel
? "items-end"
: "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel
? tooltipLabel
: null}
<span className="text-muted-foreground">
{itemConfig?.label ||
item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{!isNaN(
item.value as number
)
? new Intl.NumberFormat(
navigator.language,
{
maximumFractionDigits: 0
}
).format(
item.value as number
)
: item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey
},
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(
config,
item,
key
);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle
};

View File

@@ -1,7 +1,7 @@
import { Env } from "@app/lib/types/env";
import type { Env } from "@app/lib/types/env";
import { createContext } from "react";
interface EnvContextType {
export interface EnvContextType {
env: Env;
}

1002
src/lib/countryCodeList.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export function countryCodeToFlagEmoji(isoAlpha2: string) {
const codePoints = [...isoAlpha2.toUpperCase()].map(
(char) => 0x1f1e6 + char.charCodeAt(0) - 65
);
return String.fromCodePoint(...codePoints);
}

View File

@@ -3,6 +3,10 @@ import { durationToMs } from "./durationToMs";
import { build } from "@server/build";
import { remote } from "./api";
import type ResponseT from "@server/types/Response";
import z from "zod";
import type { AxiosInstance, AxiosResponse } from "axios";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { ListResourceNamesResponse } from "@server/routers/resource";
export type ProductUpdate = {
link: string | null;
@@ -65,3 +69,71 @@ export const productUpdatesQueries = {
// because we don't need to listen for new versions there
})
};
export const logAnalyticsFiltersSchema = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.optional(),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional()
});
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
export const logQueries = {
requestAnalytics: ({
orgId,
filters,
api
}: {
orgId: string;
filters: LogAnalyticsFilters;
api: AxiosInstance;
}) =>
queryOptions({
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
queryFn: async ({ signal }) => {
const res = await api.get<
AxiosResponse<QueryRequestAnalyticsResponse>
>(`/org/${orgId}/logs/analytics`, {
params: filters,
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
})
};
export const resourceQueries = {
listNamesPerOrg: (orgId: string, api: AxiosInstance) =>
queryOptions({
queryKey: ["RESOURCES_NAMES", orgId] as const,
queryFn: async ({ signal }) => {
const res = await api.get<
AxiosResponse<ListResourceNamesResponse>
>(`/org/${orgId}/resource-names`, {
signal
});
return res.data.data;
}
})
};