mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-11 21:56:45 +00:00
Compare commits
4 Commits
dev
...
1.17.0-s.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce165bfd5 | ||
|
|
035644eaf7 | ||
|
|
16e7233a3e | ||
|
|
1f74e1b320 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
|||||||
* @oschwartz10612 @miloschwartz
|
|
||||||
@@ -86,8 +86,6 @@ entryPoints:
|
|||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
middlewares:
|
|
||||||
- crowdsec@file
|
|
||||||
encodedCharacters:
|
encodedCharacters:
|
||||||
allowEncodedSlash: true
|
allowEncodedSlash: true
|
||||||
allowEncodedQuestionMark: true
|
allowEncodedQuestionMark: true
|
||||||
|
|||||||
@@ -2113,11 +2113,9 @@
|
|||||||
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
||||||
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||||
"domainPickerProvidedDomain": "Provided Domain",
|
"domainPickerProvidedDomain": "Provided Domain",
|
||||||
"domainPickerFreeProvidedDomain": "Provided Domain",
|
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||||
"domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
|
|
||||||
"domainPickerVerified": "Verified",
|
"domainPickerVerified": "Verified",
|
||||||
"domainPickerUnverified": "Unverified",
|
"domainPickerUnverified": "Unverified",
|
||||||
"domainPickerManual": "Manual",
|
|
||||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||||
"domainPickerError": "Error",
|
"domainPickerError": "Error",
|
||||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ export enum TierFeature {
|
|||||||
SshPam = "sshPam",
|
SshPam = "sshPam",
|
||||||
FullRbac = "fullRbac",
|
FullRbac = "fullRbac",
|
||||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
SIEM = "siem" // handle downgrade by disabling SIEM integrations
|
||||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -57,6 +56,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"]
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -479,7 +479,10 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) => target.site.online
|
(target) =>
|
||||||
|
target.site.online ||
|
||||||
|
target.site.type === "local" ||
|
||||||
|
target.site.type === "wireguard"
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -492,7 +495,7 @@ export async function getTraefikConfig(
|
|||||||
if (target.health == "unhealthy") {
|
if (target.health == "unhealthy") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any sites are online, exclude offline sites
|
// If any sites are online, exclude offline sites
|
||||||
if (anySitesOnline && !target.site.online) {
|
if (anySitesOnline && !target.site.online) {
|
||||||
return false;
|
return false;
|
||||||
@@ -607,7 +610,10 @@ export async function getTraefikConfig(
|
|||||||
servers: (() => {
|
servers: (() => {
|
||||||
// Check if any sites are online
|
// Check if any sites are online
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) => target.site.online
|
(target) =>
|
||||||
|
target.site.online ||
|
||||||
|
target.site.type === "local" ||
|
||||||
|
target.site.type === "wireguard"
|
||||||
);
|
);
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
@@ -615,7 +621,7 @@ export async function getTraefikConfig(
|
|||||||
if (!target.enabled) {
|
if (!target.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any sites are online, exclude offline sites
|
// If any sites are online, exclude offline sites
|
||||||
if (anySitesOnline && !target.site.online) {
|
if (anySitesOnline && !target.site.online) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -671,7 +671,10 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) => target.site.online
|
(target) =>
|
||||||
|
target.site.online ||
|
||||||
|
target.site.type === "local" ||
|
||||||
|
target.site.type === "wireguard"
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -799,7 +802,10 @@ export async function getTraefikConfig(
|
|||||||
servers: (() => {
|
servers: (() => {
|
||||||
// Check if any sites are online
|
// Check if any sites are online
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) => target.site.online
|
(target) =>
|
||||||
|
target.site.online ||
|
||||||
|
target.site.type === "local" ||
|
||||||
|
target.site.type === "wireguard"
|
||||||
);
|
);
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
@@ -22,15 +22,11 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { db, domainNamespaces, resources } from "@server/db";
|
import { db, domainNamespaces, resources } from "@server/db";
|
||||||
import { inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({});
|
const paramsSchema = z.strictObject({});
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.strictObject({
|
||||||
subdomain: z.string(),
|
subdomain: z.string()
|
||||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -62,23 +58,6 @@ export async function checkDomainNamespaceAvailability(
|
|||||||
}
|
}
|
||||||
const { subdomain } = parsedQuery.data;
|
const { subdomain } = parsedQuery.data;
|
||||||
|
|
||||||
// if (
|
|
||||||
// build == "saas" &&
|
|
||||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
|
||||||
// ) {
|
|
||||||
// // return not available
|
|
||||||
// return response<CheckDomainAvailabilityResponse>(res, {
|
|
||||||
// data: {
|
|
||||||
// available: false,
|
|
||||||
// options: []
|
|
||||||
// },
|
|
||||||
// success: true,
|
|
||||||
// error: false,
|
|
||||||
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
|
||||||
// status: HttpCode.OK
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const namespaces = await db.select().from(domainNamespaces);
|
const namespaces = await db.select().from(domainNamespaces);
|
||||||
let possibleDomains = namespaces.map((ns) => {
|
let possibleDomains = namespaces.map((ns) => {
|
||||||
const desired = `${subdomain}.${ns.domainNamespaceId}`;
|
const desired = `${subdomain}.${ns.domainNamespaceId}`;
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ import { eq, sql } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({});
|
const paramsSchema = z.strictObject({});
|
||||||
|
|
||||||
@@ -40,8 +37,7 @@ const querySchema = z.strictObject({
|
|||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.default("0")
|
||||||
.transform(Number)
|
.transform(Number)
|
||||||
.pipe(z.int().nonnegative()),
|
.pipe(z.int().nonnegative())
|
||||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function query(limit: number, offset: number) {
|
async function query(limit: number, offset: number) {
|
||||||
@@ -103,26 +99,6 @@ export async function listDomainNamespaces(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (
|
|
||||||
// build == "saas" &&
|
|
||||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
|
||||||
// ) {
|
|
||||||
// return response<ListDomainNamespacesResponse>(res, {
|
|
||||||
// data: {
|
|
||||||
// domainNamespaces: [],
|
|
||||||
// pagination: {
|
|
||||||
// total: 0,
|
|
||||||
// limit,
|
|
||||||
// offset
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// success: true,
|
|
||||||
// error: false,
|
|
||||||
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
|
||||||
// status: HttpCode.OK
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const domainNamespacesList = await query(limit, offset);
|
const domainNamespacesList = await query(limit, offset);
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { sites, clients, olms } from "@server/db";
|
import { sites, clients, olms } from "@server/db";
|
||||||
import { inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +21,7 @@ import logger from "@server/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 2;
|
||||||
const BASE_DELAY_MS = 50;
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
@@ -36,14 +36,6 @@ const pendingOlmArchiveResets: Set<string> = new Set();
|
|||||||
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Guard that prevents two flush cycles from running concurrently.
|
|
||||||
* setInterval does not await async callbacks, so without this a slow flush
|
|
||||||
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
|
|
||||||
* the two concurrent bulk UPDATEs would deadlock each other.
|
|
||||||
*/
|
|
||||||
let isFlushing = false;
|
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,12 +72,6 @@ export function recordClientPing(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush all accumulated site pings to the database.
|
* Flush all accumulated site pings to the database.
|
||||||
*
|
|
||||||
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
|
|
||||||
* statement. We use the maximum timestamp across the batch so that `lastPing`
|
|
||||||
* reflects the most recent ping seen for any site in the group. This avoids
|
|
||||||
* the multi-statement transaction that previously created additional
|
|
||||||
* row-lock ordering hazards.
|
|
||||||
*/
|
*/
|
||||||
async function flushSitePingsToDb(): Promise<void> {
|
async function flushSitePingsToDb(): Promise<void> {
|
||||||
if (pendingSitePings.size === 0) {
|
if (pendingSitePings.size === 0) {
|
||||||
@@ -97,35 +83,55 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
const pingsToFlush = new Map(pendingSitePings);
|
const pingsToFlush = new Map(pendingSitePings);
|
||||||
pendingSitePings.clear();
|
pendingSitePings.clear();
|
||||||
|
|
||||||
const entries = Array.from(pingsToFlush.entries());
|
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
||||||
|
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||||
|
([a], [b]) => a - b
|
||||||
|
);
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
// Use the latest timestamp in the batch so that `lastPing` always
|
|
||||||
// moves forward. Using a single timestamp for the whole batch means
|
|
||||||
// we only ever need one UPDATE statement (no transaction).
|
|
||||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
|
||||||
const siteIds = batch.map(([id]) => id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
await db
|
// Group by timestamp for efficient bulk updates
|
||||||
.update(sites)
|
const byTimestamp = new Map<number, number[]>();
|
||||||
.set({
|
for (const [siteId, timestamp] of batch) {
|
||||||
online: true,
|
const group = byTimestamp.get(timestamp) || [];
|
||||||
lastPing: maxTimestamp
|
group.push(siteId);
|
||||||
})
|
byTimestamp.set(timestamp, group);
|
||||||
.where(inArray(sites.siteId, siteIds));
|
}
|
||||||
|
|
||||||
|
if (byTimestamp.size === 1) {
|
||||||
|
const [timestamp, siteIds] = Array.from(
|
||||||
|
byTimestamp.entries()
|
||||||
|
)[0];
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: timestamp
|
||||||
|
})
|
||||||
|
.where(inArray(sites.siteId, siteIds));
|
||||||
|
} else {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [timestamp, siteIds] of byTimestamp) {
|
||||||
|
await tx
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: timestamp
|
||||||
|
})
|
||||||
|
.where(inArray(sites.siteId, siteIds));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}, "flushSitePingsToDb");
|
}, "flushSitePingsToDb");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
||||||
{ error }
|
{ error }
|
||||||
);
|
);
|
||||||
// Re-queue only if the preserved timestamp is newer than any
|
|
||||||
// update that may have landed since we snapshotted.
|
|
||||||
for (const [siteId, timestamp] of batch) {
|
for (const [siteId, timestamp] of batch) {
|
||||||
const existing = pendingSitePings.get(siteId);
|
const existing = pendingSitePings.get(siteId);
|
||||||
if (!existing || existing < timestamp) {
|
if (!existing || existing < timestamp) {
|
||||||
@@ -138,8 +144,6 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush all accumulated client (OLM) pings to the database.
|
* Flush all accumulated client (OLM) pings to the database.
|
||||||
*
|
|
||||||
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
|
|
||||||
*/
|
*/
|
||||||
async function flushClientPingsToDb(): Promise<void> {
|
async function flushClientPingsToDb(): Promise<void> {
|
||||||
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
||||||
@@ -155,25 +159,51 @@ async function flushClientPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
// ── Flush client pings ─────────────────────────────────────────────
|
// ── Flush client pings ─────────────────────────────────────────────
|
||||||
if (pingsToFlush.size > 0) {
|
if (pingsToFlush.size > 0) {
|
||||||
const entries = Array.from(pingsToFlush.entries());
|
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||||
|
([a], [b]) => a - b
|
||||||
|
);
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
|
||||||
const clientIds = batch.map(([id]) => id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
await db
|
const byTimestamp = new Map<number, number[]>();
|
||||||
.update(clients)
|
for (const [clientId, timestamp] of batch) {
|
||||||
.set({
|
const group = byTimestamp.get(timestamp) || [];
|
||||||
lastPing: maxTimestamp,
|
group.push(clientId);
|
||||||
online: true,
|
byTimestamp.set(timestamp, group);
|
||||||
archived: false
|
}
|
||||||
})
|
|
||||||
.where(inArray(clients.clientId, clientIds));
|
if (byTimestamp.size === 1) {
|
||||||
|
const [timestamp, clientIds] = Array.from(
|
||||||
|
byTimestamp.entries()
|
||||||
|
)[0];
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
lastPing: timestamp,
|
||||||
|
online: true,
|
||||||
|
archived: false
|
||||||
|
})
|
||||||
|
.where(inArray(clients.clientId, clientIds));
|
||||||
|
} else {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [timestamp, clientIds] of byTimestamp) {
|
||||||
|
await tx
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
lastPing: timestamp,
|
||||||
|
online: true,
|
||||||
|
archived: false
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
inArray(clients.clientId, clientIds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}, "flushClientPingsToDb");
|
}, "flushClientPingsToDb");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -230,12 +260,7 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
* (connection timeouts, unexpected disconnects).
|
||||||
*
|
|
||||||
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
|
||||||
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
|
||||||
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
|
||||||
* retry budgets to give deadlock victims enough chances to succeed.
|
|
||||||
*/
|
*/
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
@@ -252,8 +277,7 @@ 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}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
|
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
continue;
|
continue;
|
||||||
@@ -264,14 +288,14 @@ async function withRetry<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect transient errors that are safe to retry.
|
* Detect transient connection errors that are safe to retry.
|
||||||
*/
|
*/
|
||||||
function isTransientError(error: any): boolean {
|
function isTransientError(error: any): boolean {
|
||||||
if (!error) return false;
|
if (!error) return false;
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
const message = (error.message || "").toLowerCase();
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
const code = error.code || error.cause?.code || "";
|
const code = error.code || "";
|
||||||
|
|
||||||
// Connection timeout / terminated
|
// Connection timeout / terminated
|
||||||
if (
|
if (
|
||||||
@@ -284,17 +308,12 @@ function isTransientError(error: any): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
// PostgreSQL deadlock
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
// ECONNRESET, ECONNREFUSED, EPIPE
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
if (
|
||||||
code === "ECONNRESET" ||
|
code === "ECONNRESET" ||
|
||||||
code === "ECONNREFUSED" ||
|
code === "ECONNREFUSED" ||
|
||||||
@@ -318,26 +337,12 @@ export function startPingAccumulator(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushTimer = setInterval(async () => {
|
flushTimer = setInterval(async () => {
|
||||||
// Skip this tick if the previous flush is still in progress.
|
|
||||||
// setInterval does not await async callbacks, so without this guard
|
|
||||||
// two flush cycles can run concurrently and deadlock each other on
|
|
||||||
// overlapping bulk UPDATE statements.
|
|
||||||
if (isFlushing) {
|
|
||||||
logger.debug(
|
|
||||||
"Ping accumulator: previous flush still in progress, skipping cycle"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isFlushing = true;
|
|
||||||
try {
|
try {
|
||||||
await flushPingsToDb();
|
await flushPingsToDb();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Unhandled error in ping accumulator flush", {
|
logger.error("Unhandled error in ping accumulator flush", {
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
isFlushing = false;
|
|
||||||
}
|
}
|
||||||
}, FLUSH_INTERVAL_MS);
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
@@ -359,22 +364,7 @@ export async function stopPingAccumulator(): Promise<void> {
|
|||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final flush to persist any remaining pings.
|
// Final flush to persist any remaining pings
|
||||||
// Wait for any in-progress flush to finish first so we don't race.
|
|
||||||
if (isFlushing) {
|
|
||||||
logger.debug(
|
|
||||||
"Ping accumulator: waiting for in-progress flush before stopping…"
|
|
||||||
);
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const poll = setInterval(() => {
|
|
||||||
if (!isFlushing) {
|
|
||||||
clearInterval(poll);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await flushPingsToDb();
|
await flushPingsToDb();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -389,4 +379,4 @@ export async function stopPingAccumulator(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export function getPendingPingCount(): number {
|
export function getPendingPingCount(): number {
|
||||||
return pendingSitePings.size + pendingClientPings.size;
|
return pendingSitePings.size + pendingClientPings.size;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
import { db, loginPage } from "@server/db";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
@@ -24,8 +24,6 @@ import { build } from "@server/build";
|
|||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { getUniqueResourceName } from "@server/db/names";
|
import { getUniqueResourceName } from "@server/db/names";
|
||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
const createResourceParamsSchema = z.strictObject({
|
const createResourceParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -195,27 +193,6 @@ async function createHttpResource(
|
|||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
if (
|
|
||||||
build == "saas" &&
|
|
||||||
!isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
|
||||||
) {
|
|
||||||
// check if this domain id is a namespace domain and if so, reject
|
|
||||||
const domain = await db
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.where(eq(domainNamespaces.domainId, domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (domain.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate domain and construct full domain
|
// Validate domain and construct full domain
|
||||||
const domainResult = await validateAndConstructDomain(
|
const domainResult = await validateAndConstructDomain(
|
||||||
domainId,
|
domainId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
import { db, loginPage } from "@server/db";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
Org,
|
Org,
|
||||||
@@ -25,7 +25,6 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
|
||||||
|
|
||||||
const updateResourceParamsSchema = z.strictObject({
|
const updateResourceParamsSchema = z.strictObject({
|
||||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -319,27 +318,6 @@ async function updateHttpResource(
|
|||||||
if (updateData.domainId) {
|
if (updateData.domainId) {
|
||||||
const domainId = updateData.domainId;
|
const domainId = updateData.domainId;
|
||||||
|
|
||||||
if (
|
|
||||||
build == "saas" &&
|
|
||||||
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
|
|
||||||
) {
|
|
||||||
// check if this domain id is a namespace domain and if so, reject
|
|
||||||
const domain = await db
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.where(eq(domainNamespaces.domainId, domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (domain.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate domain and construct full domain
|
// Validate domain and construct full domain
|
||||||
const domainResult = await validateAndConstructDomain(
|
const domainResult = await validateAndConstructDomain(
|
||||||
domainId,
|
domainId,
|
||||||
@@ -388,7 +366,7 @@ async function updateHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build != "oss") {
|
||||||
const existingLoginPages = await db
|
const existingLoginPages = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -235,9 +235,7 @@ export default async function migration() {
|
|||||||
for (const row of existingUserInviteRoles) {
|
for (const row of existingUserInviteRoles) {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||||
SELECT ${row.inviteId}, ${row.roleId}
|
VALUES (${row.inviteId}, ${row.roleId})
|
||||||
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
|
||||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -260,10 +258,7 @@ export default async function migration() {
|
|||||||
for (const row of existingUserOrgRoles) {
|
for (const row of existingUserOrgRoles) {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||||
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
||||||
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
|
||||||
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
|
||||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default async function migration() {
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
||||||
).run();
|
).run();
|
||||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -246,15 +246,12 @@ export default async function migration() {
|
|||||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||||
if (existingUserInviteRoles.length > 0) {
|
if (existingUserInviteRoles.length > 0) {
|
||||||
const insertUserInviteRole = db.prepare(
|
const insertUserInviteRole = db.prepare(
|
||||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
||||||
SELECT ?, ?
|
|
||||||
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
|
||||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertAll = db.transaction(() => {
|
const insertAll = db.transaction(() => {
|
||||||
for (const row of existingUserInviteRoles) {
|
for (const row of existingUserInviteRoles) {
|
||||||
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
insertUserInviteRole.run(row.inviteId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,16 +265,12 @@ export default async function migration() {
|
|||||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||||
if (existingUserOrgRoles.length > 0) {
|
if (existingUserOrgRoles.length > 0) {
|
||||||
const insertUserOrgRole = db.prepare(
|
const insertUserOrgRole = db.prepare(
|
||||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
||||||
SELECT ?, ?, ?
|
|
||||||
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
|
||||||
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
|
||||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertAll = db.transaction(() => {
|
const insertAll = db.transaction(() => {
|
||||||
for (const row of existingUserOrgRoles) {
|
for (const row of existingUserOrgRoles) {
|
||||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
||||||
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
||||||
import DomainCertForm from "@app/components/DomainCertForm";
|
import DomainCertForm from "@app/components/DomainCertForm";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
interface DomainSettingsPageProps {
|
interface DomainSettingsPageProps {
|
||||||
params: Promise<{ domainId: string; orgId: string }>;
|
params: Promise<{ domainId: string; orgId: string }>;
|
||||||
@@ -66,14 +65,12 @@ export default async function DomainSettingsPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{build != "oss" && env.flags.usePangolinDns ? (
|
<DomainInfoCard
|
||||||
<DomainInfoCard
|
failed={domain.failed}
|
||||||
failed={domain.failed}
|
verified={domain.verified}
|
||||||
verified={domain.verified}
|
type={domain.type}
|
||||||
type={domain.type}
|
errorMessage={domain.errorMessage}
|
||||||
errorMessage={domain.errorMessage}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
|
|||||||
|
|
||||||
const punycodePreview = useMemo(() => {
|
const punycodePreview = useMemo(() => {
|
||||||
if (!baseDomain) return "";
|
if (!baseDomain) return "";
|
||||||
const punycode = toPunycode(baseDomain.toLowerCase());
|
const punycode = toPunycode(baseDomain);
|
||||||
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
||||||
}, [baseDomain]);
|
}, [baseDomain]);
|
||||||
|
|
||||||
@@ -239,24 +239,21 @@ export default function CreateDomainForm({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-domain-form"
|
id="create-domain-form"
|
||||||
>
|
>
|
||||||
{build != "oss" && env.flags.usePangolinDns ? (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="type"
|
||||||
name="type"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<StrategySelect
|
||||||
<StrategySelect
|
options={domainOptions}
|
||||||
options={domainOptions}
|
defaultValue={field.value}
|
||||||
defaultValue={field.value}
|
onChange={field.onChange}
|
||||||
onChange={field.onChange}
|
cols={1}
|
||||||
cols={1}
|
/>
|
||||||
/>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="baseDomain"
|
name="baseDomain"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -41,12 +40,9 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
KeyRound,
|
|
||||||
Zap
|
Zap
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
|
||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { toUnicode } from "punycode";
|
import { toUnicode } from "punycode";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
@@ -99,7 +95,6 @@ export default function DomainPicker({
|
|||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { hasSaasSubscription } = usePaidStatus();
|
|
||||||
|
|
||||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||||
orgQueries.domains({ orgId })
|
orgQueries.domains({ orgId })
|
||||||
@@ -514,11 +509,9 @@ export default function DomainPicker({
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedBaseDomain.domain}
|
{selectedBaseDomain.domain}
|
||||||
</span>
|
</span>
|
||||||
{selectedBaseDomain.verified &&
|
{selectedBaseDomain.verified && (
|
||||||
selectedBaseDomain.domainType !==
|
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
"wildcard" && (
|
)}
|
||||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t("domainPickerSelectBaseDomain")
|
t("domainPickerSelectBaseDomain")
|
||||||
@@ -581,23 +574,14 @@ export default function DomainPicker({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{orgDomain.type ===
|
{orgDomain.type.toUpperCase()}{" "}
|
||||||
"wildcard"
|
•{" "}
|
||||||
|
{orgDomain.verified
|
||||||
? t(
|
? t(
|
||||||
"domainPickerManual"
|
"domainPickerVerified"
|
||||||
)
|
)
|
||||||
: (
|
: t(
|
||||||
<>
|
"domainPickerUnverified"
|
||||||
{orgDomain.type.toUpperCase()}{" "}
|
|
||||||
•{" "}
|
|
||||||
{orgDomain.verified
|
|
||||||
? t(
|
|
||||||
"domainPickerVerified"
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"domainPickerUnverified"
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,23 +680,6 @@ export default function DomainPicker({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{build === "saas" &&
|
|
||||||
!hasSaasSubscription(
|
|
||||||
tierMatrix[TierFeature.DomainNamespaces]
|
|
||||||
) &&
|
|
||||||
!hideFreeDomain && (
|
|
||||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
|
||||||
<CardContent className="py-3 px-4">
|
|
||||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
|
||||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
|
||||||
<span>
|
|
||||||
{t("domainPickerFreeDomainsPaidFeature")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/*showProvidedDomainSearch && build === "saas" && (
|
{/*showProvidedDomainSearch && build === "saas" && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|||||||
@@ -333,8 +333,7 @@ export default function PendingSitesTable({
|
|||||||
"jupiter",
|
"jupiter",
|
||||||
"saturn",
|
"saturn",
|
||||||
"uranus",
|
"uranus",
|
||||||
"neptune",
|
"neptune"
|
||||||
"pluto"
|
|
||||||
].includes(originalRow.exitNodeName.toLowerCase());
|
].includes(originalRow.exitNodeName.toLowerCase());
|
||||||
|
|
||||||
if (isCloudNode) {
|
if (isCloudNode) {
|
||||||
|
|||||||
@@ -342,8 +342,7 @@ export default function SitesTable({
|
|||||||
"jupiter",
|
"jupiter",
|
||||||
"saturn",
|
"saturn",
|
||||||
"uranus",
|
"uranus",
|
||||||
"neptune",
|
"neptune"
|
||||||
"pluto"
|
|
||||||
].includes(originalRow.exitNodeName.toLowerCase());
|
].includes(originalRow.exitNodeName.toLowerCase());
|
||||||
|
|
||||||
if (isCloudNode) {
|
if (isCloudNode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user