mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 13:31:27 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f32bce952d | |||
| 17375348b0 | |||
| e7fdbf9e85 | |||
| cddb5ecc3d |
@@ -465,8 +465,6 @@
|
||||
"apiKeysDelete": "Delete API Key",
|
||||
"apiKeysManage": "Manage API Keys",
|
||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
||||
"orgsManage": "Manage Organizations",
|
||||
"orgsDescription": "View and manage all organizations on this instance",
|
||||
"provisioningKeysTitle": "Provisioning Key",
|
||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||
@@ -2097,7 +2095,6 @@
|
||||
"resourceBudgetSettings": "Budget",
|
||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarOrgs": "Organizations",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
"sidebarAllUsers": "All Users",
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.1.0",
|
||||
"nodemailer": "9.1.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.51.4",
|
||||
@@ -13151,9 +13151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.0.tgz",
|
||||
"integrity": "sha512-xj1Ri5Sau3qpPffHJwi2bY0oWVVWYq62Ph17l+2v1xXpxjqTls3YPc3L8dub9mcJpxj+1ucNnuYVqLlgh4pmDA==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
+2
-2
@@ -96,7 +96,6 @@
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lru-cache": "11.5.2",
|
||||
"lucide-react": "1.38.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.30.1",
|
||||
@@ -104,7 +103,8 @@
|
||||
"next-intl": "4.14.1",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"nodemailer": "9.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.1.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.51.4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 410 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 802 KiB After Width: | Height: | Size: 800 KiB |
+8
-2
@@ -1,7 +1,13 @@
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||
|
||||
export const localCache = createLocalCache();
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
export const localCache = new NodeCache({
|
||||
stdTTL: 3600,
|
||||
checkperiod: 120,
|
||||
maxKeys: 10000
|
||||
});
|
||||
|
||||
// Log cache statistics periodically for monitoring
|
||||
// setInterval(() => {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
|
||||
const DEFAULT_MAX_KEYS = 10000;
|
||||
const DEFAULT_TTL_MS = 3600 * 1000;
|
||||
|
||||
export type LocalCache = {
|
||||
get<T>(key: string): T | undefined;
|
||||
set(key: string, value: unknown, ttlSeconds?: number): boolean;
|
||||
del(key: string | string[]): number;
|
||||
has(key: string): boolean;
|
||||
keys(): string[];
|
||||
flushAll(): void;
|
||||
getStats(): { keys: number };
|
||||
getTtl(key: string): number | undefined;
|
||||
};
|
||||
|
||||
export function createLocalCache(max = DEFAULT_MAX_KEYS): LocalCache {
|
||||
const lru = new LRUCache<string, {}>({
|
||||
max,
|
||||
ttl: DEFAULT_TTL_MS,
|
||||
updateAgeOnGet: false
|
||||
});
|
||||
|
||||
return {
|
||||
get<T>(key: string): T | undefined {
|
||||
return lru.get(key) as T | undefined;
|
||||
},
|
||||
|
||||
set(key: string, value: unknown, ttlSeconds?: number): boolean {
|
||||
const stored = value as {};
|
||||
if (ttlSeconds === undefined) {
|
||||
lru.set(key, stored);
|
||||
} else if (ttlSeconds === 0) {
|
||||
lru.set(key, stored, { ttl: 0 });
|
||||
} else {
|
||||
lru.set(key, stored, { ttl: ttlSeconds * 1000 });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
del(key: string | string[]): number {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
let deleted = 0;
|
||||
for (const k of keys) {
|
||||
if (lru.delete(k)) {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
},
|
||||
|
||||
has(key: string): boolean {
|
||||
return lru.has(key);
|
||||
},
|
||||
|
||||
keys(): string[] {
|
||||
return [...lru.keys()];
|
||||
},
|
||||
|
||||
flushAll(): void {
|
||||
lru.clear();
|
||||
},
|
||||
|
||||
getStats(): { keys: number } {
|
||||
return { keys: lru.size };
|
||||
},
|
||||
|
||||
getTtl(key: string): number | undefined {
|
||||
if (!lru.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
const remaining = lru.getRemainingTTL(key);
|
||||
if (!Number.isFinite(remaining)) {
|
||||
return 0;
|
||||
}
|
||||
return Date.now() + remaining;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
@@ -348,8 +348,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/18"),
|
||||
block_size: z.number().positive().gt(0).optional().default(22),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
site_block_size: z
|
||||
.number()
|
||||
.positive()
|
||||
|
||||
@@ -11,11 +11,17 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||
|
||||
export const localCache = createLocalCache();
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
export const localCache = new NodeCache({
|
||||
stdTTL: 3600,
|
||||
checkperiod: 120,
|
||||
maxKeys: 10000
|
||||
});
|
||||
|
||||
// Log cache statistics periodically for monitoring
|
||||
// setInterval(() => {
|
||||
@@ -295,11 +301,15 @@ export default cache;
|
||||
|
||||
/**
|
||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||
* Falls back to a local LRU cache when the regional Redis is unavailable.
|
||||
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||
* Use this for data that is regional in nature (e.g. status history) so
|
||||
* reads are served from the same cluster the user is hitting.
|
||||
*/
|
||||
const regionalLocalCache = createLocalCache();
|
||||
const regionalLocalCache = new NodeCache({
|
||||
stdTTL: 3600,
|
||||
checkperiod: 120,
|
||||
maxKeys: 10000
|
||||
});
|
||||
|
||||
class RegionalAdaptiveCache {
|
||||
private useRedis(): boolean {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { db, HostMeta, sites, users } from "@server/db";
|
||||
import { hostMeta, licenseKey } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||
import NodeCache from "node-cache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
@@ -65,8 +65,8 @@ export class License {
|
||||
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
||||
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
||||
|
||||
private statusCache = createLocalCache();
|
||||
private licenseKeyCache = createLocalCache();
|
||||
private statusCache = new NodeCache();
|
||||
private licenseKeyCache = new NodeCache();
|
||||
|
||||
private statusKey = "status";
|
||||
private serverSecret!: string;
|
||||
@@ -179,7 +179,7 @@ LQIDAQAB
|
||||
status.isHostLicensed = false;
|
||||
// Invalidate all and set new cache (empty)
|
||||
this.licenseKeyCache.flushAll();
|
||||
this.statusCache.set(this.statusKey, status, 0);
|
||||
this.statusCache.set(this.statusKey, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ LQIDAQAB
|
||||
// Invalidate old cache and set new cache
|
||||
this.licenseKeyCache.flushAll();
|
||||
for (const [key, value] of newCache.entries()) {
|
||||
this.licenseKeyCache.set(key, value, 0);
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(key, value);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking license status:");
|
||||
@@ -398,7 +398,7 @@ LQIDAQAB
|
||||
this.checkInProgress = false;
|
||||
}
|
||||
|
||||
this.statusCache.set(this.statusKey, status, 0);
|
||||
this.statusCache.set(this.statusKey, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -2478,12 +2478,7 @@ hybridRouter.post(
|
||||
destinations: destinations
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof Error &&
|
||||
error.message === "Exit node not allowed"
|
||||
)) {
|
||||
logger.error(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export interface ExitNodeOnlineEvent {
|
||||
exitNodeId: number;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
|
||||
|
||||
export const exitNodeEvents = new EventEmitter();
|
||||
@@ -12,27 +12,11 @@
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
import { sendToClient } from "../ws";
|
||||
import {
|
||||
exitNodeEvents,
|
||||
EXIT_NODE_ONLINE_EVENT,
|
||||
ExitNodeOnlineEvent
|
||||
} from "./exitNodeEvents";
|
||||
|
||||
exitNodeEvents.on(
|
||||
EXIT_NODE_ONLINE_EVENT,
|
||||
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
|
||||
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", {
|
||||
error
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
// import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
@@ -42,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
endpoint: string;
|
||||
reachableAt: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
@@ -56,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
endpoint: string
|
||||
reachableAt: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
endpoint
|
||||
reachableAt
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
@@ -79,8 +63,8 @@ export async function scheduleExitNodeReconnect(
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"endpoint",
|
||||
endpoint
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,14 +101,14 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const endpoint = await redisManager.hget(
|
||||
const reachableAt = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"endpoint"
|
||||
"reachableAt"
|
||||
);
|
||||
if (startTimeStr && endpoint) {
|
||||
if (startTimeStr && reachableAt) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
endpoint
|
||||
reachableAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -151,7 +135,7 @@ async function processPendingReconnects(): Promise<void> {
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `http://${entry.endpoint}/ping`;
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
@@ -166,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
// await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
// try {
|
||||
// const connectedNewts = await db
|
||||
// .select({ newtId: newts.newtId })
|
||||
// .from(newts)
|
||||
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
// .where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (connectedNewts.length === 0) {
|
||||
// logger.debug(
|
||||
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
// logger.info(
|
||||
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
// );
|
||||
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
// const reconnectMessage = {
|
||||
// type: "newt/wg/reconnect",
|
||||
// data: {}
|
||||
// };
|
||||
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
// await Promise.allSettled(
|
||||
// connectedNewts.map(({ newtId }) =>
|
||||
// sendToClient(newtId, reconnectMessage)
|
||||
// )
|
||||
// );
|
||||
// } catch (error) {
|
||||
// logger.error(
|
||||
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
// { error }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
|
||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
try {
|
||||
// Fetch the current state before updating so we can detect the offline→online transition
|
||||
const [currentExitNode] = await db
|
||||
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
@@ -55,14 +55,12 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||
|
||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||
if (
|
||||
currentExitNode &&
|
||||
!currentExitNode.online &&
|
||||
currentExitNode.endpoint
|
||||
) {
|
||||
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
||||
exitNodeId: remoteExitNode.exitNodeId,
|
||||
endpoint: currentExitNode.endpoint
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -691,9 +691,7 @@ export async function verifyResourceSession(
|
||||
);
|
||||
|
||||
resourceSession = result?.resourceSession;
|
||||
if (resourceSession) {
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
|
||||
if (resourceSession?.isRequestToken) {
|
||||
@@ -1123,9 +1121,7 @@ async function allowAccessToken(
|
||||
resource.resourceId
|
||||
);
|
||||
resourceSession = result?.resourceSession;
|
||||
if (resourceSession) {
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -87,12 +87,6 @@ authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
|
||||
authenticated.delete(
|
||||
"/admin/org/:orgId",
|
||||
verifyUserIsServerAdmin,
|
||||
org.adminDeleteOrg
|
||||
);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
|
||||
authenticated.get(
|
||||
|
||||
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
if (resources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!resource.networkId) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||
|
||||
if (!siteRows || siteRows.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -164,7 +164,9 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -203,7 +205,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -227,7 +229,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!handshakeInitiated) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const adminDeleteOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export type AdminDeleteOrgResponse = {};
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/admin/org/{orgId}",
|
||||
description: "Delete any organization in the system (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: adminDeleteOrgSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminDeleteOrg(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deleteOrgById(orgId);
|
||||
sendTerminationMessages(result);
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, users } from "@server/db";
|
||||
import { orgs, resources, sites, userOrgs } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
const adminListOrgsSchema = z.strictObject({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["name", "createdAt"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["name", "createdAt"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
})
|
||||
});
|
||||
|
||||
export type AdminOrgRow = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
subnet: string | null;
|
||||
utilitySubnet: string | null;
|
||||
createdAt: string | null;
|
||||
userCount: number;
|
||||
siteCount: number;
|
||||
resourceCount: number;
|
||||
owner: {
|
||||
userId: string;
|
||||
username: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminListOrgsResponse = PaginatedResponse<{
|
||||
orgs: AdminOrgRow[];
|
||||
}>;
|
||||
|
||||
const AdminListOrgsResponseDataSchema = z.object({
|
||||
orgs: z.array(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string(),
|
||||
subnet: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
userCount: z.number(),
|
||||
siteCount: z.number(),
|
||||
resourceCount: z.number()
|
||||
})
|
||||
),
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
pageSize: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/admin/orgs",
|
||||
description:
|
||||
"List all organizations in the system with usage counts (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: adminListOrgsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
AdminListOrgsResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminListOrgs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
|
||||
|
||||
let conditions: (SQL<unknown> | undefined)[] = [];
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
conditions.push(
|
||||
or(
|
||||
like(sql`LOWER(${orgs.name})`, q),
|
||||
like(sql`LOWER(${orgs.orgId})`, q),
|
||||
like(sql`LOWER(${orgs.subnet})`, q)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sortColumns = {
|
||||
name: orgs.name,
|
||||
createdAt: orgs.createdAt
|
||||
} as const;
|
||||
|
||||
const orderBy = sort_by
|
||||
? order === "asc"
|
||||
? asc(sortColumns[sort_by])
|
||||
: desc(sortColumns[sort_by])
|
||||
: asc(orgs.name);
|
||||
|
||||
// Drizzle renders bare column references in the select list without their
|
||||
// table prefix, which would make a correlated subquery compare a column to
|
||||
// itself, so the outer `orgs` side is qualified explicitly.
|
||||
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
|
||||
|
||||
const [countRows, rows] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgs)
|
||||
.where(and(...conditions)),
|
||||
db
|
||||
.selectDistinct({
|
||||
orgId: orgs.orgId,
|
||||
name: orgs.name,
|
||||
subnet: orgs.subnet,
|
||||
utilitySubnet: orgs.utilitySubnet,
|
||||
createdAt: orgs.createdAt,
|
||||
userCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${userOrgs}
|
||||
WHERE ${userOrgs.orgId} = ${orgIdRef}
|
||||
)`.as("userCount"),
|
||||
siteCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${sites}
|
||||
WHERE ${sites.orgId} = ${orgIdRef}
|
||||
)`.as("siteCount"),
|
||||
resourceCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${resources}
|
||||
WHERE ${resources.orgId} = ${orgIdRef}
|
||||
)`.as("resourceCount"),
|
||||
owner: {
|
||||
userId: users.userId,
|
||||
username: users.username
|
||||
}
|
||||
})
|
||||
.from(orgs)
|
||||
.where(and(...conditions, eq(userOrgs.isOwner, true)))
|
||||
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(orderBy)
|
||||
]);
|
||||
|
||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
return response<AdminListOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: rows.map((row) => ({
|
||||
...row,
|
||||
userCount: Number(row.userCount ?? 0),
|
||||
siteCount: Number(row.siteCount ?? 0),
|
||||
resourceCount: Number(row.resourceCount ?? 0)
|
||||
})),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organizations retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,3 @@ export * from "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./checkOrgUserAccess";
|
||||
export * from "./resetOrgBandwidth";
|
||||
export * from "./adminListOrgs";
|
||||
export * from "./adminDeleteOrg";
|
||||
|
||||
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
|
||||
subscriptionStatus = subRes.data.data;
|
||||
} catch (error) {
|
||||
// If subscription fetch fails, keep subscriptionStatus as null
|
||||
// console.error("Failed to fetch subscription status:", error);
|
||||
console.error("Failed to fetch subscription status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgsTable from "@app/components/OrgsTable";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { AdminListOrgsResponse } from "@server/routers/org";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Organizations"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type OrganizationsPageProps = {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export default async function OrganizationsPage(props: OrganizationsPageProps) {
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let orgs: AdminListOrgsResponse["orgs"] = [];
|
||||
let pagination: AdminListOrgsResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
|
||||
`/admin/orgs?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
orgs = responseData.orgs;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("orgsManage")}
|
||||
description={t("orgsDescription")}
|
||||
/>
|
||||
|
||||
<OrgsTable
|
||||
orgs={orgs}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Building2Icon,
|
||||
Cable,
|
||||
ChartLine,
|
||||
Coins,
|
||||
@@ -382,11 +381,6 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
href: "/admin/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarOrgs",
|
||||
href: "/admin/organizations",
|
||||
icon: <Building2Icon className="size-4 flex-none" />
|
||||
},
|
||||
...(build === "oss" ||
|
||||
env?.app.identityProviderMode === "global" ||
|
||||
env?.app.identityProviderMode === undefined
|
||||
@@ -398,7 +392,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build === "enterprise"
|
||||
...(build == "enterprise"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLicense",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Check, Languages } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Check, Globe, Languages } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTransition } from "react";
|
||||
import { Locale } from "@/i18n/config";
|
||||
import { setUserLocale } from "@/services/locale";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type Props = {
|
||||
defaultValue: string;
|
||||
@@ -42,18 +43,23 @@ export default function LocaleSwitcherSelect({
|
||||
const selected = items.find((item) => item.value === defaultValue);
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
isPending && "pointer-events-none"
|
||||
)}
|
||||
aria-label={label}
|
||||
>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
<span>{selected?.label ?? label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={clsx(
|
||||
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
|
||||
isPending && "pointer-events-none"
|
||||
)}
|
||||
aria-label={label}
|
||||
>
|
||||
<Languages className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-left flex-1">
|
||||
{selected?.label ?? label}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[8rem]">
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
@@ -66,7 +72,7 @@ export default function LocaleSwitcherSelect({
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ export function OrgSelector({
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const picker = (
|
||||
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
|
||||
<OrgPicker
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
contentClassName={
|
||||
isCollapsed
|
||||
? "w-[320px]"
|
||||
: "w-[var(--radix-popover-trigger-width)]"
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="combobox"
|
||||
className={cn(
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import type { AdminOrgRow } from "@server/routers/org";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDownIcon
|
||||
} from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
|
||||
type OrgTableProps = {
|
||||
orgs: AdminOrgRow[];
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function OrgsTable({
|
||||
orgs,
|
||||
pagination,
|
||||
rowCount
|
||||
}: OrgTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
function refreshData() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
function sortableHeader(column: string, label: string) {
|
||||
const sortOrder = getSortDirection(column, searchParams);
|
||||
const Icon =
|
||||
sortOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: sortOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort(column)}
|
||||
>
|
||||
{label}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
friendlyName: t("name"),
|
||||
enableHiding: false,
|
||||
header: () => sortableHeader("name", t("name"))
|
||||
},
|
||||
{
|
||||
accessorKey: "orgId",
|
||||
friendlyName: t("orgId"),
|
||||
header: () => <span className="p-3">{t("orgId")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => sortableHeader("createdAt", t("createdAt")),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{createdAt ? moment(createdAt).format("lll") : "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "owner",
|
||||
friendlyName: t("accessRoleOwner"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("accessRoleOwner")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const owner = row.original.owner;
|
||||
return owner ? (
|
||||
<Button
|
||||
className="tabular-nums"
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Link href={`/admin/users/${owner.userId}`}>
|
||||
{owner.username}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<code>-</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("subnet"),
|
||||
header: () => <span className="p-3">{t("subnet")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.subnet || "-"}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "utilitySubnet",
|
||||
friendlyName: t("utilitySubnet"),
|
||||
header: () => <span className="p-3">{t("utilitySubnet")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.utilitySubnet || "-"}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "userCount",
|
||||
friendlyName: t("users"),
|
||||
header: () => <span className="p-3">{t("users")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.userCount}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "siteCount",
|
||||
friendlyName: t("sites"),
|
||||
header: () => <span className="p-3">{t("sites")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.siteCount}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.resourceCount}</span>
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const orgRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedOrg(orgRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [t, searchParams]);
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
async function deleteOrg(orgId: string) {
|
||||
try {
|
||||
const res = await api.delete(`/admin/org/${orgId}`);
|
||||
toast({
|
||||
title: t("orgDeleted"),
|
||||
description: t("orgDeletedMessage")
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("orgErrorDelete"),
|
||||
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
|
||||
});
|
||||
} finally {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrg && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedOrg(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("orgQuestionRemove")}</p>
|
||||
<p>{t("orgMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("orgDeleteConfirm")}
|
||||
onConfirm={async () => {
|
||||
startTransition(() => deleteOrg(selectedOrg.orgId));
|
||||
}}
|
||||
string={selectedOrg.name}
|
||||
title={t("orgDelete")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={orgs}
|
||||
tableId="admin-orgs-table"
|
||||
searchPlaceholder={t("orgSearch")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
subnet: false,
|
||||
utilitySubnet: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { Check, Laptop, Moon, Sun, Trash2 } from "lucide-react";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -152,49 +149,42 @@ export default function ProfileIcon() {
|
||||
>
|
||||
<span>{t("changePassword")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
<span>{t("viewDevices") || "View Devices"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
|
||||
{userTheme === "light" && (
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{userTheme === "dark" && (
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{userTheme === "system" && (
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">{t(userTheme)}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
||||
{(["light", "dark", "system"] as const).map(
|
||||
(themeOption) => (
|
||||
<DropdownMenuItem
|
||||
key={themeOption}
|
||||
onClick={() =>
|
||||
handleThemeChange(themeOption)
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{userTheme === themeOption && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">
|
||||
{t(themeOption)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{(["light", "dark", "system"] as const).map(
|
||||
(themeOption) => (
|
||||
<DropdownMenuItem
|
||||
key={themeOption}
|
||||
onClick={() => handleThemeChange(themeOption)}
|
||||
>
|
||||
{themeOption === "light" && (
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{themeOption === "dark" && (
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{themeOption === "system" && (
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">
|
||||
{t(themeOption)}
|
||||
</span>
|
||||
{userTheme === themeOption && (
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenuSeparator />
|
||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||
|
||||
@@ -21,12 +21,14 @@ import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { build } from "@server/build";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./ui/controlled-data-table";
|
||||
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@server/*": [
|
||||
|
||||
Reference in New Issue
Block a user