mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 13:31:27 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5157bea2b7 | |||
| 58a9417e51 | |||
| c7926223dc | |||
| 80d08d4f8e | |||
| 82c5dcf16f | |||
| 59f0c90836 | |||
| b0e64a5e5a | |||
| 733d3ece0e | |||
| 59b228ce39 | |||
| 080bcbaf97 | |||
| 9853122a51 | |||
| c411a1a5b9 | |||
| a4d9365563 | |||
| e9f7678b90 | |||
| a904c915d8 | |||
| cb84c2954b |
@@ -465,6 +465,8 @@
|
|||||||
"apiKeysDelete": "Delete API Key",
|
"apiKeysDelete": "Delete API Key",
|
||||||
"apiKeysManage": "Manage API Keys",
|
"apiKeysManage": "Manage API Keys",
|
||||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
"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",
|
"provisioningKeysTitle": "Provisioning Key",
|
||||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||||
@@ -2095,6 +2097,7 @@
|
|||||||
"resourceBudgetSettings": "Budget",
|
"resourceBudgetSettings": "Budget",
|
||||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
|
"sidebarOrgs": "Organizations",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
"sidebarAllUsers": "All Users",
|
"sidebarAllUsers": "All Users",
|
||||||
|
|||||||
+1
-1
@@ -96,6 +96,7 @@
|
|||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "5.4.1",
|
"js-yaml": "5.4.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
|
"lru-cache": "11.5.2",
|
||||||
"lucide-react": "1.38.0",
|
"lucide-react": "1.38.0",
|
||||||
"maxmind": "5.0.7",
|
"maxmind": "5.0.7",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
@@ -103,7 +104,6 @@
|
|||||||
"next-intl": "4.14.1",
|
"next-intl": "4.14.1",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
|
||||||
"nodemailer": "9.1.0",
|
"nodemailer": "9.1.0",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 802 KiB |
+2
-8
@@ -1,13 +1,7 @@
|
|||||||
import NodeCache from "node-cache";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
export const localCache = createLocalCache();
|
||||||
// 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
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 jitter = Math.random() * baseDelay;
|
||||||
const delay = baseDelay + jitter;
|
const delay = baseDelay + jitter;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|||||||
@@ -348,8 +348,8 @@ export const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.pipe(z.string())
|
.pipe(z.string())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
subnet_group: z.string().optional().default("100.89.137.0/18"),
|
||||||
block_size: z.number().positive().gt(0).optional().default(24),
|
block_size: z.number().positive().gt(0).optional().default(22),
|
||||||
site_block_size: z
|
site_block_size: z
|
||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
|
|||||||
@@ -11,17 +11,11 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import NodeCache from "node-cache";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
export const localCache = createLocalCache();
|
||||||
// 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
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
@@ -301,15 +295,11 @@ export default cache;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
* Falls back to a local LRU cache when the regional Redis is unavailable.
|
||||||
* Use this for data that is regional in nature (e.g. status history) so
|
* 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.
|
* reads are served from the same cluster the user is hitting.
|
||||||
*/
|
*/
|
||||||
const regionalLocalCache = new NodeCache({
|
const regionalLocalCache = createLocalCache();
|
||||||
stdTTL: 3600,
|
|
||||||
checkperiod: 120,
|
|
||||||
maxKeys: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
class RegionalAdaptiveCache {
|
class RegionalAdaptiveCache {
|
||||||
private useRedis(): boolean {
|
private useRedis(): boolean {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { db, HostMeta, sites, users } from "@server/db";
|
import { db, HostMeta, sites, users } from "@server/db";
|
||||||
import { hostMeta, licenseKey } from "@server/db";
|
import { hostMeta, licenseKey } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import NodeCache from "node-cache";
|
import { createLocalCache } from "@server/lib/createLocalCache";
|
||||||
import { validateJWT } from "./licenseJwt";
|
import { validateJWT } from "./licenseJwt";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -65,8 +65,8 @@ export class License {
|
|||||||
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
||||||
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
||||||
|
|
||||||
private statusCache = new NodeCache();
|
private statusCache = createLocalCache();
|
||||||
private licenseKeyCache = new NodeCache();
|
private licenseKeyCache = createLocalCache();
|
||||||
|
|
||||||
private statusKey = "status";
|
private statusKey = "status";
|
||||||
private serverSecret!: string;
|
private serverSecret!: string;
|
||||||
@@ -179,7 +179,7 @@ LQIDAQAB
|
|||||||
status.isHostLicensed = false;
|
status.isHostLicensed = false;
|
||||||
// Invalidate all and set new cache (empty)
|
// Invalidate all and set new cache (empty)
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
this.statusCache.set(this.statusKey, status);
|
this.statusCache.set(this.statusKey, status, 0);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ LQIDAQAB
|
|||||||
// Invalidate old cache and set new cache
|
// Invalidate old cache and set new cache
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
for (const [key, value] of newCache.entries()) {
|
for (const [key, value] of newCache.entries()) {
|
||||||
this.licenseKeyCache.set<LicenseKeyCache>(key, value);
|
this.licenseKeyCache.set(key, value, 0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error checking license status:");
|
logger.error("Error checking license status:");
|
||||||
@@ -398,7 +398,7 @@ LQIDAQAB
|
|||||||
this.checkInProgress = false;
|
this.checkInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.statusCache.set(this.statusKey, status);
|
this.statusCache.set(this.statusKey, status, 0);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2478,7 +2478,12 @@ hybridRouter.post(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
if (!(
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === "Exit node not allowed"
|
||||||
|
)) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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,11 +12,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { db, exitNodes, newts, sites } from "@server/db";
|
import { db, newts, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import redisManager from "#private/lib/redis";
|
import redisManager from "#private/lib/redis";
|
||||||
// import { sendToClient } from "#private/routers/ws";
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||||
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
|||||||
|
|
||||||
interface PendingReconnect {
|
interface PendingReconnect {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
reachableAt: string;
|
endpoint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory tracking for this node
|
// In-memory tracking for this node
|
||||||
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
|||||||
*/
|
*/
|
||||||
export async function scheduleExitNodeReconnect(
|
export async function scheduleExitNodeReconnect(
|
||||||
exitNodeId: number,
|
exitNodeId: number,
|
||||||
reachableAt: string
|
endpoint: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry: PendingReconnect = {
|
const entry: PendingReconnect = {
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
reachableAt
|
endpoint
|
||||||
};
|
};
|
||||||
|
|
||||||
pendingReconnects.set(exitNodeId, entry);
|
pendingReconnects.set(exitNodeId, entry);
|
||||||
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
|
|||||||
);
|
);
|
||||||
await redisManager.hset(
|
await redisManager.hset(
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
"reachableAt",
|
"endpoint",
|
||||||
reachableAt
|
endpoint
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
`${REDIS_HASH_PREFIX}${id}`,
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
"startTime"
|
"startTime"
|
||||||
);
|
);
|
||||||
const reachableAt = await redisManager.hget(
|
const endpoint = await redisManager.hget(
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
"reachableAt"
|
"endpoint"
|
||||||
);
|
);
|
||||||
if (startTimeStr && reachableAt) {
|
if (startTimeStr && endpoint) {
|
||||||
toProcess.set(id, {
|
toProcess.set(id, {
|
||||||
startTime: parseInt(startTimeStr, 10),
|
startTime: parseInt(startTimeStr, 10),
|
||||||
reachableAt
|
endpoint
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +151,7 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the exit node HTTP endpoint is reachable
|
// Check if the exit node HTTP endpoint is reachable
|
||||||
const pingUrl = `${entry.reachableAt}/ping`;
|
const pingUrl = `http://${entry.endpoint}/ping`;
|
||||||
try {
|
try {
|
||||||
await axios.get(pingUrl, { timeout: 5000 });
|
await axios.get(pingUrl, { timeout: 5000 });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -150,47 +166,47 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||||
);
|
);
|
||||||
|
|
||||||
// await sendReconnectToNewts(exitNodeId);
|
await sendReconnectToNewts(exitNodeId);
|
||||||
await removePending(exitNodeId);
|
await removePending(exitNodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||||
// try {
|
try {
|
||||||
// const connectedNewts = await db
|
const connectedNewts = await db
|
||||||
// .select({ newtId: newts.newtId })
|
.select({ newtId: newts.newtId })
|
||||||
// .from(newts)
|
.from(newts)
|
||||||
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||||
// .where(eq(sites.exitNodeId, exitNodeId));
|
.where(eq(sites.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
// if (connectedNewts.length === 0) {
|
if (connectedNewts.length === 0) {
|
||||||
// logger.debug(
|
logger.debug(
|
||||||
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||||
// );
|
);
|
||||||
// return;
|
return;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// logger.info(
|
logger.info(
|
||||||
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||||
// );
|
);
|
||||||
|
|
||||||
// const reconnectMessage = {
|
const reconnectMessage = {
|
||||||
// type: "newt/wg/reconnect",
|
type: "newt/wg/reconnect",
|
||||||
// data: {}
|
data: {}
|
||||||
// };
|
};
|
||||||
|
|
||||||
// await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
// connectedNewts.map(({ newtId }) =>
|
connectedNewts.map(({ newtId }) =>
|
||||||
// sendToClient(newtId, reconnectMessage)
|
sendToClient(newtId, reconnectMessage)
|
||||||
// )
|
)
|
||||||
// );
|
);
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// logger.error(
|
logger.error(
|
||||||
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||||
// { error }
|
{ error }
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
async function removePending(exitNodeId: number): Promise<void> {
|
async function removePending(exitNodeId: number): Promise<void> {
|
||||||
pendingReconnects.delete(exitNodeId);
|
pendingReconnects.delete(exitNodeId);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
try {
|
try {
|
||||||
// Fetch the current state before updating so we can detect the offline→online transition
|
// Fetch the current state before updating so we can detect the offline→online transition
|
||||||
const [currentExitNode] = await db
|
const [currentExitNode] = await db
|
||||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
|
||||||
.from(exitNodes)
|
.from(exitNodes)
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -55,12 +55,14 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
if (
|
||||||
scheduleExitNodeReconnect(
|
currentExitNode &&
|
||||||
remoteExitNode.exitNodeId,
|
!currentExitNode.online &&
|
||||||
currentExitNode.reachableAt
|
currentExitNode.endpoint
|
||||||
).catch((error) => {
|
) {
|
||||||
logger.error("Failed to schedule exit node reconnect", { error });
|
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
||||||
|
exitNodeId: remoteExitNode.exitNodeId,
|
||||||
|
endpoint: currentExitNode.endpoint
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -691,7 +691,9 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
|
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
if (resourceSession) {
|
||||||
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession?.isRequestToken) {
|
if (resourceSession?.isRequestToken) {
|
||||||
@@ -1121,7 +1123,9 @@ async function allowAccessToken(
|
|||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
if (resourceSession) {
|
||||||
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ authenticated.get("/org/checkId", org.checkId);
|
|||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
|
|
||||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
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("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!resources || resources.length === 0) {
|
if (!resources || resources.length === 0) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
|
|
||||||
if (resources.length > 1) {
|
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
|
// 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.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (currentResourceAssociationCaches.length === 0) {
|
if (currentResourceAssociationCaches.length === 0) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.networkId) {
|
if (!resource.networkId) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||||
|
|
||||||
if (!siteRows || siteRows.length === 0) {
|
if (!siteRows || siteRows.length === 0) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sitesToProcess.length === 0) {
|
if (sitesToProcess.length === 0) {
|
||||||
logger.error(
|
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
||||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
|
||||||
);
|
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!handshakeInitiated) {
|
if (!handshakeInitiated) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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..."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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,3 +9,5 @@ export * from "./listOrgs";
|
|||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
export * from "./checkOrgUserAccess";
|
export * from "./checkOrgUserAccess";
|
||||||
export * from "./resetOrgBandwidth";
|
export * from "./resetOrgBandwidth";
|
||||||
|
export * from "./adminListOrgs";
|
||||||
|
export * from "./adminDeleteOrg";
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
|
|||||||
subscriptionStatus = subRes.data.data;
|
subscriptionStatus = subRes.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If subscription fetch fails, keep subscriptionStatus as null
|
// If subscription fetch fails, keep subscriptionStatus as null
|
||||||
console.error("Failed to fetch subscription status:", error);
|
// console.error("Failed to fetch subscription status:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,6 +6,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
Building2Icon,
|
||||||
Cable,
|
Cable,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -381,6 +382,11 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
href: "/admin/api-keys",
|
href: "/admin/api-keys",
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarOrgs",
|
||||||
|
href: "/admin/organizations",
|
||||||
|
icon: <Building2Icon className="size-4 flex-none" />
|
||||||
|
},
|
||||||
...(build === "oss" ||
|
...(build === "oss" ||
|
||||||
env?.app.identityProviderMode === "global" ||
|
env?.app.identityProviderMode === "global" ||
|
||||||
env?.app.identityProviderMode === undefined
|
env?.app.identityProviderMode === undefined
|
||||||
@@ -392,7 +398,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(build == "enterprise"
|
...(build === "enterprise"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarLicense",
|
title: "sidebarLicense",
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Check, Languages } from "lucide-react";
|
||||||
import { Check, Globe, Languages } from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Locale } from "@/i18n/config";
|
import { Locale } from "@/i18n/config";
|
||||||
import { setUserLocale } from "@/services/locale";
|
import { setUserLocale } from "@/services/locale";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
@@ -43,23 +42,18 @@ export default function LocaleSwitcherSelect({
|
|||||||
const selected = items.find((item) => item.value === defaultValue);
|
const selected = items.find((item) => item.value === defaultValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuSubTrigger
|
||||||
<Button
|
className={cn(
|
||||||
variant="ghost"
|
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className={clsx(
|
isPending && "pointer-events-none"
|
||||||
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
|
)}
|
||||||
isPending && "pointer-events-none"
|
aria-label={label}
|
||||||
)}
|
>
|
||||||
aria-label={label}
|
<Languages className="mr-2 h-4 w-4" />
|
||||||
>
|
<span>{selected?.label ?? label}</span>
|
||||||
<Languages className="text-muted-foreground h-4 w-4" />
|
</DropdownMenuSubTrigger>
|
||||||
<span className="text-left flex-1">
|
<DropdownMenuSubContent className="min-w-[8rem]">
|
||||||
{selected?.label ?? label}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[8rem]">
|
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -72,7 +66,7 @@ export default function LocaleSwitcherSelect({
|
|||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenu>
|
</DropdownMenuSub>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,15 +27,7 @@ export function OrgSelector({
|
|||||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||||
|
|
||||||
const picker = (
|
const picker = (
|
||||||
<OrgPicker
|
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
|
||||||
orgId={orgId}
|
|
||||||
orgs={orgs}
|
|
||||||
contentClassName={
|
|
||||||
isCollapsed
|
|
||||||
? "w-[320px]"
|
|
||||||
: "w-[var(--radix-popover-trigger-width)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"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,13 +9,16 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
import { Check, Laptop, Moon, Sun, Trash2 } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -149,42 +152,49 @@ export default function ProfileIcon() {
|
|||||||
>
|
>
|
||||||
<span>{t("changePassword")}</span>
|
<span>{t("changePassword")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
||||||
<Smartphone className="mr-2 h-4 w-4" />
|
|
||||||
<span>{t("viewDevices") || "View Devices"}</span>
|
<span>{t("viewDevices") || "View Devices"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
||||||
{(["light", "dark", "system"] as const).map(
|
<DropdownMenuSub>
|
||||||
(themeOption) => (
|
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
|
||||||
<DropdownMenuItem
|
{userTheme === "light" && (
|
||||||
key={themeOption}
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
onClick={() => handleThemeChange(themeOption)}
|
)}
|
||||||
>
|
{userTheme === "dark" && (
|
||||||
{themeOption === "light" && (
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
)}
|
||||||
)}
|
{userTheme === "system" && (
|
||||||
{themeOption === "dark" && (
|
<Laptop className="mr-2 h-4 w-4" />
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
)}
|
||||||
)}
|
<span className="capitalize">{t(userTheme)}</span>
|
||||||
{themeOption === "system" && (
|
</DropdownMenuSubTrigger>
|
||||||
<Laptop className="mr-2 h-4 w-4" />
|
<DropdownMenuSubContent className="min-w-[8rem]">
|
||||||
)}
|
{(["light", "dark", "system"] as const).map(
|
||||||
<span className="capitalize">
|
(themeOption) => (
|
||||||
{t(themeOption)}
|
<DropdownMenuItem
|
||||||
</span>
|
key={themeOption}
|
||||||
{userTheme === themeOption && (
|
onClick={() =>
|
||||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
handleThemeChange(themeOption)
|
||||||
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
}
|
||||||
</span>
|
className="flex items-center gap-2"
|
||||||
)}
|
>
|
||||||
</DropdownMenuItem>
|
{userTheme === themeOption && (
|
||||||
)
|
<Check className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
<span className="capitalize">
|
||||||
|
{t(themeOption)}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||||
|
|||||||
@@ -21,14 +21,12 @@ import { Switch } from "@app/components/ui/switch";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
|
||||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||||
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
import { durationToMs } from "@app/lib/durationToMs";
|
|
||||||
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": [
|
"@server/*": [
|
||||||
|
|||||||
Reference in New Issue
Block a user