mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 20:29:50 +00:00
Compare commits
5 Commits
dev
...
feat/remem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d13e9105c | ||
|
|
289be30e6b | ||
|
|
a74c0c227c | ||
|
|
05bf77da29 | ||
|
|
bb4deb1ae9 |
@@ -864,8 +864,8 @@
|
|||||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
"policyAuthHeaderAuthSummary": "Header configured",
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
"policyAuthHeaderName": "Username",
|
"policyAuthHeaderName": "Header name",
|
||||||
"policyAuthHeaderValue": "Password",
|
"policyAuthHeaderValue": "Expected value",
|
||||||
"policyAuthSetPasscode": "Set Passcode",
|
"policyAuthSetPasscode": "Set Passcode",
|
||||||
"policyAuthSetPincode": "Set PIN Code",
|
"policyAuthSetPincode": "Set PIN Code",
|
||||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||||
@@ -1503,6 +1503,7 @@
|
|||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
|
"idpLastUsed": "Last used",
|
||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
@@ -3577,23 +3578,18 @@
|
|||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"resourceLauncherTitle": "Resource Launcher",
|
"resourceLauncherTitle": "Resource Launcher",
|
||||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
"resourceLauncherDescription": "View resource details and launch them from one place",
|
||||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
||||||
"resourceLauncherDefaultView": "Default",
|
"resourceLauncherDefaultView": "Default",
|
||||||
"resourceLauncherSaveView": "Save View",
|
"resourceLauncherSaveView": "Save View",
|
||||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
||||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
|
||||||
"resourceLauncherResetView": "Reset View",
|
"resourceLauncherResetView": "Reset View",
|
||||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
|
||||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
|
||||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
|
||||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
"resourceLauncherSaveAsNewView": "Save as New View",
|
||||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
||||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
||||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
||||||
"resourceLauncherMakePersonal": "Make Personal",
|
"resourceLauncherMakePersonal": "Make Personal",
|
||||||
"resourceLauncherFilter": "Filter",
|
"resourceLauncherFilter": "Filter",
|
||||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
|
||||||
"resourceLauncherSort": "Sort",
|
"resourceLauncherSort": "Sort",
|
||||||
"resourceLauncherSortAscending": "Sort ascending",
|
"resourceLauncherSortAscending": "Sort ascending",
|
||||||
"resourceLauncherSortDescending": "Sort descending",
|
"resourceLauncherSortDescending": "Sort descending",
|
||||||
@@ -3601,7 +3597,6 @@
|
|||||||
"resourceLauncherGroupBy": "Group By",
|
"resourceLauncherGroupBy": "Group By",
|
||||||
"resourceLauncherGroupBySite": "Site",
|
"resourceLauncherGroupBySite": "Site",
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
"resourceLauncherGroupByLabel": "Label",
|
||||||
"resourceLauncherGroupByNone": "None",
|
|
||||||
"resourceLauncherLayout": "Layout",
|
"resourceLauncherLayout": "Layout",
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
"resourceLauncherLayoutGrid": "Grid",
|
||||||
"resourceLauncherLayoutList": "List",
|
"resourceLauncherLayoutList": "List",
|
||||||
@@ -3609,20 +3604,8 @@
|
|||||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
"resourceLauncherShowSiteTags": "Show Site Tags",
|
||||||
"resourceLauncherShowRecents": "Show Recents",
|
"resourceLauncherShowRecents": "Show Recents",
|
||||||
"resourceLauncherDeleteView": "Delete View",
|
"resourceLauncherDeleteView": "Delete View",
|
||||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
|
||||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
|
||||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
|
||||||
"resourceLauncherResourceDetails": "Resource Details",
|
|
||||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
|
||||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
|
||||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
|
||||||
"resourceLauncherDownloadClient": "Download client",
|
|
||||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
|
||||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
|
||||||
"resourceLauncherTcp": "TCP",
|
|
||||||
"resourceLauncherUdp": "UDP",
|
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
"resourceLauncherUnlabeled": "Unlabeled",
|
||||||
"resourceLauncherNoSite": "No Site",
|
"resourceLauncherNoSite": "No Site",
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||||
@@ -3631,12 +3614,6 @@
|
|||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
||||||
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
||||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
|
||||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
|
||||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
|
||||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
|
||||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
|
||||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
|
||||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
"resourceLauncherViewNamePlaceholder": "View name",
|
||||||
|
|||||||
@@ -18,17 +18,7 @@ export function createPoolConfig(
|
|||||||
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
|
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
|
||||||
// Allow connections to be released and recreated more aggressively
|
// Allow connections to be released and recreated more aggressively
|
||||||
// to avoid stale connections building up
|
// to avoid stale connections building up
|
||||||
allowExitOnIdle: false,
|
allowExitOnIdle: false
|
||||||
// Disable JIT compilation for this connection. Our hot-path queries
|
|
||||||
// (e.g. resource-by-domain lookups) join many tables but only ever
|
|
||||||
// return a handful of rows. When planner row estimates drift (e.g.
|
|
||||||
// due to autovacuum lag under write-heavy load), Postgres decides
|
|
||||||
// these plans are expensive enough to JIT-compile, which can add
|
|
||||||
// multiple seconds of pure compilation overhead per query and
|
|
||||||
// saturate the connection pool. JIT never pays off for these
|
|
||||||
// short-lived OLTP queries, so it's disabled outright rather than
|
|
||||||
// relying on statistics staying fresh.
|
|
||||||
options: "-c jit=off"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const domains = pgTable("domains", {
|
|||||||
customCertResolver: varchar("customCertResolver"),
|
customCertResolver: varchar("customCertResolver"),
|
||||||
preferWildcardCert: boolean("preferWildcardCert"),
|
preferWildcardCert: boolean("preferWildcardCert"),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage"),
|
||||||
checkedAt: integer("checkedAt")
|
lastCheckedAt: integer("lastCheckedAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = pgTable("dnsRecords", {
|
export const dnsRecords = pgTable("dnsRecords", {
|
||||||
@@ -232,7 +232,6 @@ export const launcherViews = pgTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
isDefault: boolean("isDefault").notNull().default(false),
|
|
||||||
createdAt: varchar("createdAt").notNull(),
|
createdAt: varchar("createdAt").notNull(),
|
||||||
updatedAt: varchar("updatedAt").notNull()
|
updatedAt: varchar("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const domains = sqliteTable("domains", {
|
|||||||
customCertResolver: text("customCertResolver"),
|
customCertResolver: text("customCertResolver"),
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage"),
|
||||||
checkedAt: integer("checkedAt")
|
lastCheckedAt: integer("lastCheckedAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -233,9 +233,6 @@ export const launcherViews = sqliteTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
isDefault: integer("isDefault", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: text("createdAt").notNull(),
|
createdAt: text("createdAt").notNull(),
|
||||||
updatedAt: text("updatedAt").notNull()
|
updatedAt: text("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
|
||||||
* serialization failures). 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.
|
|
||||||
*/
|
|
||||||
export function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || error.cause?.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string,
|
|
||||||
maxRetries: number = MAX_RETRIES,
|
|
||||||
baseDelayMs: number = BASE_DELAY_MS
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < maxRetries) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +1,30 @@
|
|||||||
import { db, exitNodes, Transaction } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
/**
|
export async function getNextAvailableSubnet(): Promise<string> {
|
||||||
* Reserves the next available exit node subnet.
|
// Get all existing subnets from routes table
|
||||||
*
|
const existingAddresses = await db
|
||||||
* Exit node subnets must never overlap with one another - regardless of
|
.select({
|
||||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
address: exitNodes.address
|
||||||
* the same org. This acquires a lock that the caller MUST release (via the
|
})
|
||||||
* returned `release`) only after the chosen address has been durably
|
.from(exitNodes);
|
||||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
|
||||||
* concurrent callers can race and pick the same subnet.
|
const addresses = existingAddresses.map((a) => a.address);
|
||||||
*/
|
let subnet = findNextAvailableCidr(
|
||||||
export async function getNextAvailableSubnet(
|
addresses,
|
||||||
trx: Transaction | typeof db = db
|
config.getRawConfig().gerbil.block_size,
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
config.getRawConfig().gerbil.subnet_group
|
||||||
const lockKey = "exit-node-subnet-allocation";
|
);
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
if (!subnet) {
|
||||||
if (!acquired) {
|
throw new Error("No available subnets remaining in space");
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
|
||||||
|
|
||||||
try {
|
// replace the last octet with 1
|
||||||
// Get all existing subnets from routes table
|
subnet =
|
||||||
const existingAddresses = await trx
|
subnet.split(".").slice(0, 3).join(".") +
|
||||||
.select({
|
".1" +
|
||||||
address: exitNodes.address
|
"/" +
|
||||||
})
|
subnet.split("/")[1];
|
||||||
.from(exitNodes);
|
return subnet;
|
||||||
|
|
||||||
const addresses = existingAddresses.map((a) => a.address);
|
|
||||||
let subnet = findNextAvailableCidr(
|
|
||||||
addresses,
|
|
||||||
config.getRawConfig().gerbil.block_size,
|
|
||||||
config.getRawConfig().gerbil.subnet_group
|
|
||||||
);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace the last octet with 1
|
|
||||||
subnet =
|
|
||||||
subnet.split(".").slice(0, 3).join(".") +
|
|
||||||
".1" +
|
|
||||||
"/" +
|
|
||||||
subnet.split("/")[1];
|
|
||||||
return { value: subnet, release };
|
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
||||||
import { withRetry, isTransientError } from "@server/lib/dbRetry";
|
|
||||||
import {
|
import {
|
||||||
checkOrgRebuildRateLimit,
|
checkOrgRebuildRateLimit,
|
||||||
decrementOrgRebuildCount,
|
decrementOrgRebuildCount,
|
||||||
@@ -286,20 +285,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
) {
|
) {
|
||||||
await incrementOrgRebuildCount(siteResource.orgId);
|
await incrementOrgRebuildCount(siteResource.orgId);
|
||||||
try {
|
try {
|
||||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
return await lockManager.withLock(
|
||||||
// actual state each time), so on a transient DB error it's safe to
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
// retry the entire thing rather than just the failed query.
|
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
|
||||||
return await withRetry(
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
() =>
|
|
||||||
lockManager.withLock(
|
|
||||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
|
||||||
() =>
|
|
||||||
rebuildClientAssociationsFromSiteResourceImpl(
|
|
||||||
siteResource
|
|
||||||
),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
),
|
|
||||||
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
|
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -315,17 +304,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
});
|
});
|
||||||
return { mergedAllClients: [] };
|
return { mergedAllClients: [] };
|
||||||
}
|
}
|
||||||
if (isTransientError(err)) {
|
|
||||||
logger.warn(
|
|
||||||
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "site-resource",
|
|
||||||
id: siteResource.siteResourceId
|
|
||||||
});
|
|
||||||
return { mergedAllClients: [] };
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(siteResource.orgId);
|
await decrementOrgRebuildCount(siteResource.orgId);
|
||||||
@@ -485,7 +463,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
await trx
|
await trx
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
.insert(clientSiteResourcesAssociationsCache)
|
||||||
.values(clientSiteResourcesToInsert)
|
.values(clientSiteResourcesToInsert)
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
.returning();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||||
@@ -533,148 +510,121 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
for (const site of sitesToProcess) {
|
for (const site of sitesToProcess) {
|
||||||
const siteId = site.siteId;
|
const siteId = site.siteId;
|
||||||
|
|
||||||
try {
|
logger.debug(
|
||||||
logger.debug(
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const existingClientSites = await trx
|
const existingClientSites = await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clientSitesAssociationsCache.clientId
|
clientId: clientSitesAssociationsCache.clientId
|
||||||
})
|
})
|
||||||
.from(clientSitesAssociationsCache)
|
.from(clientSitesAssociationsCache)
|
||||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||||
|
|
||||||
const existingClientSiteIds = existingClientSites.map(
|
const existingClientSiteIds = existingClientSites.map(
|
||||||
(row) => row.clientId
|
(row) => row.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get full client details for existing clients (needed for sending delete messages)
|
// Get full client details for existing clients (needed for sending delete messages)
|
||||||
const existingClients =
|
const existingClients =
|
||||||
existingClientSiteIds.length > 0
|
existingClientSiteIds.length > 0
|
||||||
? await trx
|
? await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
pubKey: clients.pubKey,
|
pubKey: clients.pubKey,
|
||||||
subnet: clients.subnet
|
subnet: clients.subnet
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(
|
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||||
inArray(clients.clientId, existingClientSiteIds)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const otherResourceClientIds =
|
|
||||||
clientsFromOtherResourcesBySite.get(siteId) ??
|
|
||||||
new Set<number>();
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected clients from this resource are site-scoped: if this site is
|
|
||||||
// no longer attached to the resource, the expected set is empty.
|
|
||||||
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
|
|
||||||
? mergedAllClientIds
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Note: we deliberately do NOT exclude clients covered by another
|
const otherResourceClientIds =
|
||||||
// site resource here (unlike clientSitesToRemove below). Doing so
|
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||||
// previously caused a permanent gap: if resource A saw resource B's
|
|
||||||
// cache row and skipped adding (assuming B would maintain it), and
|
|
||||||
// B's own rebuild made the same assumption about A, the site-level
|
|
||||||
// row could end up never inserted by anyone even though both
|
|
||||||
// resources' client associations were otherwise correct.
|
|
||||||
// onConflictDoNothing makes a redundant insert harmless, so there's
|
|
||||||
// no correctness reason to skip here.
|
|
||||||
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
|
||||||
(clientId) => !existingClientSiteIds.includes(clientId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
logger.debug(
|
||||||
clientId,
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||||
siteId
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
|
// Expected clients from this resource are site-scoped: if this site is
|
||||||
|
// no longer attached to the resource, the expected set is empty.
|
||||||
|
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
|
||||||
|
? mergedAllClientIds
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
||||||
|
(clientId) =>
|
||||||
|
!existingClientSiteIds.includes(clientId) &&
|
||||||
|
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteId
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientSitesToInsert.length > 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||||
);
|
);
|
||||||
|
await trx
|
||||||
if (clientSitesToInsert.length > 0) {
|
.insert(clientSitesAssociationsCache)
|
||||||
logger.debug(
|
.values(clientSitesToInsert)
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
.returning();
|
||||||
);
|
|
||||||
await trx
|
|
||||||
.insert(clientSitesAssociationsCache)
|
|
||||||
.values(clientSitesToInsert)
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
|
||||||
logger.debug(
|
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now remove any client-site associations that should no longer exist
|
|
||||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
|
||||||
(clientId) =>
|
|
||||||
!expectedClientIdsForSite.includes(clientId) &&
|
|
||||||
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
if (clientSitesToRemove.length > 0) {
|
logger.debug(
|
||||||
logger.debug(
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
|
||||||
);
|
|
||||||
await trx
|
|
||||||
.delete(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
|
||||||
inArray(
|
|
||||||
clientSitesAssociationsCache.clientId,
|
|
||||||
clientSitesToRemove
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
|
||||||
await handleMessagesForSiteClients(
|
|
||||||
site,
|
|
||||||
siteId,
|
|
||||||
mergedAllClients,
|
|
||||||
existingClients,
|
|
||||||
clientSitesToAdd,
|
|
||||||
clientSitesToRemove,
|
|
||||||
trx
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
// Don't let a failure on one site abort processing of every
|
|
||||||
// other site queued after it in this run. Since we're not
|
|
||||||
// re-throwing, the outer wrapper's retry/requeue logic never
|
|
||||||
// sees this failure, so explicitly queue this resource for a
|
|
||||||
// follow-up pass to reconcile whatever this site didn't get to.
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "site-resource",
|
|
||||||
id: siteResource.siteResourceId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now remove any client-site associations that should no longer exist
|
||||||
|
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||||
|
(clientId) =>
|
||||||
|
!expectedClientIdsForSite.includes(clientId) &&
|
||||||
|
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientSitesToRemove.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||||
|
);
|
||||||
|
await trx
|
||||||
|
.delete(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||||
|
inArray(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clientSitesToRemove
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||||
|
await handleMessagesForSiteClients(
|
||||||
|
site,
|
||||||
|
siteId,
|
||||||
|
mergedAllClients,
|
||||||
|
existingClients,
|
||||||
|
clientSitesToAdd,
|
||||||
|
clientSitesToRemove,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle subnet proxy target updates for the resource associations
|
// Handle subnet proxy target updates for the resource associations
|
||||||
@@ -707,7 +657,7 @@ async function handleMessagesForSiteClients(
|
|||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -721,14 +671,14 @@ async function handleMessagesForSiteClients(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.publicKey) {
|
if (!site.publicKey) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -742,7 +692,7 @@ async function handleMessagesForSiteClients(
|
|||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Newt not found for site ${siteId} so cannot add peers to clients`
|
`Newt not found for site ${siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -967,7 +917,7 @@ export async function updateClientSiteDestinations(
|
|||||||
|
|
||||||
for (const site of sitesData) {
|
for (const site of sitesData) {
|
||||||
if (!site.sites.subnet) {
|
if (!site.sites.subnet) {
|
||||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1706,17 +1656,10 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
await incrementOrgRebuildCount(client.orgId);
|
await incrementOrgRebuildCount(client.orgId);
|
||||||
try {
|
try {
|
||||||
const trx = primaryDb;
|
const trx = primaryDb;
|
||||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
return await lockManager.withLock(
|
||||||
// actual state each time), so on a transient DB error it's safe to
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
// retry the entire thing rather than just the failed query.
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
return await withRetry(
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
() =>
|
|
||||||
lockManager.withLock(
|
|
||||||
`rebuild-client-associations:client:${client.clientId}`,
|
|
||||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
|
||||||
),
|
|
||||||
`rebuildClientAssociationsFromClient:${client.clientId}`
|
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -1732,17 +1675,6 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTransientError(err)) {
|
|
||||||
logger.warn(
|
|
||||||
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "client",
|
|
||||||
id: client.clientId
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(client.orgId);
|
await decrementOrgRebuildCount(client.orgId);
|
||||||
@@ -1894,15 +1826,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new associations
|
// Insert new associations
|
||||||
if (resourcesToAdd.length > 0) {
|
if (resourcesToAdd.length > 0) {
|
||||||
await trx
|
await trx.insert(clientSiteResourcesAssociationsCache).values(
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
resourcesToAdd.map((siteResourceId) => ({
|
||||||
.values(
|
clientId: client.clientId,
|
||||||
resourcesToAdd.map((siteResourceId) => ({
|
siteResourceId
|
||||||
clientId: client.clientId,
|
}))
|
||||||
siteResourceId
|
);
|
||||||
}))
|
|
||||||
)
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old associations
|
// Remove old associations
|
||||||
@@ -1940,15 +1869,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new site associations
|
// Insert new site associations
|
||||||
if (sitesToAdd.length > 0) {
|
if (sitesToAdd.length > 0) {
|
||||||
await trx
|
await trx.insert(clientSitesAssociationsCache).values(
|
||||||
.insert(clientSitesAssociationsCache)
|
sitesToAdd.map((siteId) => ({
|
||||||
.values(
|
clientId: client.clientId,
|
||||||
sitesToAdd.map((siteId) => ({
|
siteId
|
||||||
clientId: client.clientId,
|
}))
|
||||||
siteId
|
);
|
||||||
}))
|
|
||||||
)
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old site associations
|
// Remove old site associations
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { isTransientError } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
// Number of times this job has already been re-queued after a transient
|
|
||||||
// failure. Absent/0 means it has not failed yet.
|
|
||||||
attempt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -28,10 +24,6 @@ export interface RebuildQueueManager {
|
|||||||
// retried shortly after against fresh DB state.
|
// retried shortly after against fresh DB state.
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
const BATCH_SIZE = 5;
|
const BATCH_SIZE = 5;
|
||||||
// A job that fails with a transient DB error gets re-queued with backoff
|
|
||||||
// instead of being dropped, up to this many times.
|
|
||||||
const MAX_JOB_ATTEMPTS = 5;
|
|
||||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
|
||||||
|
|
||||||
function dedupeKey(job: RebuildJob): string {
|
function dedupeKey(job: RebuildJob): string {
|
||||||
return `${job.type}:${job.id}`;
|
return `${job.type}:${job.id}`;
|
||||||
@@ -114,29 +106,10 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const attempt = (job.attempt ?? 0) + 1;
|
logger.error(
|
||||||
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
const delay =
|
err
|
||||||
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
);
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.enqueue({ ...job, attempt }).catch(
|
|
||||||
(enqueueErr) =>
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
|
||||||
enqueueErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
|
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
|
||||||
|
|
||||||
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
||||||
// server process dies while holding a rebuild, this ensures the counter key
|
// server process dies while holding a rebuild, this ensures the counter key
|
||||||
|
|||||||
@@ -14,16 +14,12 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import { lockManager } from "#private/lib/lock";
|
import { lockManager } from "#private/lib/lock";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { isTransientError } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
// Number of times this job has already been re-queued after a transient
|
|
||||||
// failure. Absent/0 means it has not failed yet.
|
|
||||||
attempt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -47,11 +43,6 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
// A job that fails with a transient DB error gets re-queued with backoff
|
|
||||||
// instead of being dropped, up to this many times.
|
|
||||||
const MAX_JOB_ATTEMPTS = 5;
|
|
||||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
|
||||||
|
|
||||||
class RedisRebuildQueue {
|
class RedisRebuildQueue {
|
||||||
private processingStarted = false;
|
private processingStarted = false;
|
||||||
|
|
||||||
@@ -189,33 +180,10 @@ class RedisRebuildQueue {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const attempt = (job.attempt ?? 0) + 1;
|
logger.error(
|
||||||
if (
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
isTransientError(err) &&
|
err
|
||||||
attempt <= MAX_JOB_ATTEMPTS
|
);
|
||||||
) {
|
|
||||||
const delay =
|
|
||||||
JOB_RETRY_BASE_DELAY_MS *
|
|
||||||
Math.pow(2, attempt - 1);
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.enqueue({ ...job, attempt }).catch(
|
|
||||||
(enqueueErr) =>
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
|
||||||
enqueueErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -894,19 +894,6 @@ class RegionalRedisManager {
|
|||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
|
|
||||||
// (primary). The replica (redis-1) is only reachable through the
|
|
||||||
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
|
|
||||||
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
|
|
||||||
// if the configured host doesn't match that pattern (e.g. local dev),
|
|
||||||
// in which case callers should fall back to the primary for reads.
|
|
||||||
private getReplicaHost(primaryHost: string): string | null {
|
|
||||||
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const namespace = match[1];
|
|
||||||
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeClients(): void {
|
private initializeClients(): void {
|
||||||
const cfg = this.getConfig();
|
const cfg = this.getConfig();
|
||||||
const baseOpts = {
|
const baseOpts = {
|
||||||
@@ -920,42 +907,35 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.writeClient = new Redis(baseOpts);
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
const replicaHost = this.getReplicaHost(cfg.host!);
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
this.readClient = replicaHost
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
? new Redis({ ...baseOpts, host: replicaHost })
|
this.readClient = this.writeClient;
|
||||||
: this.writeClient;
|
|
||||||
|
|
||||||
this.writeClient.on("ready", () => {
|
this.writeClient.on("ready", () => {
|
||||||
logger.info("Regional Redis write client ready");
|
logger.info("Regional Redis client ready");
|
||||||
this.isHealthy = true;
|
this.isHealthy = true;
|
||||||
});
|
});
|
||||||
this.writeClient.on("error", (err) => {
|
this.writeClient.on("error", (err) => {
|
||||||
logger.error("Regional Redis write client error:", err);
|
logger.error("Regional Redis client error:", err);
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
this.writeClient.on("reconnecting", () => {
|
this.writeClient.on("reconnecting", () => {
|
||||||
logger.info("Regional Redis write client reconnecting...");
|
logger.info("Regional Redis client reconnecting...");
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.readClient !== this.writeClient) {
|
logger.info("Regional Redis client initialized");
|
||||||
this.readClient.on("ready", () => {
|
|
||||||
logger.info("Regional Redis read client ready");
|
|
||||||
});
|
|
||||||
this.readClient.on("error", (err) => {
|
|
||||||
logger.error("Regional Redis read client error:", err);
|
|
||||||
});
|
|
||||||
this.readClient.on("reconnecting", () => {
|
|
||||||
logger.info("Regional Redis read client reconnecting...");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
replicaHost
|
|
||||||
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
|
|
||||||
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to initialize regional Redis client:", error);
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
@@ -1061,14 +1041,11 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (this.readClient && this.readClient !== this.writeClient) {
|
|
||||||
await this.readClient.quit();
|
|
||||||
}
|
|
||||||
this.readClient = null;
|
|
||||||
if (this.writeClient) {
|
if (this.writeClient) {
|
||||||
await this.writeClient.quit();
|
await this.writeClient.quit();
|
||||||
this.writeClient = null;
|
this.writeClient = null;
|
||||||
}
|
}
|
||||||
|
this.readClient = null;
|
||||||
logger.info("Regional Redis client disconnected");
|
logger.info("Regional Redis client disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error disconnecting regional Redis client:", error);
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -29,41 +29,37 @@ export async function createExitNode(
|
|||||||
.where(eq(exitNodes.publicKey, publicKey));
|
.where(eq(exitNodes.publicKey, publicKey));
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const { value: address, release } = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
try {
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// const listenPort = await getNextAvailablePort();
|
||||||
// const listenPort = await getNextAvailablePort();
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
let subEndpoint = "";
|
||||||
let subEndpoint = "";
|
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodeName =
|
|
||||||
config.getRawConfig().gerbil.exit_node_name ||
|
|
||||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
|
||||||
|
|
||||||
// create a new exit node
|
|
||||||
[exitNode] = await db
|
|
||||||
.insert(exitNodes)
|
|
||||||
.values({
|
|
||||||
publicKey,
|
|
||||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
|
||||||
address,
|
|
||||||
listenPort,
|
|
||||||
online: true,
|
|
||||||
reachableAt,
|
|
||||||
name: exitNodeName
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exitNodeName =
|
||||||
|
config.getRawConfig().gerbil.exit_node_name ||
|
||||||
|
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||||
|
|
||||||
|
// create a new exit node
|
||||||
|
[exitNode] = await db
|
||||||
|
.insert(exitNodes)
|
||||||
|
.values({
|
||||||
|
publicKey,
|
||||||
|
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||||
|
address,
|
||||||
|
listenPort,
|
||||||
|
online: true,
|
||||||
|
reachableAt,
|
||||||
|
name: exitNodeName
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// update the reachable at
|
// update the reachable at
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export async function createRemoteExitNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secretHash = await hashPassword(secret);
|
const secretHash = await hashPassword(secret);
|
||||||
|
// const address = await getNextAvailableSubnet();
|
||||||
|
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
|
||||||
|
|
||||||
const [existingRemoteExitNode] = await db
|
const [existingRemoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -189,106 +191,89 @@ export async function createRemoteExitNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this remote exit node isn't already backing an exit node in
|
await db.transaction(async (trx) => {
|
||||||
// another org, we're about to create a brand new one. Reserve a
|
if (!existingExitNode) {
|
||||||
// subnet for it up front so the allocation lock is held across the
|
const [res] = await trx
|
||||||
// whole insert - this guarantees exit node subnets never overlap,
|
.insert(exitNodes)
|
||||||
// even under concurrent creation, which matters for HA setups.
|
.values({
|
||||||
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
name: remoteExitNodeId,
|
||||||
let newExitNodeAddress: string | null = null;
|
address,
|
||||||
if (!existingExitNode) {
|
endpoint: "",
|
||||||
const { value, release } = await getNextAvailableSubnet();
|
publicKey: "",
|
||||||
newExitNodeAddress = value;
|
listenPort: 0,
|
||||||
releaseSubnetLock = release;
|
online: false,
|
||||||
}
|
type: "remoteExitNode"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
existingExitNode = res;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (!existingRemoteExitNode) {
|
||||||
await db.transaction(async (trx) => {
|
await trx.insert(remoteExitNodes).values({
|
||||||
if (!existingExitNode) {
|
remoteExitNodeId: remoteExitNodeId,
|
||||||
const [res] = await trx
|
secretHash,
|
||||||
.insert(exitNodes)
|
dateCreated: moment().toISOString(),
|
||||||
.values({
|
exitNodeId: existingExitNode.exitNodeId
|
||||||
name: remoteExitNodeId,
|
});
|
||||||
address: newExitNodeAddress!,
|
} else {
|
||||||
endpoint: "",
|
// update the existing remote exit node
|
||||||
publicKey: "",
|
await trx
|
||||||
listenPort: 0,
|
.update(remoteExitNodes)
|
||||||
online: false,
|
.set({
|
||||||
type: "remoteExitNode"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
existingExitNode = res;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingRemoteExitNode) {
|
|
||||||
await trx.insert(remoteExitNodes).values({
|
|
||||||
remoteExitNodeId: remoteExitNodeId,
|
|
||||||
secretHash,
|
|
||||||
dateCreated: moment().toISOString(),
|
|
||||||
exitNodeId: existingExitNode.exitNodeId
|
exitNodeId: existingExitNode.exitNodeId
|
||||||
});
|
})
|
||||||
} else {
|
.where(
|
||||||
// update the existing remote exit node
|
eq(
|
||||||
await trx
|
remoteExitNodes.remoteExitNodeId,
|
||||||
.update(remoteExitNodes)
|
existingRemoteExitNode.remoteExitNodeId
|
||||||
.set({
|
)
|
||||||
exitNodeId: existingExitNode.exitNodeId
|
);
|
||||||
})
|
}
|
||||||
.where(
|
|
||||||
|
if (!existingExitNodeOrg) {
|
||||||
|
await trx.insert(exitNodeOrgs).values({
|
||||||
|
exitNodeId: existingExitNode.exitNodeId,
|
||||||
|
orgId: orgId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
const otherBillingOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgs.billingOrgId, org.billingOrgId),
|
||||||
|
ne(orgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||||
|
|
||||||
|
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||||
|
.select()
|
||||||
|
.from(exitNodeOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
eq(
|
eq(
|
||||||
remoteExitNodes.remoteExitNodeId,
|
exitNodeOrgs.exitNodeId,
|
||||||
existingRemoteExitNode.remoteExitNodeId
|
existingExitNode.exitNodeId
|
||||||
)
|
),
|
||||||
);
|
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||||
|
await usageService.add(
|
||||||
|
orgId,
|
||||||
|
LimitId.REMOTE_EXIT_NODES,
|
||||||
|
1,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!existingExitNodeOrg) {
|
});
|
||||||
await trx.insert(exitNodeOrgs).values({
|
|
||||||
exitNodeId: existingExitNode.exitNodeId,
|
|
||||||
orgId: orgId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
|
||||||
if (org.billingOrgId) {
|
|
||||||
const otherBillingOrgs = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(orgs.billingOrgId, org.billingOrgId),
|
|
||||||
ne(orgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
|
||||||
|
|
||||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
|
||||||
.select()
|
|
||||||
.from(exitNodeOrgs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
exitNodeOrgs.exitNodeId,
|
|
||||||
existingExitNode.exitNodeId
|
|
||||||
),
|
|
||||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
|
||||||
await usageService.add(
|
|
||||||
orgId,
|
|
||||||
LimitId.REMOTE_EXIT_NODES,
|
|
||||||
1,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await releaseSubnetLock?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
|
|||||||
source: "API"
|
source: "API"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -327,14 +327,6 @@ authenticated.get(
|
|||||||
siteResource.listAllSiteResourcesByOrg
|
siteResource.listAllSiteResourcesByOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/site-resource/:siteResourceId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifySiteResourceAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
|
||||||
siteResource.getSiteResource
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
@@ -478,12 +470,6 @@ authenticated.get(
|
|||||||
launcher.listLauncherGroups
|
launcher.listLauncherGroups
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/scale",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherScale
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/launcher/resources",
|
"/org/:orgId/launcher/resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -508,12 +494,6 @@ authenticated.get(
|
|||||||
launcher.listLauncherViews
|
launcher.listLauncherViews
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/launcher/invalidate-cache",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.invalidateLauncherCache
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/launcher/views",
|
"/org/:orgId/launcher/views",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -526,18 +506,6 @@ authenticated.put(
|
|||||||
launcher.updateLauncherView
|
launcher.updateLauncherView
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/launcher/default-view",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.upsertLauncherDefaultView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/launcher/default-view",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.deleteLauncherDefaultView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/launcher/views/:viewId",
|
"/org/:orgId/launcher/views/:viewId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -13,41 +13,37 @@ export async function createExitNode(
|
|||||||
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const { value: address, release } = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
try {
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// const listenPort = await getNextAvailablePort();
|
||||||
// const listenPort = await getNextAvailablePort();
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
let subEndpoint = "";
|
||||||
let subEndpoint = "";
|
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodeName =
|
|
||||||
config.getRawConfig().gerbil.exit_node_name ||
|
|
||||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
|
||||||
|
|
||||||
// create a new exit node
|
|
||||||
[exitNode] = await db
|
|
||||||
.insert(exitNodes)
|
|
||||||
.values({
|
|
||||||
publicKey,
|
|
||||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
|
||||||
address,
|
|
||||||
online: true,
|
|
||||||
listenPort,
|
|
||||||
reachableAt,
|
|
||||||
name: exitNodeName
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exitNodeName =
|
||||||
|
config.getRawConfig().gerbil.exit_node_name ||
|
||||||
|
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||||
|
|
||||||
|
// create a new exit node
|
||||||
|
[exitNode] = await db
|
||||||
|
.insert(exitNodes)
|
||||||
|
.values({
|
||||||
|
publicKey,
|
||||||
|
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||||
|
address,
|
||||||
|
online: true,
|
||||||
|
listenPort,
|
||||||
|
reachableAt,
|
||||||
|
name: exitNodeName
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// update the existing exit node
|
// update the existing exit node
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ export async function createLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
isOrgWide: created.userId == null,
|
isOrgWide: created.userId == null
|
||||||
isDefault: created.isDefault
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import {
|
|
||||||
deleteAllDefaultViewOverrides,
|
|
||||||
deleteDefaultViewOverride
|
|
||||||
} from "./launcherDefaultView";
|
|
||||||
|
|
||||||
const deleteLauncherDefaultViewBodySchema = z.strictObject({
|
|
||||||
orgWide: z.boolean().optional().default(false),
|
|
||||||
all: z.boolean().optional().default(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function deleteLauncherDefaultView(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.data.all) {
|
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
if (!canManageOrgWide) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteAllDefaultViewOverrides(orgId, userId);
|
|
||||||
} else if (parsed.data.orgWide) {
|
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
if (!canManageOrgWide) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteDefaultViewOverride({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
orgWide: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await deleteDefaultViewOverride({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
orgWide: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher default view reset successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error resetting launcher default view:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,15 +46,6 @@ export async function deleteLauncherView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.isDefault) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"The default view cannot be deleted from here"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
const isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { listLauncherGroups } from "./listLauncherGroups";
|
export { listLauncherGroups } from "./listLauncherGroups";
|
||||||
export { listLauncherScale } from "./listLauncherScale";
|
|
||||||
export { listLauncherResources } from "./listLauncherResources";
|
export { listLauncherResources } from "./listLauncherResources";
|
||||||
export { listLauncherSites } from "./listLauncherSites";
|
export { listLauncherSites } from "./listLauncherSites";
|
||||||
export { listLauncherLabels } from "./listLauncherLabels";
|
export { listLauncherLabels } from "./listLauncherLabels";
|
||||||
@@ -8,6 +7,3 @@ export { listLauncherViews } from "./listLauncherViews";
|
|||||||
export { createLauncherView } from "./createLauncherView";
|
export { createLauncherView } from "./createLauncherView";
|
||||||
export { updateLauncherView } from "./updateLauncherView";
|
export { updateLauncherView } from "./updateLauncherView";
|
||||||
export { deleteLauncherView } from "./deleteLauncherView";
|
export { deleteLauncherView } from "./deleteLauncherView";
|
||||||
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
|
|
||||||
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
|
|
||||||
export { invalidateLauncherCache } from "./invalidateLauncherCache";
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
|
|
||||||
async function invalidateLauncherCacheForUser(
|
|
||||||
orgId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const prefixes = [
|
|
||||||
`launcherAccessibleIds:${orgId}:${userId}:`,
|
|
||||||
`launcher:groups:${orgId}:${userId}:`,
|
|
||||||
`launcher:results:${orgId}:${userId}:`,
|
|
||||||
`launcher:scale:counts:${orgId}:${userId}:`
|
|
||||||
];
|
|
||||||
|
|
||||||
const keys = (
|
|
||||||
await Promise.all(
|
|
||||||
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
|
|
||||||
)
|
|
||||||
).flat();
|
|
||||||
|
|
||||||
if (keys.length > 0) {
|
|
||||||
await cache.del(keys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invalidateLauncherCache(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await invalidateLauncherCacheForUser(orgId, userId);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher cache invalidated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error invalidating launcher cache:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { db, launcherViews } from "@server/db";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import moment from "moment";
|
|
||||||
import {
|
|
||||||
launcherViewConfigSchema,
|
|
||||||
type LauncherDefaultViewOverrides,
|
|
||||||
type LauncherViewConfig,
|
|
||||||
type LauncherViewRecord
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export function mapViewRow(
|
|
||||||
row: typeof launcherViews.$inferSelect
|
|
||||||
): LauncherViewRecord {
|
|
||||||
return {
|
|
||||||
viewId: row.viewId,
|
|
||||||
orgId: row.orgId,
|
|
||||||
userId: row.userId,
|
|
||||||
name: row.name,
|
|
||||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
isOrgWide: row.userId == null,
|
|
||||||
isDefault: row.isDefault
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractDefaultViewOverrides(
|
|
||||||
rows: Array<typeof launcherViews.$inferSelect>
|
|
||||||
): LauncherDefaultViewOverrides {
|
|
||||||
const overrideRows = rows.filter((row) => row.isDefault);
|
|
||||||
|
|
||||||
const personalRow = overrideRows.find((row) => row.userId !== null);
|
|
||||||
const orgWideRow = overrideRows.find((row) => row.userId === null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
personal: personalRow ? mapViewRow(personalRow) : null,
|
|
||||||
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listVisibleLauncherViews(
|
|
||||||
rows: Array<typeof launcherViews.$inferSelect>
|
|
||||||
): LauncherViewRecord[] {
|
|
||||||
return rows.filter((row) => !row.isDefault).map(mapViewRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findDefaultViewOverride(
|
|
||||||
orgId: string,
|
|
||||||
orgWide: boolean,
|
|
||||||
userId: string
|
|
||||||
) {
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(launcherViews)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(launcherViews.orgId, orgId),
|
|
||||||
eq(launcherViews.isDefault, true),
|
|
||||||
orgWide
|
|
||||||
? isNull(launcherViews.userId)
|
|
||||||
: eq(launcherViews.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return existing ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertDefaultViewOverride({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
orgWide,
|
|
||||||
config
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
userId: string;
|
|
||||||
orgWide: boolean;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
}) {
|
|
||||||
const now = moment().toISOString();
|
|
||||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(launcherViews)
|
|
||||||
.set({
|
|
||||||
config: JSON.stringify(config),
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
.where(eq(launcherViews.viewId, existing.viewId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return mapViewRow(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [created] = await db
|
|
||||||
.insert(launcherViews)
|
|
||||||
.values({
|
|
||||||
orgId,
|
|
||||||
userId: orgWide ? null : userId,
|
|
||||||
name: "",
|
|
||||||
isDefault: true,
|
|
||||||
config: JSON.stringify(config),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return mapViewRow(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDefaultViewOverride({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
orgWide
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
userId: string;
|
|
||||||
orgWide: boolean;
|
|
||||||
}) {
|
|
||||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
|
||||||
if (!existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(launcherViews)
|
|
||||||
.where(eq(launcherViews.viewId, existing.viewId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAllDefaultViewOverrides(
|
|
||||||
orgId: string,
|
|
||||||
userId: string
|
|
||||||
) {
|
|
||||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
|
|
||||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import {
|
import {
|
||||||
exitNodes,
|
exitNodes,
|
||||||
@@ -32,15 +31,13 @@ import {
|
|||||||
isNull,
|
isNull,
|
||||||
like,
|
like,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql
|
||||||
type SQL
|
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
formatPublicResourceAccess,
|
formatPublicResourceAccess,
|
||||||
formatSiteResourceAccess
|
formatSiteResourceAccess
|
||||||
} from "./formatLauncherAccess";
|
} from "./formatLauncherAccess";
|
||||||
import {
|
import {
|
||||||
LAUNCHER_FLAT_GROUP_KEY,
|
|
||||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||||
type LauncherFilterListQuery,
|
type LauncherFilterListQuery,
|
||||||
@@ -62,68 +59,6 @@ export type AccessibleIds = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
||||||
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
|
|
||||||
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
|
|
||||||
|
|
||||||
type LauncherResourcesCacheEntry = {
|
|
||||||
items: LauncherResource[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LauncherGroupsCacheEntry = {
|
|
||||||
groups: LauncherGroup[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function launcherListQueryHash(
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherListQuery,
|
|
||||||
extra?: Record<string, string>
|
|
||||||
) {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
roles: [...userRoleIds].sort((a, b) => a - b),
|
|
||||||
query: query.query,
|
|
||||||
groupBy: query.groupBy,
|
|
||||||
siteIds: query.siteIds ?? "",
|
|
||||||
labelIds: query.labelIds ?? "",
|
|
||||||
sort_by: query.sort_by,
|
|
||||||
order: query.order,
|
|
||||||
...extra
|
|
||||||
});
|
|
||||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherResourcesQueryHash(
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherListQuery & { groupKey: string }
|
|
||||||
) {
|
|
||||||
return launcherListQueryHash(userRoleIds, query, {
|
|
||||||
groupKey: query.groupKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherGroupsQueryHash(
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherListQuery
|
|
||||||
) {
|
|
||||||
return launcherListQueryHash(userRoleIds, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherResourcesCacheKey(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
queryHash: string
|
|
||||||
) {
|
|
||||||
return `launcher:results:${orgId}:${userId}:${queryHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherGroupsCacheKey(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
queryHash: string
|
|
||||||
) {
|
|
||||||
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherAccessibleIdsCacheKey(
|
function launcherAccessibleIdsCacheKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -259,21 +194,6 @@ function searchPattern(query: string) {
|
|||||||
return `%${query.trim()}%`;
|
return `%${query.trim()}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function combineOrConditions(
|
|
||||||
...conditions: (SQL | undefined)[]
|
|
||||||
): SQL | undefined {
|
|
||||||
const parts = conditions.filter(
|
|
||||||
(condition): condition is SQL => !!condition
|
|
||||||
);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (parts.length === 1) {
|
|
||||||
return parts[0];
|
|
||||||
}
|
|
||||||
return or(...parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchConditionForPublic(
|
function buildSearchConditionForPublic(
|
||||||
query: string,
|
query: string,
|
||||||
labelsFeatureEnabled: boolean
|
labelsFeatureEnabled: boolean
|
||||||
@@ -285,17 +205,7 @@ function buildSearchConditionForPublic(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${resources.name})`, pattern),
|
like(sql`LOWER(${resources.name})`, pattern),
|
||||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
|
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
|
||||||
inArray(
|
|
||||||
resources.resourceId,
|
|
||||||
db
|
|
||||||
.select({ id: resources.resourceId })
|
|
||||||
.from(resources)
|
|
||||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
|
||||||
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
|
||||||
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (labelsFeatureEnabled) {
|
if (labelsFeatureEnabled) {
|
||||||
@@ -328,11 +238,6 @@ function buildSearchConditionForSiteResource(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.destination})`, pattern),
|
like(sql`LOWER(${siteResources.destination})`, pattern),
|
||||||
like(
|
|
||||||
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
|
|
||||||
pattern
|
|
||||||
),
|
|
||||||
like(sql`LOWER(${siteResources.scheme})`, pattern),
|
|
||||||
like(sql`LOWER(${siteResources.alias})`, pattern),
|
like(sql`LOWER(${siteResources.alias})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
||||||
@@ -357,79 +262,6 @@ function buildSearchConditionForSiteResource(
|
|||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filterPublicResourceIdsByTextSearch(
|
|
||||||
orgId: string,
|
|
||||||
resourceIds: number[],
|
|
||||||
query: string,
|
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
): Promise<number[]> {
|
|
||||||
if (!query.trim() || resourceIds.length === 0) {
|
|
||||||
return resourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
|
||||||
buildSearchConditionForPublic(query, labelsFeatureEnabled),
|
|
||||||
buildSiteNameSearchCondition(query)
|
|
||||||
);
|
|
||||||
if (!textMatch) {
|
|
||||||
return resourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.selectDistinct({ resourceId: resources.resourceId })
|
|
||||||
.from(resources)
|
|
||||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(resources.resourceId, resourceIds),
|
|
||||||
eq(resources.orgId, orgId),
|
|
||||||
eq(resources.enabled, true),
|
|
||||||
textMatch
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function filterSiteResourceIdsByTextSearch(
|
|
||||||
orgId: string,
|
|
||||||
siteResourceIds: number[],
|
|
||||||
query: string,
|
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
): Promise<number[]> {
|
|
||||||
if (!query.trim() || siteResourceIds.length === 0) {
|
|
||||||
return siteResourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
|
||||||
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
|
|
||||||
buildSiteNameSearchCondition(query)
|
|
||||||
);
|
|
||||||
if (!textMatch) {
|
|
||||||
return siteResourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteResources)
|
|
||||||
.leftJoin(
|
|
||||||
siteNetworks,
|
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
|
||||||
)
|
|
||||||
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
|
||||||
eq(siteResources.orgId, orgId),
|
|
||||||
eq(siteResources.enabled, true),
|
|
||||||
textMatch
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.siteResourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
async function labelsEnabled(orgId: string): Promise<boolean> {
|
||||||
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
||||||
}
|
}
|
||||||
@@ -756,8 +588,9 @@ async function listSiteGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
groups,
|
groups: groups.slice(offset, offset + query.pageSize),
|
||||||
total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -955,53 +788,26 @@ async function listLabelGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
groups,
|
groups: groups.slice(offset, offset + query.pageSize),
|
||||||
total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLauncherGroupsForUserUncached(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherListQuery
|
|
||||||
): Promise<LauncherGroupsCacheEntry> {
|
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
|
||||||
|
|
||||||
if (query.groupBy === "label") {
|
|
||||||
return listLabelGroups(orgId, accessible, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return listSiteGroups(orgId, accessible, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLauncherGroupsForUser(
|
export async function listLauncherGroupsForUser(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery
|
query: LauncherListQuery
|
||||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||||
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
|
|
||||||
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
|
|
||||||
|
|
||||||
let result = cached;
|
if (query.groupBy === "label") {
|
||||||
if (!result) {
|
return listLabelGroups(orgId, accessible, query);
|
||||||
result = await listLauncherGroupsForUserUncached(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
userRoleIds,
|
|
||||||
query
|
|
||||||
);
|
|
||||||
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
return listSiteGroups(orgId, accessible, query);
|
||||||
return {
|
|
||||||
groups: result.groups.slice(offset, offset + query.pageSize),
|
|
||||||
total: result.total
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapPublicResources(
|
async function mapPublicResources(
|
||||||
@@ -1212,6 +1018,26 @@ function filterResourcesByLabel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterResourcesBySearch(
|
||||||
|
items: LauncherResource[],
|
||||||
|
query: string
|
||||||
|
): LauncherResource[] {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const pattern = query.trim().toLowerCase();
|
||||||
|
return items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase().includes(pattern) ||
|
||||||
|
item.accessDisplay.toLowerCase().includes(pattern) ||
|
||||||
|
item.accessCopyValue.toLowerCase().includes(pattern) ||
|
||||||
|
item.labels.some((label) =>
|
||||||
|
label.name.toLowerCase().includes(pattern)
|
||||||
|
) ||
|
||||||
|
item.site?.name.toLowerCase().includes(pattern)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function sortLauncherResources(
|
function sortLauncherResources(
|
||||||
items: LauncherResource[],
|
items: LauncherResource[],
|
||||||
order: "asc" | "desc"
|
order: "asc" | "desc"
|
||||||
@@ -1224,12 +1050,12 @@ function sortLauncherResources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLauncherResourcesForUserUncached(
|
export async function listLauncherResourcesForUser(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery & { groupKey: string }
|
query: LauncherListQuery & { groupKey: string }
|
||||||
): Promise<LauncherResourcesCacheEntry> {
|
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
|
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
@@ -1302,27 +1128,6 @@ async function listLauncherResourcesForUserUncached(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
|
||||||
|
|
||||||
if (query.query.trim()) {
|
|
||||||
if (filteredResourceIds.length > 0) {
|
|
||||||
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
|
||||||
orgId,
|
|
||||||
filteredResourceIds,
|
|
||||||
query.query,
|
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (filteredSiteResourceIds.length > 0) {
|
|
||||||
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
|
||||||
orgId,
|
|
||||||
filteredSiteResourceIds,
|
|
||||||
query.query,
|
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelMaps = await fetchLabelsForResources(
|
const labelMaps = await fetchLabelsForResources(
|
||||||
orgId,
|
orgId,
|
||||||
filteredResourceIds,
|
filteredResourceIds,
|
||||||
@@ -1354,48 +1159,21 @@ async function listLauncherResourcesForUserUncached(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let items = [...publicItems, ...siteItems];
|
let items = [...publicItems, ...siteItems];
|
||||||
|
items = filterResourcesBySearch(items, query.query);
|
||||||
|
|
||||||
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
if (query.groupBy === "label") {
|
||||||
if (query.groupBy === "label") {
|
items = filterResourcesByLabel(items, query.groupKey);
|
||||||
items = filterResourcesByLabel(items, query.groupKey);
|
} else if (query.groupBy === "site") {
|
||||||
} else if (query.groupBy === "site") {
|
items = filterResourcesBySite(items, query.groupKey);
|
||||||
items = filterResourcesBySite(items, query.groupKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items = sortLauncherResources(items, query.order);
|
items = sortLauncherResources(items, query.order);
|
||||||
|
|
||||||
return {
|
const total = items.length;
|
||||||
items,
|
|
||||||
total: items.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLauncherResourcesForUser(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherListQuery & { groupKey: string }
|
|
||||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
|
||||||
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
|
|
||||||
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
|
|
||||||
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
|
|
||||||
|
|
||||||
let result = cached;
|
|
||||||
if (!result) {
|
|
||||||
result = await listLauncherResourcesForUserUncached(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
userRoleIds,
|
|
||||||
query
|
|
||||||
);
|
|
||||||
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
resources: result.items.slice(offset, offset + query.pageSize),
|
resources: items.slice(offset, offset + query.pageSize),
|
||||||
total: result.total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
|
||||||
import {
|
|
||||||
listLauncherGroupsForUser,
|
|
||||||
resolveAccessibleIds
|
|
||||||
} from "./launcherResourceAccess";
|
|
||||||
import {
|
|
||||||
parseIdListParam,
|
|
||||||
type LauncherScaleInfo,
|
|
||||||
type LauncherScaleQuery
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
|
|
||||||
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
|
|
||||||
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
|
|
||||||
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
|
|
||||||
|
|
||||||
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
|
|
||||||
|
|
||||||
type LauncherScaleCountsCacheEntry = {
|
|
||||||
resourceCount: number;
|
|
||||||
siteGroupCount: number;
|
|
||||||
labelGroupCount: number;
|
|
||||||
mode: LauncherScaleInfo["mode"];
|
|
||||||
};
|
|
||||||
|
|
||||||
function launcherScaleCountsCacheKey(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
roleIds: number[]
|
|
||||||
) {
|
|
||||||
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
|
|
||||||
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildScaleCapabilities(
|
|
||||||
counts: LauncherScaleCountsCacheEntry,
|
|
||||||
query: LauncherScaleQuery
|
|
||||||
): LauncherScaleInfo["capabilities"] {
|
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
|
||||||
const labelFilterIds = parseIdListParam(query.labelIds);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowSiteGrouping:
|
|
||||||
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
|
|
||||||
(siteFilterIds.length > 0 &&
|
|
||||||
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
|
||||||
allowLabelGrouping:
|
|
||||||
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
|
|
||||||
(labelFilterIds.length > 0 &&
|
|
||||||
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
|
||||||
requireSearchOrFilter:
|
|
||||||
counts.mode === "compact" &&
|
|
||||||
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLauncherScaleCountsForUser(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
userRoleIds: number[]
|
|
||||||
): Promise<LauncherScaleCountsCacheEntry> {
|
|
||||||
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
|
|
||||||
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
|
||||||
const resourceCount =
|
|
||||||
accessible.resourceIds.length + accessible.siteResourceIds.length;
|
|
||||||
|
|
||||||
const baselineQuery = {
|
|
||||||
query: "",
|
|
||||||
groupBy: "site" as const,
|
|
||||||
siteIds: undefined,
|
|
||||||
labelIds: undefined,
|
|
||||||
sort_by: "name" as const,
|
|
||||||
order: "asc" as const,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
|
|
||||||
await Promise.all([
|
|
||||||
listLauncherGroupsForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
userRoleIds,
|
|
||||||
baselineQuery
|
|
||||||
),
|
|
||||||
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
|
|
||||||
...baselineQuery,
|
|
||||||
groupBy: "label"
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mode =
|
|
||||||
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
|
|
||||||
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
|
|
||||||
? "full"
|
|
||||||
: "compact";
|
|
||||||
|
|
||||||
const result: LauncherScaleCountsCacheEntry = {
|
|
||||||
resourceCount,
|
|
||||||
siteGroupCount,
|
|
||||||
labelGroupCount,
|
|
||||||
mode
|
|
||||||
};
|
|
||||||
|
|
||||||
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLauncherScaleForUser(
|
|
||||||
orgId: string,
|
|
||||||
userId: string,
|
|
||||||
userRoleIds: number[],
|
|
||||||
query: LauncherScaleQuery
|
|
||||||
): Promise<LauncherScaleInfo> {
|
|
||||||
const counts = await getLauncherScaleCountsForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
userRoleIds
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: counts.mode,
|
|
||||||
resourceCount: counts.resourceCount,
|
|
||||||
siteGroupCount: counts.siteGroupCount,
|
|
||||||
labelGroupCount: counts.labelGroupCount,
|
|
||||||
capabilities: buildScaleCapabilities(counts, query)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { getLauncherScaleForUser } from "./launcherScale";
|
|
||||||
import { launcherScaleQuerySchema } from "./types";
|
|
||||||
|
|
||||||
export async function listLauncherScale(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = launcherScaleQuerySchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = await getLauncherScaleForUser(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
req.userOrgRoleIds ?? [],
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: { scale },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher scale retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error listing launcher scale:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,22 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
import { and, eq, isNull, or } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import {
|
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
||||||
extractDefaultViewOverrides,
|
|
||||||
listVisibleLauncherViews
|
function mapViewRow(
|
||||||
} from "./launcherDefaultView";
|
row: typeof launcherViews.$inferSelect
|
||||||
|
): LauncherViewRecord {
|
||||||
|
return {
|
||||||
|
viewId: row.viewId,
|
||||||
|
orgId: row.orgId,
|
||||||
|
userId: row.userId,
|
||||||
|
name: row.name,
|
||||||
|
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
isOrgWide: row.userId == null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listLauncherViews(
|
export async function listLauncherViews(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -39,8 +51,7 @@ export async function listLauncherViews(
|
|||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {
|
data: {
|
||||||
views: listVisibleLauncherViews(rows),
|
views: rows.map(mapViewRow)
|
||||||
defaultViewOverrides: extractDefaultViewOverrides(rows)
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||||
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
|
||||||
|
|
||||||
export const launcherViewConfigSchema = z.object({
|
export const launcherViewConfigSchema = z.object({
|
||||||
groupBy: z.enum(["site", "label", "none"]).default("site"),
|
groupBy: z.enum(["site", "label"]).default("site"),
|
||||||
layout: z.enum(["grid", "list"]).default("grid"),
|
layout: z.enum(["grid", "list"]).default("grid"),
|
||||||
sortBy: z.literal("name").default("name"),
|
sortBy: z.literal("name").default("name"),
|
||||||
order: z.enum(["asc", "desc"]).default("asc"),
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
@@ -89,31 +88,10 @@ export type LauncherViewRecord = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isOrgWide: boolean;
|
isOrgWide: boolean;
|
||||||
isDefault: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherDefaultViewOverrides = {
|
|
||||||
personal: LauncherViewRecord | null;
|
|
||||||
orgWide: LauncherViewRecord | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEffectiveDefaultLauncherConfig(
|
|
||||||
overrides: LauncherDefaultViewOverrides
|
|
||||||
): LauncherViewConfig {
|
|
||||||
if (overrides.personal) {
|
|
||||||
return overrides.personal.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides.orgWide) {
|
|
||||||
return overrides.orgWide.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultLauncherViewConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListLauncherViewsResponse = {
|
export type ListLauncherViewsResponse = {
|
||||||
views: LauncherViewRecord[];
|
views: LauncherViewRecord[];
|
||||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const launcherFilterListQuerySchema = z.strictObject({
|
export const launcherFilterListQuerySchema = z.strictObject({
|
||||||
@@ -122,8 +100,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.catch(20)
|
.catch(500)
|
||||||
.default(20),
|
.default(500),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default("")
|
query: z.string().optional().default("")
|
||||||
});
|
});
|
||||||
@@ -160,7 +138,7 @@ export const launcherListQuerySchema = z.strictObject({
|
|||||||
.default(20),
|
.default(20),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default(""),
|
query: z.string().optional().default(""),
|
||||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
||||||
groupKey: z.string().optional(),
|
groupKey: z.string().optional(),
|
||||||
siteIds: z.string().optional(),
|
siteIds: z.string().optional(),
|
||||||
labelIds: z.string().optional(),
|
labelIds: z.string().optional(),
|
||||||
@@ -185,32 +163,3 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
|||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
| { type: "default" }
|
| { type: "default" }
|
||||||
| { type: "saved"; viewId: number };
|
| { type: "saved"; viewId: number };
|
||||||
|
|
||||||
export type LauncherScaleCapabilities = {
|
|
||||||
allowSiteGrouping: boolean;
|
|
||||||
allowLabelGrouping: boolean;
|
|
||||||
requireSearchOrFilter: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LauncherScaleInfo = {
|
|
||||||
mode: "full" | "compact";
|
|
||||||
resourceCount: number;
|
|
||||||
siteGroupCount: number;
|
|
||||||
labelGroupCount: number;
|
|
||||||
capabilities: LauncherScaleCapabilities;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListLauncherScaleResponse = {
|
|
||||||
scale: LauncherScaleInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const launcherScaleQuerySchema = z.strictObject({
|
|
||||||
query: z.string().optional().default(""),
|
|
||||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
|
||||||
siteIds: z.string().optional(),
|
|
||||||
labelIds: z.string().optional(),
|
|
||||||
sort_by: z.literal("name").optional().default("name"),
|
|
||||||
order: z.enum(["asc", "desc"]).optional().default("asc")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
|
|
||||||
|
|||||||
@@ -66,15 +66,6 @@ export async function updateLauncherView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.isDefault) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Use the default view save endpoint for this view"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
const isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
@@ -144,8 +135,7 @@ export async function updateLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: updated.createdAt,
|
createdAt: updated.createdAt,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
isOrgWide: updated.userId == null,
|
isOrgWide: updated.userId == null
|
||||||
isDefault: updated.isDefault
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { response } from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import { upsertDefaultViewOverride } from "./launcherDefaultView";
|
|
||||||
import { launcherViewConfigSchema } from "./types";
|
|
||||||
|
|
||||||
const upsertLauncherDefaultViewBodySchema = z.strictObject({
|
|
||||||
config: launcherViewConfigSchema,
|
|
||||||
orgWide: z.boolean().optional().default(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function upsertLauncherDefaultView(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const orgId = req.userOrgId;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromZodError(parsed.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.data.orgWide) {
|
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
|
||||||
ActionsEnum.createOrgWideLauncherView,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
if (!canManageOrgWide) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = await upsertDefaultViewOverride({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
orgWide: parsed.data.orgWide,
|
|
||||||
config: parsed.data.config
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: view,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Launcher default view saved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
console.error("Error saving launcher default view:", error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Internal server error"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
clientsRes
|
clientsRes
|
||||||
.filter((client) => {
|
.filter((client) => {
|
||||||
if (!client.clients.pubKey) {
|
if (!client.clients.pubKey) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Client ${client.clients.clientId} has no public key, skipping`
|
`Client ${client.clients.clientId} has no public key, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!client.clients.subnet) {
|
if (!client.clients.subnet) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Client ${client.clients.clientId} has no subnet, skipping`
|
`Client ${client.clients.clientId} has no subnet, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!newt.siteId) {
|
if (!newt.siteId) {
|
||||||
logger.warn("Newt has no site ID!");
|
logger.warn("Newt has no client ID!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +34,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
.where(eq(sites.siteId, newt.siteId!))
|
.where(eq(sites.siteId, newt.siteId!))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fireSiteOfflineAlert(
|
await fireSiteOfflineAlert(
|
||||||
site.orgId,
|
site.orgId,
|
||||||
site.siteId,
|
site.siteId,
|
||||||
@@ -49,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling site disconnecting message", error);
|
logger.error("Error handling disconnecting message", { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { sites, clients, olms } from "@server/db";
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||||
import { withRetry } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping Accumulator
|
* Ping Accumulator
|
||||||
@@ -23,6 +22,8 @@ import { withRetry } from "@server/lib/dbRetry";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
// Map of siteId -> latest ping timestamp (unix seconds)
|
// Map of siteId -> latest ping timestamp (unix seconds)
|
||||||
@@ -265,7 +266,85 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
||||||
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
|
|
||||||
|
/**
|
||||||
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
|
* (deadlocks, 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>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isTransientError(error) && attempt < MAX_RETRIES) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`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));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect transient errors that are safe to retry.
|
||||||
|
*/
|
||||||
|
function isTransientError(error: any): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const message = (error.message || "").toLowerCase();
|
||||||
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
|
// Connection timeout / terminated
|
||||||
|
if (
|
||||||
|
message.includes("connection timeout") ||
|
||||||
|
message.includes("connection terminated") ||
|
||||||
|
message.includes("timeout exceeded when trying to connect") ||
|
||||||
|
causeMessage.includes("connection terminated unexpectedly") ||
|
||||||
|
causeMessage.includes("connection timeout")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||||
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
|
if (
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!site.subnet) {
|
if (!site.subnet) {
|
||||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
logger.warn(`Site ${site.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import logger from "@server/logger";
|
|||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||||
*/
|
*/
|
||||||
export const handleOlmDisconnectingMessage: MessageHandler = async (
|
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
||||||
context
|
|
||||||
) => {
|
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
@@ -31,6 +29,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (
|
|||||||
})
|
})
|
||||||
.where(eq(clients.clientId, olm.clientId));
|
.where(eq(clients.clientId, olm.clientId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling client disconnecting message", error);
|
logger.error("Error handling disconnecting message", { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -616,8 +616,8 @@ export async function listResources(
|
|||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
or(
|
or(
|
||||||
effectiveSso,
|
eq(effectiveSso, true),
|
||||||
effectiveWhitelist,
|
eq(effectiveWhitelist, true),
|
||||||
not(isNull(effectiveHeaderAuthId)),
|
not(isNull(effectiveHeaderAuthId)),
|
||||||
not(isNull(effectivePincodeId)),
|
not(isNull(effectivePincodeId)),
|
||||||
not(isNull(effectivePasswordId))
|
not(isNull(effectivePasswordId))
|
||||||
@@ -629,8 +629,8 @@ export async function listResources(
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
not(effectiveSso),
|
not(eq(effectiveSso, true)),
|
||||||
not(effectiveWhitelist),
|
not(eq(effectiveWhitelist, true)),
|
||||||
isNull(effectiveHeaderAuthId),
|
isNull(effectiveHeaderAuthId),
|
||||||
isNull(effectivePincodeId),
|
isNull(effectivePincodeId),
|
||||||
isNull(effectivePasswordId)
|
isNull(effectivePasswordId)
|
||||||
|
|||||||
@@ -268,11 +268,7 @@ export async function createSite(
|
|||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
try {
|
try {
|
||||||
if (type === "wireguard" && subnet && exitNodeId) {
|
if (subnet && exitNodeId) {
|
||||||
// Only wireguard sites actually persist the provided subnet/exitNodeId.
|
|
||||||
// Newt sites have their subnet/exit node chosen (under a lock) when the
|
|
||||||
// newt connects, so validating them here is both unnecessary and racy,
|
|
||||||
// since pickSiteDefaults does not lock the subnet it suggests.
|
|
||||||
//make sure the subnet is in the range of the exit node if provided
|
//make sure the subnet is in the range of the exit node if provided
|
||||||
const [exitNode] = await db
|
const [exitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -82,11 +82,9 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
isAdmin={isAdminOrOwner}
|
isAdmin={isAdminOrOwner}
|
||||||
views={launcherData.views}
|
views={launcherData.views}
|
||||||
defaultViewOverrides={launcherData.defaultViewOverrides}
|
|
||||||
activeViewId={launcherData.activeViewId}
|
activeViewId={launcherData.activeViewId}
|
||||||
config={launcherData.config}
|
config={launcherData.config}
|
||||||
savedConfig={launcherData.savedConfig}
|
savedConfig={launcherData.savedConfig}
|
||||||
scale={launcherData.scale}
|
|
||||||
groups={launcherData.groups}
|
groups={launcherData.groups}
|
||||||
groupsPagination={launcherData.groupsPagination}
|
groupsPagination={launcherData.groupsPagination}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -290,13 +290,7 @@ export default function BillingPage() {
|
|||||||
setHasSubscription(
|
setHasSubscription(
|
||||||
tierSub.subscription.status === "active"
|
tierSub.subscription.status === "active"
|
||||||
);
|
);
|
||||||
// expiresAt is only meaningful while the trial hasn't
|
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||||
// actually run out yet; a stale row with a past
|
|
||||||
// expiresAt should no longer be treated as a live trial
|
|
||||||
const expiresAt = tierSub.subscription.expiresAt;
|
|
||||||
setIsTrial(
|
|
||||||
expiresAt != null && expiresAt * 1000 > Date.now()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find license subscription
|
// Find license subscription
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||||
const [headersValid, setHeadersValid] = useState(true);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
|
||||||
if (!headersValid) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: t("headersValidationError")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
|
|||||||
onChange={
|
onChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
onValidityChange={
|
|
||||||
setHeadersValid
|
|
||||||
}
|
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading || !headersValid}
|
disabled={saveLoading}
|
||||||
form="http-settings-form"
|
form="http-settings-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("saveSettings")}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
|||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||||
import { ListIdpsResponse } from "@server/routers/idp";
|
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Log In"
|
title: "Log In"
|
||||||
@@ -29,8 +32,9 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
||||||
|
|
||||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||||
const forceLoginParam = searchParams?.forceLogin;
|
const forceLoginParam = searchParams?.forceLogin;
|
||||||
@@ -85,19 +89,47 @@ export default async function Page(props: {
|
|||||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
|
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||||
|
null;
|
||||||
if (!useSmartLogin) {
|
if (!useSmartLogin) {
|
||||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||||
const idpsRes = await cache(
|
const idpsRes =
|
||||||
async () =>
|
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
variant: idp.type
|
variant: idp.type
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (lastUsedIdpCookie) {
|
||||||
|
const lastUsedIdpSchema = z.object({
|
||||||
|
orgId: z.string().optional(),
|
||||||
|
idpId: z.number()
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const persistedData = lastUsedIdpSchema.parse(
|
||||||
|
JSON.parse(lastUsedIdpCookie.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
||||||
|
`/idp/${persistedData.idpId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const idp = idpRes.data.data.idp;
|
||||||
|
|
||||||
|
lastUsedIdpForSmartLogin = {
|
||||||
|
idpId: idp.idpId,
|
||||||
|
name: idp.name,
|
||||||
|
variant: idp.type,
|
||||||
|
orgId: persistedData.orgId,
|
||||||
|
lastUsed: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// the idp might not exist or the data is malformatted, skip this
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -160,6 +192,7 @@ export default async function Page(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
defaultUser={defaultUser}
|
defaultUser={defaultUser}
|
||||||
|
lastUsedIdp={lastUsedIdpForSmartLogin}
|
||||||
orgSignIn={
|
orgSignIn={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
(build === "saas" ||
|
(build === "saas" ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -27,8 +25,7 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,14 +7,12 @@ type CopyToClipboardProps = {
|
|||||||
text: string;
|
text: string;
|
||||||
displayText?: string;
|
displayText?: string;
|
||||||
isLink?: boolean;
|
isLink?: boolean;
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyToClipboard = ({
|
const CopyToClipboard = ({
|
||||||
text,
|
text,
|
||||||
displayText,
|
displayText,
|
||||||
isLink,
|
isLink
|
||||||
className
|
|
||||||
}: CopyToClipboardProps) => {
|
}: CopyToClipboardProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -51,20 +48,14 @@ const CopyToClipboard = ({
|
|||||||
href={text}
|
href={text}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||||
"truncate hover:underline text-sm min-w-0 max-w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
title={text} // Shows full text on hover
|
title={text} // Shows full text on hover
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className="truncate text-sm min-w-0 max-w-full"
|
||||||
"truncate text-sm min-w-0 max-w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -2,36 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface HeadersInputProps {
|
interface HeadersInputProps {
|
||||||
value?: { name: string; value: string }[] | null;
|
value?: { name: string; value: string }[] | null;
|
||||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the server side validation in updateResource.ts so that invalid
|
|
||||||
// input is caught (and shown to the user) before it is ever submitted,
|
|
||||||
// instead of being silently dropped in favor of the last known good value.
|
|
||||||
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
|
||||||
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
|
|
||||||
const templatePattern = /\{\{[^}]+\}\}/;
|
|
||||||
|
|
||||||
export function HeadersInput({
|
export function HeadersInput({
|
||||||
value = [],
|
value = [],
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
|
||||||
placeholder = `X-Example-Header: example-value
|
placeholder = `X-Example-Header: example-value
|
||||||
X-Another-Header: another-value`,
|
X-Another-Header: another-value`,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
className
|
className
|
||||||
}: HeadersInputProps) {
|
}: HeadersInputProps) {
|
||||||
const t = useTranslations();
|
|
||||||
const [internalValue, setInternalValue] = useState("");
|
const [internalValue, setInternalValue] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isUserEditingRef = useRef(false);
|
const isUserEditingRef = useRef(false);
|
||||||
|
|
||||||
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse newline-separated text into header objects, validating each line
|
// Convert newline-separated string to header objects array
|
||||||
// against the same rules enforced by the server. Returns either the
|
const convertToHeadersArray = (
|
||||||
// parsed headers or an error message describing the first invalid line.
|
|
||||||
const parseHeaders = (
|
|
||||||
newlineSeparated: string
|
newlineSeparated: string
|
||||||
):
|
): { name: string; value: string }[] | null => {
|
||||||
| { headers: { name: string; value: string }[]; error: null }
|
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||||
| { headers: null; error: string } => {
|
|
||||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
|
||||||
return { headers: [], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = newlineSeparated
|
return newlineSeparated
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0 && line.includes(":"))
|
||||||
|
.map((line) => {
|
||||||
|
const colonIndex = line.indexOf(":");
|
||||||
|
const name = line.substring(0, colonIndex).trim();
|
||||||
|
const value = line.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
const headers: { name: string; value: string }[] = [];
|
// Ensure header name conforms to HTTP header requirements
|
||||||
|
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||||
|
const normalizedName = name
|
||||||
|
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
for (const line of lines) {
|
return { name: normalizedName, value };
|
||||||
const colonIndex = line.indexOf(":");
|
})
|
||||||
if (colonIndex === -1) {
|
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||||
return { headers: null, error: t("headersValidationError") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = line.substring(0, colonIndex).trim();
|
|
||||||
const value = line.substring(colonIndex + 1).trim();
|
|
||||||
|
|
||||||
if (
|
|
||||||
!validHeaderNamePattern.test(name) ||
|
|
||||||
!validHeaderValuePattern.test(value) ||
|
|
||||||
templatePattern.test(name) ||
|
|
||||||
templatePattern.test(value)
|
|
||||||
) {
|
|
||||||
return { headers: null, error: t("headersValidationError") };
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.push({ name, value });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { headers, error: null };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update internal value when external value changes
|
// Update internal value when external value changes
|
||||||
// But only if the user is not currently editing (textarea not focused)
|
// But only if the user is not currently editing (textarea not focused)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserEditingRef.current) {
|
if (!isUserEditingRef.current) {
|
||||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
setInternalValue(convertToNewlineSeparated(value));
|
||||||
setError(null);
|
|
||||||
onValidityChange?.(true);
|
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
|
|||||||
// Mark that user is actively editing
|
// Mark that user is actively editing
|
||||||
isUserEditingRef.current = true;
|
isUserEditingRef.current = true;
|
||||||
|
|
||||||
const result = parseHeaders(newValue);
|
// Only update parent if the input is in a valid state
|
||||||
|
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||||
|
|
||||||
if (result.error) {
|
if (newValue.trim() === "") {
|
||||||
// Surface the error and do not touch the last known good value.
|
// Empty input is valid - represents no headers
|
||||||
// Silently dropping the update here (without telling the user)
|
onChange([]);
|
||||||
// is what previously let stale data get saved without warning.
|
} else {
|
||||||
setError(result.error);
|
// Check if all non-empty lines are properly formatted (contain ':')
|
||||||
onValidityChange?.(false);
|
const lines = newValue.split("\n");
|
||||||
return;
|
const nonEmptyLines = lines
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||||
|
const isValid =
|
||||||
|
nonEmptyLines.length === 0 ||
|
||||||
|
nonEmptyLines.every((line) => line.includes(":"));
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// Safe to convert and update parent
|
||||||
|
const headersArray = convertToHeadersArray(newValue);
|
||||||
|
onChange(headersArray);
|
||||||
|
}
|
||||||
|
// If not valid, don't call onChange - let user continue typing
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
|
||||||
onValidityChange?.(true);
|
|
||||||
onChange(result.headers);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -134,20 +114,15 @@ X-Another-Header: another-value`,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Textarea
|
||||||
<Textarea
|
ref={textareaRef}
|
||||||
ref={textareaRef}
|
value={internalValue}
|
||||||
value={internalValue}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
onFocus={handleFocus}
|
||||||
onFocus={handleFocus}
|
onBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
placeholder={placeholder}
|
||||||
placeholder={placeholder}
|
rows={rows}
|
||||||
rows={rows}
|
className={className}
|
||||||
className={className}
|
/>
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
import {
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
generateOidcUrlProxy,
|
import { Button } from "@app/components/ui/button";
|
||||||
type GenerateOidcUrlResponse
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
} from "@app/actions/server";
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
redirect as redirectTo,
|
redirect as redirectTo,
|
||||||
useParams,
|
useRouter,
|
||||||
useSearchParams
|
useSearchParams
|
||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdpLoginButtonsProps = {
|
type IdpLoginButtonsProps = {
|
||||||
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
|
|||||||
orgId
|
orgId
|
||||||
}: IdpLoginButtonsProps) {
|
}: IdpLoginButtonsProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
setClientCookie(
|
||||||
|
LAST_USED_IDP_COOKIE_NAME,
|
||||||
|
JSON.stringify({
|
||||||
|
orgId,
|
||||||
|
idpId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
sameSite: "Lax"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log("generating", idpId, redirect || "/", orgId);
|
console.log("generating", idpId, redirect || "/", orgId);
|
||||||
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
|
|||||||
"An unexpected error occurred. Please try again."
|
"An unexpected error occurred. Please try again."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectToUrl) {
|
if (redirectToUrl) {
|
||||||
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
|
className="w-full relative"
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full inline-flex items-center space-x-2"
|
|
||||||
onClick={() => {
|
|
||||||
loginWithIdp(idp.idpId);
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
loading={loading}
|
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<Button
|
||||||
<span>{idp.name}</span>
|
key={idp.idpId}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(() =>
|
||||||
|
loginWithIdp(idp.idpId)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span>{idp.name}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{idp.lastUsed && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import { cn } from "@app/lib/cn";
|
|||||||
export function InfoSections({
|
export function InfoSections({
|
||||||
children,
|
children,
|
||||||
cols,
|
cols,
|
||||||
columnSizing = "content",
|
columnSizing = "content"
|
||||||
layout = "default"
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||||
columnSizing?: "fill" | "content";
|
columnSizing?: "fill" | "content";
|
||||||
/** panel: 2 columns until xl for narrow containers such as side panels */
|
|
||||||
layout?: "default" | "panel";
|
|
||||||
}) {
|
}) {
|
||||||
const n = cols || 1;
|
const n = cols || 1;
|
||||||
const track =
|
const track =
|
||||||
@@ -22,14 +19,9 @@ export function InfoSections({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full min-w-0 gap-4",
|
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||||
layout === "panel"
|
|
||||||
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
|
|
||||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
|
||||||
columnSizing === "content" &&
|
columnSizing === "content" &&
|
||||||
(layout === "panel"
|
"md:justify-items-start md:justify-start"
|
||||||
? "xl:justify-items-start xl:justify-start"
|
|
||||||
: "md:justify-items-start md:justify-start")
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { Checkbox } from "./ui/checkbox";
|
import { Checkbox } from "./ui/checkbox";
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ type LabelsFilterSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
isSelected: (label: LabelFilterOption) => boolean;
|
isSelected: (label: LabelFilterOption) => boolean;
|
||||||
onToggle: (label: LabelFilterOption) => void;
|
onToggle: (label: LabelFilterOption) => void;
|
||||||
selectedLabels?: LabelFilterOption[];
|
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
showClear?: boolean;
|
showClear?: boolean;
|
||||||
scope?: "org" | "launcher";
|
scope?: "org" | "launcher";
|
||||||
@@ -35,7 +34,6 @@ export function LabelsFilterSelector({
|
|||||||
orgId,
|
orgId,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggle,
|
onToggle,
|
||||||
selectedLabels = [],
|
|
||||||
onClear,
|
onClear,
|
||||||
showClear = false,
|
showClear = false,
|
||||||
scope = "org"
|
scope = "org"
|
||||||
@@ -56,7 +54,7 @@ export function LabelsFilterSelector({
|
|||||||
...launcherQueries.labels({
|
...launcherQueries.labels({
|
||||||
orgId,
|
orgId,
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
}),
|
}),
|
||||||
enabled: scope === "launcher"
|
enabled: scope === "launcher"
|
||||||
});
|
});
|
||||||
@@ -65,18 +63,6 @@ export function LabelsFilterSelector({
|
|||||||
? (launcherLabelsQuery.data ?? [])
|
? (launcherLabelsQuery.data ?? [])
|
||||||
: (orgLabelsQuery.data ?? []);
|
: (orgLabelsQuery.data ?? []);
|
||||||
|
|
||||||
const labelsShown = useMemo(() => {
|
|
||||||
const base = [...labels];
|
|
||||||
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
|
|
||||||
const selectedNotInBase = selectedLabels.filter(
|
|
||||||
(selected) =>
|
|
||||||
!base.some((label) => label.labelId === selected.labelId)
|
|
||||||
);
|
|
||||||
return [...selectedNotInBase, ...base];
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}, [debouncedQuery, labels, selectedLabels]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
@@ -95,7 +81,7 @@ export function LabelsFilterSelector({
|
|||||||
{t("accessFilterClear")}
|
{t("accessFilterClear")}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
)}
|
||||||
{labelsShown.map((label) => (
|
{labels.map((label) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={label.labelId}
|
key={label.labelId}
|
||||||
value={label.name}
|
value={label.name}
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ import Link from "next/link";
|
|||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
||||||
generateOidcUrlProxy,
|
|
||||||
loginProxy
|
|
||||||
} from "@app/actions/server";
|
|
||||||
import { redirect as redirectTo } from "next/navigation";
|
import { redirect as redirectTo } from "next/navigation";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { loadReoScript } from "reodotdev";
|
import { loadReoScript } from "reodotdev";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
|
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
@@ -105,7 +104,6 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||||
@@ -130,6 +128,10 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
||||||
|
"login:last-used-idp",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
@@ -179,8 +181,7 @@ export default function LoginForm({
|
|||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
setError(
|
setError(
|
||||||
t("securityKeyRequired", {
|
t("securityKeyRequired", {
|
||||||
defaultValue:
|
defaultValue: "Please use your security key to sign in."
|
||||||
"Please use your security key to sign in."
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -242,6 +243,8 @@ export default function LoginForm({
|
|||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
let redirectUrl: string | undefined;
|
let redirectUrl: string | undefined;
|
||||||
|
|
||||||
|
setLastUsedIdpId(idpId.toString());
|
||||||
try {
|
try {
|
||||||
const data = await generateOidcUrlProxy(
|
const data = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
@@ -356,7 +359,6 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<SecurityKeyAuthButton
|
<SecurityKeyAuthButton
|
||||||
@@ -385,25 +387,41 @@ export default function LoginForm({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
|
className="w-full relative"
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full inline-flex items-center space-x-2"
|
|
||||||
onClick={() => {
|
|
||||||
loginWithIdp(idp.idpId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<Button
|
||||||
<span>{idp.name}</span>
|
key={idp.idpId}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
|
onClick={() => {
|
||||||
|
loginWithIdp(idp.idpId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span>{idp.name}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{lastUsedIdpId ===
|
||||||
|
idp.idpId.toString() && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
|
|||||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||||
|
import type { LoginFormIDP } from "./LoginForm";
|
||||||
|
import IdpLoginButtons from "./IdpLoginButtons";
|
||||||
|
|
||||||
const identifierSchema = z.object({
|
const identifierSchema = z.object({
|
||||||
identifier: z.string().min(1, "Username or email is required")
|
identifier: z.string().min(1, "Username or email is required")
|
||||||
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
|
|||||||
forceLogin?: boolean;
|
forceLogin?: boolean;
|
||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
|
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
|
|||||||
redirect,
|
redirect,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
orgSignIn
|
orgSignIn,
|
||||||
|
lastUsedIdp
|
||||||
}: SmartLoginFormProps) {
|
}: SmartLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lastUsedIdp && (
|
||||||
|
<IdpLoginButtons
|
||||||
|
idps={[lastUsedIdp]}
|
||||||
|
orgId={lastUsedIdp.orgId}
|
||||||
|
redirect={redirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<OrgSignInLink
|
<OrgSignInLink
|
||||||
href={orgSignIn.href}
|
href={orgSignIn.href}
|
||||||
linkText={orgSignIn.linkText}
|
linkText={orgSignIn.linkText}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export type MultiSitesSelectorProps = {
|
|||||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||||
filterTypes?: string[];
|
filterTypes?: string[];
|
||||||
scope?: "org" | "launcher";
|
scope?: "org" | "launcher";
|
||||||
onClear?: () => void;
|
|
||||||
showClear?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMultiSitesSelectorLabel(
|
export function formatMultiSitesSelectorLabel(
|
||||||
@@ -44,9 +42,7 @@ export function MultiSitesSelector({
|
|||||||
selectedSites,
|
selectedSites,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
filterTypes,
|
filterTypes,
|
||||||
scope = "org",
|
scope = "org"
|
||||||
onClear,
|
|
||||||
showClear = false
|
|
||||||
}: MultiSitesSelectorProps) {
|
}: MultiSitesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||||
@@ -64,7 +60,7 @@ export function MultiSitesSelector({
|
|||||||
...launcherQueries.sites({
|
...launcherQueries.sites({
|
||||||
orgId,
|
orgId,
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
}),
|
}),
|
||||||
enabled: scope === "launcher"
|
enabled: scope === "launcher"
|
||||||
});
|
});
|
||||||
@@ -111,14 +107,6 @@ export function MultiSitesSelector({
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{showClear && onClear && (
|
|
||||||
<CommandItem
|
|
||||||
onSelect={onClear}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("accessFilterClear")}
|
|
||||||
</CommandItem>
|
|
||||||
)}
|
|
||||||
{sitesShown.map((site) => (
|
{sitesShown.map((site) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={site.siteId}
|
key={site.siteId}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type SelectedLabel
|
type SelectedLabel
|
||||||
} from "@app/components/labels-selector";
|
} from "@app/components/labels-selector";
|
||||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -47,14 +46,14 @@ export function LauncherFilterPopover({
|
|||||||
const { data: labels = [] } = useQuery(
|
const { data: labels = [] } = useQuery(
|
||||||
launcherQueries.labels({
|
launcherQueries.labels({
|
||||||
orgId,
|
orgId,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: sites = [] } = useQuery(
|
const { data: sites = [] } = useQuery(
|
||||||
launcherQueries.sites({
|
launcherQueries.sites({
|
||||||
orgId,
|
orgId,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,32 +96,14 @@ export function LauncherFilterPopover({
|
|||||||
[labels, selectedLabels]
|
[labels, selectedLabels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover modal={false}>
|
<Popover modal={false}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="shrink-0">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="relative shrink-0"
|
|
||||||
>
|
|
||||||
<Funnel className="size-4" />
|
<Funnel className="size-4" />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{activeFilterCount > 0
|
{t("resourceLauncherFilter")}
|
||||||
? t("resourceLauncherFilterWithCount", {
|
|
||||||
count: activeFilterCount
|
|
||||||
})
|
|
||||||
: t("resourceLauncherFilter")}
|
|
||||||
</span>
|
</span>
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
|
|
||||||
>
|
|
||||||
{activeFilterCount > 99 ? "99+" : activeFilterCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-72">
|
<PopoverContent align="end" className="w-72">
|
||||||
@@ -158,10 +139,6 @@ export function LauncherFilterPopover({
|
|||||||
selectedSites={resolvedSelectedSites}
|
selectedSites={resolvedSelectedSites}
|
||||||
onSelectionChange={onSitesChange}
|
onSelectionChange={onSitesChange}
|
||||||
scope="launcher"
|
scope="launcher"
|
||||||
showClear={selectedSites.length > 0}
|
|
||||||
onClear={() => {
|
|
||||||
onSitesChange([]);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -195,7 +172,6 @@ export function LauncherFilterPopover({
|
|||||||
<LabelsFilterSelector
|
<LabelsFilterSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
scope="launcher"
|
scope="launcher"
|
||||||
selectedLabels={resolvedSelectedLabels}
|
|
||||||
isSelected={(label) =>
|
isSelected={(label) =>
|
||||||
selectedLabelIds.has(label.labelId)
|
selectedLabelIds.has(label.labelId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
|
||||||
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import type {
|
|
||||||
LauncherResource,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
|
||||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
|
||||||
import { LauncherResourceList } from "./LauncherResourceList";
|
|
||||||
|
|
||||||
type LauncherFlatResourceListProps = {
|
|
||||||
orgId: string;
|
|
||||||
activeViewId: LauncherActiveViewId;
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
onClearFilters?: () => void;
|
|
||||||
onResourceSelect?: (resource: LauncherResource) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LauncherFlatResourceList({
|
|
||||||
orgId,
|
|
||||||
config,
|
|
||||||
onClearFilters,
|
|
||||||
onResourceSelect
|
|
||||||
}: LauncherFlatResourceListProps) {
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const resourceFilters = useMemo(
|
|
||||||
() => ({
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
groupKey: LAUNCHER_FLAT_GROUP_KEY,
|
|
||||||
siteIds: config.siteIds,
|
|
||||||
labelIds: config.labelIds,
|
|
||||||
sort_by: config.sortBy,
|
|
||||||
order: config.order,
|
|
||||||
pageSize: 20
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
config.groupBy,
|
|
||||||
config.labelIds,
|
|
||||||
config.order,
|
|
||||||
config.query,
|
|
||||||
config.siteIds,
|
|
||||||
config.sortBy
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
...launcherQueries.resources(orgId, resourceFilters)
|
|
||||||
});
|
|
||||||
|
|
||||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = loadMoreRef.current;
|
|
||||||
if (!node || !hasNextPage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
|
||||||
void fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(node);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
|
||||||
|
|
||||||
if (resources.length === 0) {
|
|
||||||
if (isFetching) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
||||||
<Loader2 className="size-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LauncherEmptyState
|
|
||||||
variant={
|
|
||||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
|
||||||
}
|
|
||||||
layout={config.layout}
|
|
||||||
query={config.query}
|
|
||||||
onClearFilters={onClearFilters}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{config.layout === "grid" ? (
|
|
||||||
<LauncherResourceGrid
|
|
||||||
resources={resources}
|
|
||||||
showLabels={config.showLabels}
|
|
||||||
onResourceSelect={onResourceSelect}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LauncherResourceList
|
|
||||||
resources={resources}
|
|
||||||
showLabels={config.showLabels}
|
|
||||||
onResourceSelect={onResourceSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={loadMoreRef} className="h-4" />
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -69,20 +69,16 @@ export function LauncherGroupList({
|
|||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
...launcherQueries.groups(orgId, groupFilters),
|
...launcherQueries.groups(orgId, groupFilters),
|
||||||
...(initialGroups.length > 0
|
initialData: {
|
||||||
? {
|
pages: [
|
||||||
initialData: {
|
{
|
||||||
pages: [
|
groups: initialGroups,
|
||||||
{
|
pagination: groupsPagination
|
||||||
groups: initialGroups,
|
}
|
||||||
pagination: groupsPagination
|
],
|
||||||
}
|
pageParams: [1]
|
||||||
],
|
},
|
||||||
pageParams: [1]
|
refetchOnMount: false
|
||||||
},
|
|
||||||
refetchOnMount: false as const
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function LauncherRefreshButton({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
<span className="hidden sm:inline">{t("refresh")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
|
||||||
import {
|
|
||||||
InfoSection,
|
|
||||||
InfoSectionContent,
|
|
||||||
InfoSections,
|
|
||||||
InfoSectionTitle
|
|
||||||
} from "@app/components/InfoSection";
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import {
|
import {
|
||||||
SidePanel,
|
SidePanel,
|
||||||
SidePanelBody,
|
SidePanelBody,
|
||||||
SidePanelContent,
|
SidePanelContent,
|
||||||
|
SidePanelDescription,
|
||||||
SidePanelFooter,
|
SidePanelFooter,
|
||||||
SidePanelHeader,
|
SidePanelHeader,
|
||||||
SidePanelTitle
|
SidePanelTitle
|
||||||
} from "@app/components/SidePanel";
|
} from "@app/components/SidePanel";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
|
||||||
derivePublicAuthState,
|
|
||||||
formatPortRestrictionDisplay,
|
|
||||||
formatPublicResourceType
|
|
||||||
} from "@app/lib/launcherResourceDetails";
|
|
||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import {
|
|
||||||
formatSiteResourceDestinationDisplay,
|
|
||||||
isSafeUrlForLink
|
|
||||||
} from "@app/lib/launcherResourceAccess";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
|
||||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
ShieldCheck,
|
|
||||||
ShieldOff,
|
|
||||||
XCircle
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -61,460 +23,6 @@ type LauncherResourcePanelProps = {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LauncherResourceDetailResult =
|
|
||||||
| {
|
|
||||||
resourceType: "public";
|
|
||||||
data: GetResourceResponse;
|
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
|
||||||
}
|
|
||||||
| { resourceType: "site"; data: GetSiteResourceResponse };
|
|
||||||
|
|
||||||
function AccessMethodContent({
|
|
||||||
accessDisplay,
|
|
||||||
accessCopyValue,
|
|
||||||
accessUrl
|
|
||||||
}: {
|
|
||||||
accessDisplay: string;
|
|
||||||
accessCopyValue: string;
|
|
||||||
accessUrl?: string | null;
|
|
||||||
}) {
|
|
||||||
if (!accessDisplay) {
|
|
||||||
return <span>-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const href = accessUrl ?? undefined;
|
|
||||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
|
||||||
|
|
||||||
if (canLink && href) {
|
|
||||||
return (
|
|
||||||
<CopyToClipboard
|
|
||||||
text={href}
|
|
||||||
displayText={accessDisplay}
|
|
||||||
isLink={true}
|
|
||||||
className="text-base"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CopyToClipboard
|
|
||||||
text={accessCopyValue || accessDisplay}
|
|
||||||
displayText={accessDisplay}
|
|
||||||
isLink={false}
|
|
||||||
className="text-base"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthStatusDisplay({
|
|
||||||
health
|
|
||||||
}: {
|
|
||||||
health: string | null | undefined;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const status = health ?? "unknown";
|
|
||||||
|
|
||||||
if (status === "healthy") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
|
|
||||||
<span>{t("resourcesTableHealthy")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "degraded") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
|
|
||||||
<span>{t("resourcesTableDegraded")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "unhealthy") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
|
||||||
<span>{t("resourcesTableUnhealthy")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span>{t("resourcesTableUnknown")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
|
||||||
|
|
||||||
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{enabled ? (
|
|
||||||
<CheckCircle2 className="size-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="size-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
<span>{enabled ? t("enabled") : t("disabled")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PublicResourceAuthMethods({
|
|
||||||
authInfo
|
|
||||||
}: {
|
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const authMethods = [
|
|
||||||
{
|
|
||||||
key: "sso",
|
|
||||||
title: t("policyAuthSsoTitle"),
|
|
||||||
enabled: authInfo.sso
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "password",
|
|
||||||
title: t("policyAuthPasscodeTitle"),
|
|
||||||
enabled: authInfo.password
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "pincode",
|
|
||||||
title: t("policyAuthPincodeTitle"),
|
|
||||||
enabled: authInfo.pincode
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "whitelist",
|
|
||||||
title: t("policyAuthEmailTitle"),
|
|
||||||
enabled: authInfo.whitelist
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "headerAuth",
|
|
||||||
title: t("policyAuthHeaderAuthTitle"),
|
|
||||||
enabled: authInfo.headerAuth
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("authentication")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourceLauncherAuthMethodsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<InfoSections cols={authMethods.length} layout="panel">
|
|
||||||
{authMethods.map((method) => (
|
|
||||||
<InfoSection key={method.key}>
|
|
||||||
<InfoSectionTitle>{method.title}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<AuthMethodStatusDisplay
|
|
||||||
enabled={method.enabled}
|
|
||||||
/>
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
))}
|
|
||||||
</InfoSections>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PublicResourceDetails({
|
|
||||||
launcherResource,
|
|
||||||
resource,
|
|
||||||
authInfo
|
|
||||||
}: {
|
|
||||||
launcherResource: LauncherResource;
|
|
||||||
resource: GetResourceResponse;
|
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
|
|
||||||
resource.mode || ""
|
|
||||||
);
|
|
||||||
const authState = derivePublicAuthState(resource.mode, authInfo);
|
|
||||||
const infoSectionCount = supportsAuth ? 4 : 3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceLauncherResourceDetails")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourceLauncherResourceDetailsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<InfoSections cols={infoSectionCount} layout="panel">
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{formatPublicResourceType(resource)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<AccessMethodContent
|
|
||||||
accessDisplay={
|
|
||||||
launcherResource.accessDisplay
|
|
||||||
}
|
|
||||||
accessCopyValue={
|
|
||||||
launcherResource.accessCopyValue
|
|
||||||
}
|
|
||||||
accessUrl={launcherResource.accessUrl}
|
|
||||||
/>
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{supportsAuth ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("authentication")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{authState === "protected" ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheck className="size-4 shrink-0 text-green-500" />
|
|
||||||
<span>{t("protected")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
|
|
||||||
<span>{t("notProtected")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
) : null}
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<HealthStatusDisplay health={resource.health} />
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
</InfoSections>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
{supportsAuth ? (
|
|
||||||
<PublicResourceAuthMethods authInfo={authInfo} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrivateResourceDetails({
|
|
||||||
launcherResource,
|
|
||||||
resource
|
|
||||||
}: {
|
|
||||||
launcherResource: LauncherResource;
|
|
||||||
resource: GetSiteResourceResponse;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
|
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
|
||||||
http: t("editInternalResourceDialogModeHttp"),
|
|
||||||
ssh: t("editInternalResourceDialogModeSsh")
|
|
||||||
};
|
|
||||||
const destination = formatSiteResourceDestinationDisplay({
|
|
||||||
mode: resource.mode,
|
|
||||||
destination: resource.destination,
|
|
||||||
destinationPort: resource.destinationPort,
|
|
||||||
scheme: resource.scheme
|
|
||||||
});
|
|
||||||
const portRestrictions = formatPortRestrictionDisplay(resource);
|
|
||||||
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
|
|
||||||
const showDestination = !(
|
|
||||||
resource.mode === "ssh" && resource.authDaemonMode === "native"
|
|
||||||
);
|
|
||||||
const infoSectionCount =
|
|
||||||
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert variant="default">
|
|
||||||
<AlertCircle className="size-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{t("resourceLauncherPrivateClientRequiredTitle")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<span>
|
|
||||||
{t("resourceLauncherPrivateClientRequired")}{" "}
|
|
||||||
<a
|
|
||||||
href="https://pangolin.net/downloads"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t("resourceLauncherDownloadClient")}
|
|
||||||
<ExternalLink className="size-3.5 shrink-0" />
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("resourceLauncherResourceDetails")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("resourceLauncherResourceDetailsDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<InfoSections cols={infoSectionCount} layout="panel">
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{modeLabel[resource.mode]}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<AccessMethodContent
|
|
||||||
accessDisplay={
|
|
||||||
launcherResource.accessDisplay
|
|
||||||
}
|
|
||||||
accessCopyValue={
|
|
||||||
launcherResource.accessCopyValue
|
|
||||||
}
|
|
||||||
accessUrl={launcherResource.accessUrl}
|
|
||||||
/>
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{showDestination ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("editInternalResourceDialogDestination")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{destination || "-"}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
) : null}
|
|
||||||
{showAlias ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("editInternalResourceDialogAlias")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{resource.alias?.trim()
|
|
||||||
? resource.alias
|
|
||||||
: "-"}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
) : null}
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("portRestrictions")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{!portRestrictions.hasNonDefaultPorts ? (
|
|
||||||
<span>
|
|
||||||
{t(
|
|
||||||
"resourceLauncherNoPortRestrictions"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{portRestrictions.tcp.state !==
|
|
||||||
"all" ? (
|
|
||||||
<div>
|
|
||||||
{t("resourceLauncherTcp")}:{" "}
|
|
||||||
{portRestrictions.tcp.state ===
|
|
||||||
"blocked"
|
|
||||||
? t("blocked")
|
|
||||||
: portRestrictions.tcp
|
|
||||||
.ports}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{portRestrictions.udp.state !==
|
|
||||||
"all" ? (
|
|
||||||
<div>
|
|
||||||
{t("resourceLauncherUdp")}:{" "}
|
|
||||||
{portRestrictions.udp.state ===
|
|
||||||
"blocked"
|
|
||||||
? t("blocked")
|
|
||||||
: portRestrictions.udp
|
|
||||||
.ports}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
</InfoSections>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LauncherResourcePanelBody({
|
|
||||||
orgId,
|
|
||||||
resource,
|
|
||||||
open
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
resource: LauncherResource;
|
|
||||||
open: boolean;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const { data, isPending, isError } = useQuery({
|
|
||||||
...launcherQueries.resourceDetail(orgId, resource),
|
|
||||||
enabled: open
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<Loader2 className="size-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("resourceLauncherFailedToLoadDetails")}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = data as LauncherResourceDetailResult;
|
|
||||||
|
|
||||||
if (detail.resourceType === "public") {
|
|
||||||
return (
|
|
||||||
<PublicResourceDetails
|
|
||||||
launcherResource={resource}
|
|
||||||
resource={detail.data}
|
|
||||||
authInfo={detail.authInfo}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrivateResourceDetails
|
|
||||||
launcherResource={resource}
|
|
||||||
resource={detail.data}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherResourcePanel({
|
export function LauncherResourcePanel({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -529,16 +37,11 @@ export function LauncherResourcePanel({
|
|||||||
<SidePanelContent>
|
<SidePanelContent>
|
||||||
<SidePanelHeader>
|
<SidePanelHeader>
|
||||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
||||||
|
<SidePanelDescription>
|
||||||
|
{t("resourceLauncherResourceDetailsDescription")}
|
||||||
|
</SidePanelDescription>
|
||||||
</SidePanelHeader>
|
</SidePanelHeader>
|
||||||
<SidePanelBody>
|
<SidePanelBody />
|
||||||
{resource ? (
|
|
||||||
<LauncherResourcePanelBody
|
|
||||||
orgId={orgId}
|
|
||||||
resource={resource}
|
|
||||||
open={open}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidePanelBody>
|
|
||||||
<SidePanelFooter>
|
<SidePanelFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
type LauncherSearchFirstGateProps = {
|
|
||||||
layout: "grid" | "list";
|
|
||||||
};
|
|
||||||
|
|
||||||
function GhostResourceGrid() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
||||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
|
||||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GhostResourceList() {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col" aria-hidden>
|
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-4 px-4 py-3",
|
|
||||||
index < 2 && "border-b border-border/60"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
||||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
|
||||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LauncherSearchFirstGate({
|
|
||||||
layout
|
|
||||||
}: LauncherSearchFirstGateProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
|
||||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
|
||||||
{layout === "grid" ? (
|
|
||||||
<GhostResourceGrid />
|
|
||||||
) : (
|
|
||||||
<GhostResourceList />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
||||||
<Search className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-md space-y-1.5">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("resourceLauncherSearchFirstTitle")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("resourceLauncherSearchFirstDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,27 +15,22 @@ import {
|
|||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import type {
|
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||||
LauncherScaleCapabilities,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
type LauncherSettingsMenuProps = {
|
type LauncherSettingsMenuProps = {
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
capabilities: LauncherScaleCapabilities;
|
isDefaultView: boolean;
|
||||||
isCompactMode: boolean;
|
|
||||||
selectedGroupBy: LauncherViewConfig["groupBy"];
|
|
||||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||||
|
onDeleteView: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LauncherSettingsMenu({
|
export function LauncherSettingsMenu({
|
||||||
config,
|
config,
|
||||||
capabilities,
|
isDefaultView,
|
||||||
isCompactMode,
|
onConfigChange,
|
||||||
selectedGroupBy,
|
onDeleteView
|
||||||
onConfigChange
|
|
||||||
}: LauncherSettingsMenuProps) {
|
}: LauncherSettingsMenuProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -56,7 +51,7 @@ export function LauncherSettingsMenu({
|
|||||||
{t("resourceLauncherGroupBy")}
|
{t("resourceLauncherGroupBy")}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={selectedGroupBy}
|
value={config.groupBy}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
groupBy:
|
groupBy:
|
||||||
@@ -68,46 +63,14 @@ export function LauncherSettingsMenu({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">
|
<SelectItem value="site">
|
||||||
{t("resourceLauncherGroupByNone")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="site"
|
|
||||||
disabled={
|
|
||||||
!capabilities.allowSiteGrouping ||
|
|
||||||
(isCompactMode &&
|
|
||||||
config.siteIds.length === 0)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("resourceLauncherGroupBySite")}
|
{t("resourceLauncherGroupBySite")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem value="label">
|
||||||
value="label"
|
|
||||||
disabled={
|
|
||||||
!capabilities.allowLabelGrouping ||
|
|
||||||
(isCompactMode &&
|
|
||||||
config.labelIds.length === 0)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("resourceLauncherGroupByLabel")}
|
{t("resourceLauncherGroupByLabel")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{isCompactMode ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("resourceLauncherCompactGroupingHint")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{!isCompactMode && !capabilities.allowSiteGrouping ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("resourceLauncherSiteGroupingDisabled")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{!isCompactMode && !capabilities.allowLabelGrouping ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("resourceLauncherLabelGroupingDisabled")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -153,6 +116,16 @@ export function LauncherSettingsMenu({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isDefaultView ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
onClick={onDeleteView}
|
||||||
|
>
|
||||||
|
{t("resourceLauncherDeleteView")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -68,14 +68,11 @@ type LauncherSaveViewMenuProps = {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isOrgWideView: boolean;
|
isOrgWideView: boolean;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
canResetSystemDefault: boolean;
|
|
||||||
onSaveToCurrent: () => void;
|
onSaveToCurrent: () => void;
|
||||||
onSaveAsNew: () => void;
|
onSaveAsNew: () => void;
|
||||||
onSaveForEveryone: () => void;
|
onSaveForEveryone: () => void;
|
||||||
onMakePersonal: () => void;
|
onMakePersonal: () => void;
|
||||||
onResetView: () => void;
|
onResetView: () => void;
|
||||||
onResetSystemDefault: () => void;
|
|
||||||
onDeleteView: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LauncherSaveViewMenu({
|
export function LauncherSaveViewMenu({
|
||||||
@@ -83,17 +80,13 @@ export function LauncherSaveViewMenu({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
isOrgWideView,
|
isOrgWideView,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
canResetSystemDefault,
|
|
||||||
onSaveToCurrent,
|
onSaveToCurrent,
|
||||||
onSaveAsNew,
|
onSaveAsNew,
|
||||||
onSaveForEveryone,
|
onSaveForEveryone,
|
||||||
onMakePersonal,
|
onMakePersonal,
|
||||||
onResetView,
|
onResetView
|
||||||
onResetSystemDefault,
|
|
||||||
onDeleteView
|
|
||||||
}: LauncherSaveViewMenuProps) {
|
}: LauncherSaveViewMenuProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -115,26 +108,7 @@ export function LauncherSaveViewMenu({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{isDefaultView && canResetSystemDefault ? (
|
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
|
||||||
<>
|
|
||||||
<DropdownMenuItem onSelect={onResetSystemDefault}>
|
|
||||||
{t("resourceLauncherResetSystemDefault")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{isDefaultView && hasUnsavedChanges ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
|
||||||
{t("resourceLauncherSaveDefaultPersonal")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{isAdmin ? (
|
|
||||||
<DropdownMenuItem onSelect={onSaveForEveryone}>
|
|
||||||
{t("resourceLauncherSaveForEveryone")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
|
|
||||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
||||||
{t("resourceLauncherSaveToCurrentView")}
|
{t("resourceLauncherSaveToCurrentView")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -152,17 +126,6 @@ export function LauncherSaveViewMenu({
|
|||||||
{t("resourceLauncherMakePersonal")}
|
{t("resourceLauncherMakePersonal")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{canDeleteView ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={onDeleteView}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
{t("resourceLauncherDeleteView")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,23 +29,12 @@ import {
|
|||||||
} from "@app/lib/launcherUrlState";
|
} from "@app/lib/launcherUrlState";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import {
|
import type {
|
||||||
getEffectiveLauncherConfig,
|
LauncherGroup,
|
||||||
shouldShowFlatResourceList,
|
LauncherViewConfig,
|
||||||
shouldShowLauncherGroupList,
|
LauncherViewRecord
|
||||||
shouldShowSearchFirstGate
|
|
||||||
} from "@app/lib/launcherScale";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
|
||||||
import {
|
|
||||||
getEffectiveDefaultLauncherConfig,
|
|
||||||
type LauncherDefaultViewOverrides,
|
|
||||||
type LauncherGroup,
|
|
||||||
type LauncherResource,
|
|
||||||
type LauncherScaleInfo,
|
|
||||||
type LauncherViewConfig,
|
|
||||||
type LauncherViewRecord
|
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -63,26 +52,20 @@ import type { SelectedLabel } from "@app/components/labels-selector";
|
|||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
||||||
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
|
|
||||||
import { LauncherGroupList } from "./LauncherGroupList";
|
import { LauncherGroupList } from "./LauncherGroupList";
|
||||||
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
|
||||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
||||||
import { LauncherResourcePanel } from "./LauncherResourcePanel";
|
|
||||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||||
import { LauncherSortButton } from "./LauncherSortButton";
|
import { LauncherSortButton } from "./LauncherSortButton";
|
||||||
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
type ResourceLauncherProps = {
|
type ResourceLauncherProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
views: LauncherViewRecord[];
|
views: LauncherViewRecord[];
|
||||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
|
||||||
activeViewId: LauncherActiveViewId;
|
activeViewId: LauncherActiveViewId;
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
savedConfig: LauncherViewConfig;
|
savedConfig: LauncherViewConfig;
|
||||||
scale: LauncherScaleInfo;
|
|
||||||
groups: LauncherGroup[];
|
groups: LauncherGroup[];
|
||||||
groupsPagination: {
|
groupsPagination: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -95,11 +78,9 @@ export default function ResourceLauncher({
|
|||||||
orgId,
|
orgId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
views,
|
views,
|
||||||
defaultViewOverrides,
|
|
||||||
activeViewId,
|
activeViewId,
|
||||||
config,
|
config,
|
||||||
savedConfig,
|
savedConfig,
|
||||||
scale: initialScale,
|
|
||||||
groups,
|
groups,
|
||||||
groupsPagination
|
groupsPagination
|
||||||
}: ResourceLauncherProps) {
|
}: ResourceLauncherProps) {
|
||||||
@@ -107,7 +88,6 @@ export default function ResourceLauncher({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { navigate, isNavigating, searchParams } = useNavigationContext();
|
const { navigate, isNavigating, searchParams } = useNavigationContext();
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||||
@@ -115,47 +95,11 @@ export default function ResourceLauncher({
|
|||||||
|
|
||||||
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
|
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [newViewName, setNewViewName] = useState("");
|
const [newViewName, setNewViewName] = useState("");
|
||||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
||||||
const [selectedResource, setSelectedResource] =
|
|
||||||
useState<LauncherResource | null>(null);
|
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
|
||||||
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
const scaleFilters = useMemo(
|
|
||||||
() => ({
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
siteIds: config.siteIds,
|
|
||||||
labelIds: config.labelIds,
|
|
||||||
sort_by: config.sortBy,
|
|
||||||
order: config.order
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
config.groupBy,
|
|
||||||
config.labelIds,
|
|
||||||
config.order,
|
|
||||||
config.query,
|
|
||||||
config.siteIds,
|
|
||||||
config.sortBy
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: scale = initialScale } = useQuery({
|
|
||||||
...launcherQueries.scale(orgId, scaleFilters),
|
|
||||||
initialData: initialScale
|
|
||||||
});
|
|
||||||
|
|
||||||
const showGroupList = shouldShowLauncherGroupList(scale, config);
|
|
||||||
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
|
|
||||||
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
|
|
||||||
const effectiveConfig = useMemo(
|
|
||||||
() => getEffectiveLauncherConfig(scale, config),
|
|
||||||
[config, scale]
|
|
||||||
);
|
|
||||||
|
|
||||||
const configRef = useRef(config);
|
const configRef = useRef(config);
|
||||||
configRef.current = config;
|
configRef.current = config;
|
||||||
const searchInputRef = useRef(config.query);
|
const searchInputRef = useRef(config.query);
|
||||||
@@ -185,24 +129,13 @@ export default function ResourceLauncher({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseConfig = getLauncherUrlBaseConfig(
|
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
|
||||||
lastView,
|
|
||||||
views,
|
|
||||||
defaultViewOverrides
|
|
||||||
);
|
|
||||||
const params = serializeLauncherUrlState({
|
const params = serializeLauncherUrlState({
|
||||||
viewId: lastView,
|
viewId: lastView,
|
||||||
config: baseConfig
|
config: baseConfig
|
||||||
});
|
});
|
||||||
navigate({ searchParams: params, replace: true });
|
navigate({ searchParams: params, replace: true });
|
||||||
}, [
|
}, [activeViewId, navigate, orgId, searchParams, views]);
|
||||||
activeViewId,
|
|
||||||
defaultViewOverrides,
|
|
||||||
navigate,
|
|
||||||
orgId,
|
|
||||||
searchParams,
|
|
||||||
views
|
|
||||||
]);
|
|
||||||
|
|
||||||
const navigateToConfig = useCallback(
|
const navigateToConfig = useCallback(
|
||||||
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
||||||
@@ -225,14 +158,10 @@ export default function ResourceLauncher({
|
|||||||
const selectView = useCallback(
|
const selectView = useCallback(
|
||||||
(viewId: LauncherActiveViewId) => {
|
(viewId: LauncherActiveViewId) => {
|
||||||
writeLauncherLastView(orgId, viewId);
|
writeLauncherLastView(orgId, viewId);
|
||||||
const baseConfig = getLauncherUrlBaseConfig(
|
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
|
||||||
viewId,
|
|
||||||
views,
|
|
||||||
defaultViewOverrides
|
|
||||||
);
|
|
||||||
navigateToConfig(viewId, baseConfig);
|
navigateToConfig(viewId, baseConfig);
|
||||||
},
|
},
|
||||||
[defaultViewOverrides, navigateToConfig, orgId, views]
|
[navigateToConfig, orgId, views]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeSavedView = useMemo(
|
const activeSavedView = useMemo(
|
||||||
@@ -246,10 +175,6 @@ export default function ResourceLauncher({
|
|||||||
const isDefaultView = activeViewId === "default";
|
const isDefaultView = activeViewId === "default";
|
||||||
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
|
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
|
||||||
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
|
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
|
||||||
const canResetSystemDefault =
|
|
||||||
isDefaultView &&
|
|
||||||
(Boolean(defaultViewOverrides.personal) ||
|
|
||||||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
|
|
||||||
|
|
||||||
const selectedSites: Selectedsite[] = useMemo(
|
const selectedSites: Selectedsite[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,87 +270,6 @@ export default function ResourceLauncher({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveDefaultViewMutation = useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
config: LauncherViewConfig;
|
|
||||||
orgWide: boolean;
|
|
||||||
}) => {
|
|
||||||
const res = await api.put(
|
|
||||||
`/org/${orgId}/launcher/default-view`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return res.data.data as LauncherViewRecord;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
writeLauncherLastView(orgId, "default");
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: "default",
|
|
||||||
config
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params, replace: true });
|
|
||||||
router.refresh();
|
|
||||||
toast({
|
|
||||||
title: t("resourceLauncherViewSaved"),
|
|
||||||
description: t("resourceLauncherViewSavedDescription")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceLauncherViewSaveFailed"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
error,
|
|
||||||
t("resourceLauncherViewSaveFailedDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetSystemDefaultMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide);
|
|
||||||
await api.delete(`/org/${orgId}/launcher/default-view`, {
|
|
||||||
data: resetAll ? { all: true } : { orgWide: false }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
writeLauncherLastView(orgId, "default");
|
|
||||||
const nextOverrides: LauncherDefaultViewOverrides = {
|
|
||||||
personal: null,
|
|
||||||
orgWide:
|
|
||||||
isAdmin && defaultViewOverrides.orgWide
|
|
||||||
? null
|
|
||||||
: defaultViewOverrides.orgWide
|
|
||||||
};
|
|
||||||
const targetConfig =
|
|
||||||
getEffectiveDefaultLauncherConfig(nextOverrides);
|
|
||||||
searchInputRef.current = targetConfig.query;
|
|
||||||
setSearchInputResetKey((key) => key + 1);
|
|
||||||
const params = serializeLauncherUrlState({
|
|
||||||
viewId: "default",
|
|
||||||
config: targetConfig
|
|
||||||
});
|
|
||||||
navigate({ searchParams: params, replace: true });
|
|
||||||
router.refresh();
|
|
||||||
toast({
|
|
||||||
title: t("resourceLauncherSystemDefaultRestored"),
|
|
||||||
description: t(
|
|
||||||
"resourceLauncherSystemDefaultRestoredDescription"
|
|
||||||
)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceLauncherViewSaveFailed"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
error,
|
|
||||||
t("resourceLauncherViewSaveFailedDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteViewMutation = useMutation({
|
const deleteViewMutation = useMutation({
|
||||||
mutationFn: async (viewId: number) => {
|
mutationFn: async (viewId: number) => {
|
||||||
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
||||||
@@ -434,11 +278,7 @@ export default function ResourceLauncher({
|
|||||||
writeLauncherLastView(orgId, "default");
|
writeLauncherLastView(orgId, "default");
|
||||||
const params = serializeLauncherUrlState({
|
const params = serializeLauncherUrlState({
|
||||||
viewId: "default",
|
viewId: "default",
|
||||||
config: getLauncherUrlBaseConfig(
|
config: getLauncherUrlBaseConfig("default", views)
|
||||||
"default",
|
|
||||||
views,
|
|
||||||
defaultViewOverrides
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
navigate({ searchParams: params, replace: true });
|
navigate({ searchParams: params, replace: true });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -488,17 +328,9 @@ export default function ResourceLauncher({
|
|||||||
navigateToConfig(activeViewIdRef.current, savedConfig);
|
navigateToConfig(activeViewIdRef.current, savedConfig);
|
||||||
}, [navigateToConfig, savedConfig]);
|
}, [navigateToConfig, savedConfig]);
|
||||||
|
|
||||||
const handleResetSystemDefault = useCallback(() => {
|
|
||||||
resetSystemDefaultMutation.mutate();
|
|
||||||
}, [resetSystemDefaultMutation]);
|
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startRefreshTransition(async () => {
|
startRefreshTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/org/${orgId}/launcher/invalidate-cache`);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["ORG", orgId, "LAUNCHER"]
|
|
||||||
});
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
@@ -511,14 +343,7 @@ export default function ResourceLauncher({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveToCurrent = () => {
|
const handleSaveToCurrent = () => {
|
||||||
if (isDefaultView) {
|
if (isDefaultView || (isOrgWideView && !isAdmin)) {
|
||||||
saveDefaultViewMutation.mutate({
|
|
||||||
config,
|
|
||||||
orgWide: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isOrgWideView && !isAdmin) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateViewMutation.mutate({
|
updateViewMutation.mutate({
|
||||||
@@ -535,10 +360,6 @@ export default function ResourceLauncher({
|
|||||||
|
|
||||||
const handleSaveForEveryone = () => {
|
const handleSaveForEveryone = () => {
|
||||||
if (isDefaultView) {
|
if (isDefaultView) {
|
||||||
saveDefaultViewMutation.mutate({
|
|
||||||
config,
|
|
||||||
orgWide: true
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateViewMutation.mutate({
|
updateViewMutation.mutate({
|
||||||
@@ -568,18 +389,6 @@ export default function ResourceLauncher({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResourceSelect = useCallback((resource: LauncherResource) => {
|
|
||||||
setSelectedResource(resource);
|
|
||||||
setPanelOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePanelOpenChange = useCallback((open: boolean) => {
|
|
||||||
setPanelOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setSelectedResource(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const savedViewTabs = views.map((view) => ({
|
const savedViewTabs = views.map((view) => ({
|
||||||
viewId: view.viewId,
|
viewId: view.viewId,
|
||||||
name: view.name
|
name: view.name
|
||||||
@@ -603,8 +412,19 @@ export default function ResourceLauncher({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderToolbarFilterSort = () => (
|
const renderToolbarActions = () => (
|
||||||
<>
|
<>
|
||||||
|
<LauncherSaveViewMenu
|
||||||
|
isDefaultView={isDefaultView}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isOrgWideView={isOrgWideView}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
onSaveToCurrent={handleSaveToCurrent}
|
||||||
|
onSaveAsNew={handleSaveAsNew}
|
||||||
|
onSaveForEveryone={handleSaveForEveryone}
|
||||||
|
onMakePersonal={handleMakePersonal}
|
||||||
|
onResetView={handleResetView}
|
||||||
|
/>
|
||||||
<LauncherFilterPopover
|
<LauncherFilterPopover
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedSites={selectedSites}
|
selectedSites={selectedSites}
|
||||||
@@ -628,31 +448,15 @@ export default function ResourceLauncher({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderToolbarActions = () => (
|
|
||||||
<>
|
|
||||||
<LauncherSaveViewMenu
|
|
||||||
isDefaultView={isDefaultView}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
isOrgWideView={isOrgWideView}
|
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
|
||||||
canResetSystemDefault={canResetSystemDefault}
|
|
||||||
onSaveToCurrent={handleSaveToCurrent}
|
|
||||||
onSaveAsNew={handleSaveAsNew}
|
|
||||||
onSaveForEveryone={handleSaveForEveryone}
|
|
||||||
onMakePersonal={handleMakePersonal}
|
|
||||||
onResetView={handleResetView}
|
|
||||||
onResetSystemDefault={handleResetSystemDefault}
|
|
||||||
onDeleteView={() => setDeleteDialogOpen(true)}
|
|
||||||
/>
|
|
||||||
<LauncherSettingsMenu
|
<LauncherSettingsMenu
|
||||||
config={config}
|
config={config}
|
||||||
capabilities={scale.capabilities}
|
isDefaultView={isDefaultView}
|
||||||
isCompactMode={scale.mode === "compact"}
|
|
||||||
selectedGroupBy={effectiveConfig.groupBy}
|
|
||||||
onConfigChange={applyConfigPatch}
|
onConfigChange={applyConfigPatch}
|
||||||
|
onDeleteView={() => {
|
||||||
|
if (!isDefaultView) {
|
||||||
|
deleteViewMutation.mutate(activeViewId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<LauncherRefreshButton
|
<LauncherRefreshButton
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
@@ -679,9 +483,6 @@ export default function ResourceLauncher({
|
|||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
|
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
|
||||||
{renderToolbarSearch("w-64")}
|
{renderToolbarSearch("w-64")}
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{renderToolbarFilterSort()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||||
{renderToolbarViews()}
|
{renderToolbarViews()}
|
||||||
</div>
|
</div>
|
||||||
@@ -694,64 +495,22 @@ export default function ResourceLauncher({
|
|||||||
<div className="flex items-center gap-2 overflow-x-auto">
|
<div className="flex items-center gap-2 overflow-x-auto">
|
||||||
{renderToolbarActions()}
|
{renderToolbarActions()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{renderToolbarSearch("w-full")}
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{renderToolbarSearch("w-full")}
|
|
||||||
</div>
|
|
||||||
{renderToolbarFilterSort()}
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{renderToolbarViews()}
|
{renderToolbarViews()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSearchFirstGate ? (
|
<LauncherGroupList
|
||||||
<LauncherSearchFirstGate layout={config.layout} />
|
|
||||||
) : showGroupList ? (
|
|
||||||
<LauncherGroupList
|
|
||||||
orgId={orgId}
|
|
||||||
activeViewId={activeViewId}
|
|
||||||
config={effectiveConfig}
|
|
||||||
initialGroups={groups}
|
|
||||||
groupsPagination={groupsPagination}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
onResourceSelect={handleResourceSelect}
|
|
||||||
/>
|
|
||||||
) : showFlatResourceList ? (
|
|
||||||
<LauncherFlatResourceList
|
|
||||||
orgId={orgId}
|
|
||||||
activeViewId={activeViewId}
|
|
||||||
config={effectiveConfig}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
onResourceSelect={handleResourceSelect}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<LauncherResourcePanel
|
|
||||||
open={panelOpen}
|
|
||||||
onOpenChange={handlePanelOpenChange}
|
|
||||||
resource={selectedResource}
|
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
isAdmin={isAdmin}
|
activeViewId={activeViewId}
|
||||||
|
config={config}
|
||||||
|
initialGroups={groups}
|
||||||
|
groupsPagination={groupsPagination}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeSavedView ? (
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
setOpen={setDeleteDialogOpen}
|
|
||||||
string={activeSavedView.name}
|
|
||||||
title={t("resourceLauncherDeleteViewTitle")}
|
|
||||||
buttonText={t("resourceLauncherDeleteViewConfirm")}
|
|
||||||
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
|
|
||||||
onConfirm={async () => {
|
|
||||||
await deleteViewMutation.mutateAsync(
|
|
||||||
activeSavedView.viewId
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
|
|||||||
1
src/lib/consts.ts
Normal file
1
src/lib/consts.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
||||||
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
|
|||||||
function groupOpenKey(
|
function groupOpenKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label" | "none"
|
groupBy: "site" | "label"
|
||||||
) {
|
) {
|
||||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
|
|||||||
export function readLauncherGroupOpenState(
|
export function readLauncherGroupOpenState(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label" | "none"
|
groupBy: "site" | "label"
|
||||||
): Record<string, boolean> {
|
): Record<string, boolean> {
|
||||||
return readJson<Record<string, boolean>>(
|
return readJson<Record<string, boolean>>(
|
||||||
groupOpenKey(orgId, viewId, groupBy),
|
groupOpenKey(orgId, viewId, groupBy),
|
||||||
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
|
|||||||
export function readLauncherGroupOpen(
|
export function readLauncherGroupOpen(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label" | "none",
|
groupBy: "site" | "label",
|
||||||
groupKey: string,
|
groupKey: string,
|
||||||
defaultOpen: boolean
|
defaultOpen: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
|
|||||||
export function writeLauncherGroupOpen(
|
export function writeLauncherGroupOpen(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label" | "none",
|
groupBy: "site" | "label",
|
||||||
groupKey: string,
|
groupKey: string,
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
|
||||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
|
||||||
|
|
||||||
export type PublicAuthState = "protected" | "not_protected" | "none";
|
|
||||||
|
|
||||||
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
|
||||||
|
|
||||||
export function derivePublicAuthState(
|
|
||||||
mode: string | null,
|
|
||||||
authInfo: Pick<
|
|
||||||
GetResourceAuthInfoResponse,
|
|
||||||
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
|
|
||||||
>
|
|
||||||
): PublicAuthState {
|
|
||||||
if (!BROWSER_MODES.includes(mode || "")) {
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
authInfo.password ||
|
|
||||||
authInfo.pincode ||
|
|
||||||
authInfo.sso ||
|
|
||||||
authInfo.whitelist ||
|
|
||||||
authInfo.headerAuth
|
|
||||||
) {
|
|
||||||
return "protected";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "not_protected";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPublicResourceType(
|
|
||||||
resource: Pick<GetResourceResponse, "mode" | "ssl">
|
|
||||||
): string {
|
|
||||||
if (resource.mode === "http") {
|
|
||||||
return resource.ssl ? "HTTPS" : "HTTP";
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = (resource.mode || "").toLowerCase();
|
|
||||||
if (mode === "tcp") {
|
|
||||||
return "TCP";
|
|
||||||
}
|
|
||||||
if (mode === "udp") {
|
|
||||||
return "UDP";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (resource.mode || "—").toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PortProtocolState = "all" | "blocked" | "custom";
|
|
||||||
|
|
||||||
function getPortProtocolState(
|
|
||||||
value: string | null | undefined
|
|
||||||
): PortProtocolState {
|
|
||||||
if (value === "*") {
|
|
||||||
return "all";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value || value.trim() === "") {
|
|
||||||
return "blocked";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "custom";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PortRestrictionDisplay = {
|
|
||||||
hasNonDefaultPorts: boolean;
|
|
||||||
tcp: { state: PortProtocolState; ports: string | null };
|
|
||||||
udp: { state: PortProtocolState; ports: string | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatPortRestrictionDisplay(
|
|
||||||
resource: Pick<
|
|
||||||
GetSiteResourceResponse,
|
|
||||||
"tcpPortRangeString" | "udpPortRangeString"
|
|
||||||
>
|
|
||||||
): PortRestrictionDisplay {
|
|
||||||
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
|
|
||||||
const udpState = getPortProtocolState(resource.udpPortRangeString);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
|
|
||||||
tcp: {
|
|
||||||
state: tcpState,
|
|
||||||
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
|
|
||||||
},
|
|
||||||
udp: {
|
|
||||||
state: udpState,
|
|
||||||
ports: udpState === "custom" ? resource.udpPortRangeString : null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import type {
|
|
||||||
LauncherScaleInfo,
|
|
||||||
LauncherViewConfig
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
|
|
||||||
export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
|
||||||
return (
|
|
||||||
config.query.trim().length > 0 ||
|
|
||||||
config.siteIds.length > 0 ||
|
|
||||||
config.labelIds.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectiveGroupBy(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): LauncherViewConfig["groupBy"] {
|
|
||||||
if (scale.mode !== "compact") {
|
|
||||||
return config.groupBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.groupBy === "site" &&
|
|
||||||
config.siteIds.length > 0 &&
|
|
||||||
scale.capabilities.allowSiteGrouping
|
|
||||||
) {
|
|
||||||
return "site";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.groupBy === "label" &&
|
|
||||||
config.labelIds.length > 0 &&
|
|
||||||
scale.capabilities.allowLabelGrouping
|
|
||||||
) {
|
|
||||||
return "label";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectiveLauncherConfig(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): LauncherViewConfig {
|
|
||||||
const groupBy = getEffectiveGroupBy(scale, config);
|
|
||||||
if (groupBy === config.groupBy) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...config, groupBy };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldFetchLauncherGroups(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): boolean {
|
|
||||||
const groupBy = getEffectiveGroupBy(scale, config);
|
|
||||||
|
|
||||||
if (groupBy === "none") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scale.mode === "full") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
(scale.capabilities.allowSiteGrouping &&
|
|
||||||
groupBy === "site" &&
|
|
||||||
config.siteIds.length > 0) ||
|
|
||||||
(scale.capabilities.allowLabelGrouping &&
|
|
||||||
groupBy === "label" &&
|
|
||||||
config.labelIds.length > 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldShowLauncherGroupList(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): boolean {
|
|
||||||
return shouldFetchLauncherGroups(scale, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldShowSearchFirstGate(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
scale.mode === "compact" &&
|
|
||||||
scale.capabilities.requireSearchOrFilter &&
|
|
||||||
!hasActiveLauncherFilters(config)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldShowFlatResourceList(
|
|
||||||
scale: LauncherScaleInfo,
|
|
||||||
config: LauncherViewConfig
|
|
||||||
): boolean {
|
|
||||||
if (shouldShowSearchFirstGate(scale, config)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupBy = getEffectiveGroupBy(scale, config);
|
|
||||||
|
|
||||||
if (groupBy === "none") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scale.mode === "full") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !shouldShowLauncherGroupList(scale, config);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||||
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
|
|
||||||
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
import type {
|
import type {
|
||||||
LauncherGroup,
|
LauncherGroup,
|
||||||
LauncherScaleInfo,
|
|
||||||
LauncherDefaultViewOverrides,
|
|
||||||
LauncherViewConfig,
|
LauncherViewConfig,
|
||||||
LauncherViewRecord,
|
LauncherViewRecord,
|
||||||
ListLauncherGroupsResponse,
|
ListLauncherGroupsResponse,
|
||||||
ListLauncherScaleResponse,
|
|
||||||
ListLauncherViewsResponse
|
ListLauncherViewsResponse
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
export type LauncherPageData = {
|
export type LauncherPageData = {
|
||||||
views: LauncherViewRecord[];
|
views: LauncherViewRecord[];
|
||||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
|
||||||
activeViewId: LauncherActiveViewId;
|
activeViewId: LauncherActiveViewId;
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
savedConfig: LauncherViewConfig;
|
savedConfig: LauncherViewConfig;
|
||||||
scale: LauncherScaleInfo;
|
|
||||||
groups: LauncherGroup[];
|
groups: LauncherGroup[];
|
||||||
groupsPagination: {
|
groupsPagination: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -38,56 +32,19 @@ export async function fetchLauncherPageData(
|
|||||||
>
|
>
|
||||||
): Promise<LauncherPageData> {
|
): Promise<LauncherPageData> {
|
||||||
let views: LauncherViewRecord[] = [];
|
let views: LauncherViewRecord[] = [];
|
||||||
let defaultViewOverrides: LauncherDefaultViewOverrides = {
|
|
||||||
personal: null,
|
|
||||||
orgWide: null
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const viewsRes = await internal.get<
|
const viewsRes = await internal.get<
|
||||||
AxiosResponse<ListLauncherViewsResponse>
|
AxiosResponse<ListLauncherViewsResponse>
|
||||||
>(`/org/${orgId}/launcher/views`, cookieHeader);
|
>(`/org/${orgId}/launcher/views`, cookieHeader);
|
||||||
views = viewsRes.data.data.views;
|
views = viewsRes.data.data.views;
|
||||||
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
|
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
|
||||||
searchParams,
|
searchParams,
|
||||||
views,
|
views,
|
||||||
null,
|
null
|
||||||
defaultViewOverrides
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const scaleFilters = {
|
|
||||||
query: config.query,
|
|
||||||
groupBy: config.groupBy,
|
|
||||||
siteIds: config.siteIds,
|
|
||||||
labelIds: config.labelIds,
|
|
||||||
sort_by: config.sortBy,
|
|
||||||
order: config.order
|
|
||||||
};
|
|
||||||
|
|
||||||
let scale: LauncherScaleInfo = {
|
|
||||||
mode: "full",
|
|
||||||
resourceCount: 0,
|
|
||||||
siteGroupCount: 0,
|
|
||||||
labelGroupCount: 0,
|
|
||||||
capabilities: {
|
|
||||||
allowSiteGrouping: true,
|
|
||||||
allowLabelGrouping: true,
|
|
||||||
requireSearchOrFilter: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sp = buildLauncherSearchParams(scaleFilters, 1);
|
|
||||||
sp.delete("page");
|
|
||||||
sp.delete("pageSize");
|
|
||||||
const scaleRes = await internal.get<
|
|
||||||
AxiosResponse<ListLauncherScaleResponse>
|
|
||||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
|
|
||||||
scale = scaleRes.data.data.scale;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const groupFilters = {
|
const groupFilters = {
|
||||||
query: config.query,
|
query: config.query,
|
||||||
groupBy: config.groupBy,
|
groupBy: config.groupBy,
|
||||||
@@ -105,24 +62,20 @@ export async function fetchLauncherPageData(
|
|||||||
pageSize: 20
|
pageSize: 20
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shouldFetchLauncherGroups(scale, config)) {
|
try {
|
||||||
try {
|
const sp = buildLauncherSearchParams(groupFilters, 1);
|
||||||
const sp = buildLauncherSearchParams(groupFilters, 1);
|
const groupsRes = await internal.get<
|
||||||
const groupsRes = await internal.get<
|
AxiosResponse<ListLauncherGroupsResponse>
|
||||||
AxiosResponse<ListLauncherGroupsResponse>
|
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
||||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
groups = groupsRes.data.data.groups;
|
||||||
groups = groupsRes.data.data.groups;
|
groupsPagination = groupsRes.data.data.pagination;
|
||||||
groupsPagination = groupsRes.data.data.pagination;
|
} catch (e) {}
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
views,
|
views,
|
||||||
defaultViewOverrides,
|
|
||||||
activeViewId,
|
activeViewId,
|
||||||
config,
|
config,
|
||||||
savedConfig,
|
savedConfig,
|
||||||
scale,
|
|
||||||
groups,
|
groups,
|
||||||
groupsPagination
|
groupsPagination
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||||
import {
|
import {
|
||||||
defaultLauncherViewConfig,
|
defaultLauncherViewConfig,
|
||||||
getEffectiveDefaultLauncherConfig,
|
|
||||||
parseIdListParam,
|
parseIdListParam,
|
||||||
type LauncherDefaultViewOverrides,
|
|
||||||
type LauncherViewConfig,
|
type LauncherViewConfig,
|
||||||
type LauncherViewRecord
|
type LauncherViewRecord
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
@@ -66,13 +64,10 @@ export function isLauncherConfigEqual(
|
|||||||
|
|
||||||
export function getLauncherUrlBaseConfig(
|
export function getLauncherUrlBaseConfig(
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
views: LauncherViewRecord[],
|
views: LauncherViewRecord[]
|
||||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
|
||||||
): LauncherViewConfig {
|
): LauncherViewConfig {
|
||||||
if (viewId === "default") {
|
if (viewId === "default") {
|
||||||
return defaultViewOverrides
|
return defaultLauncherViewConfig;
|
||||||
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
|
|
||||||
: defaultLauncherViewConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedView = views.find((view) => view.viewId === viewId);
|
const savedView = views.find((view) => view.viewId === viewId);
|
||||||
@@ -118,7 +113,7 @@ function parseConfigOverrides(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupBy = searchParams.get("groupBy");
|
const groupBy = searchParams.get("groupBy");
|
||||||
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
|
if (groupBy === "site" || groupBy === "label") {
|
||||||
overrides.groupBy = groupBy;
|
overrides.groupBy = groupBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +172,7 @@ function isValidActiveViewId(
|
|||||||
export function resolveLauncherStateFromUrl(
|
export function resolveLauncherStateFromUrl(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
views: LauncherViewRecord[],
|
views: LauncherViewRecord[],
|
||||||
fallbackViewId: LauncherActiveViewId | null,
|
fallbackViewId: LauncherActiveViewId | null
|
||||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
|
||||||
): ResolvedLauncherState {
|
): ResolvedLauncherState {
|
||||||
const parsed = parseLauncherUrlState(searchParams);
|
const parsed = parseLauncherUrlState(searchParams);
|
||||||
|
|
||||||
@@ -194,26 +188,18 @@ export function resolveLauncherStateFromUrl(
|
|||||||
: "default";
|
: "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedConfig = getLauncherUrlBaseConfig(
|
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
|
||||||
activeViewId,
|
|
||||||
views,
|
|
||||||
defaultViewOverrides
|
|
||||||
);
|
|
||||||
|
|
||||||
let config: LauncherViewConfig;
|
let config: LauncherViewConfig;
|
||||||
if (hasLauncherConfigParams(searchParams)) {
|
if (hasLauncherConfigParams(searchParams)) {
|
||||||
config = resolveLauncherConfig(
|
config = resolveLauncherConfig(
|
||||||
getEffectiveDefaultLauncherConfig(
|
defaultLauncherViewConfig,
|
||||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
|
||||||
),
|
|
||||||
parsed.configOverrides
|
parsed.configOverrides
|
||||||
);
|
);
|
||||||
} else if (activeViewId !== "default") {
|
} else if (activeViewId !== "default") {
|
||||||
config = savedConfig;
|
config = savedConfig;
|
||||||
} else {
|
} else {
|
||||||
config = getEffectiveDefaultLauncherConfig(
|
config = defaultLauncherViewConfig;
|
||||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -50,16 +50,11 @@ import type {
|
|||||||
ListLauncherGroupsResponse,
|
ListLauncherGroupsResponse,
|
||||||
ListLauncherLabelsResponse,
|
ListLauncherLabelsResponse,
|
||||||
ListLauncherResourcesResponse,
|
ListLauncherResourcesResponse,
|
||||||
ListLauncherScaleResponse,
|
|
||||||
ListLauncherSitesResponse,
|
ListLauncherSitesResponse,
|
||||||
ListLauncherViewsResponse,
|
ListLauncherViewsResponse,
|
||||||
LauncherListQuery,
|
LauncherListQuery,
|
||||||
LauncherResource,
|
|
||||||
LauncherViewConfig
|
LauncherViewConfig
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
|
||||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
|
||||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
|
|
||||||
@@ -1194,13 +1189,13 @@ export const launcherQueries = {
|
|||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListLauncherViewsResponse>
|
AxiosResponse<ListLauncherViewsResponse>
|
||||||
>(`/org/${orgId}/launcher/views`, { signal });
|
>(`/org/${orgId}/launcher/views`, { signal });
|
||||||
return res.data.data;
|
return res.data.data.views;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
sites: ({
|
sites: ({
|
||||||
orgId,
|
orgId,
|
||||||
query,
|
query,
|
||||||
perPage = 20
|
perPage = 500
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -1232,7 +1227,7 @@ export const launcherQueries = {
|
|||||||
labels: ({
|
labels: ({
|
||||||
orgId,
|
orgId,
|
||||||
query,
|
query,
|
||||||
perPage = 20
|
perPage = 500
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -1303,62 +1298,5 @@ export const launcherQueries = {
|
|||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
return page * pageSize < total ? nextPage : undefined;
|
return page * pageSize < total ? nextPage : undefined;
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
scale: (orgId: string, filters: LauncherQueryFilters) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
const sp = buildLauncherSearchParams(filters, 1);
|
|
||||||
sp.delete("page");
|
|
||||||
sp.delete("pageSize");
|
|
||||||
sp.delete("groupKey");
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<ListLauncherScaleResponse>
|
|
||||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
|
|
||||||
return res.data.data.scale;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [
|
|
||||||
"ORG",
|
|
||||||
orgId,
|
|
||||||
"LAUNCHER",
|
|
||||||
"RESOURCE_DETAIL",
|
|
||||||
resource?.launcherResourceKey ?? null
|
|
||||||
] as const,
|
|
||||||
enabled: resource != null,
|
|
||||||
queryFn: async ({ signal, meta }) => {
|
|
||||||
if (!resource) {
|
|
||||||
throw new Error("Resource is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.resourceType === "public") {
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<GetResourceResponse>
|
|
||||||
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
|
|
||||||
const resourceData = res.data.data;
|
|
||||||
const authRes = await meta!.api.get<
|
|
||||||
AxiosResponse<GetResourceAuthInfoResponse>
|
|
||||||
>(`/resource/${resourceData.resourceGuid}/auth`, {
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
resourceType: "public" as const,
|
|
||||||
data: resourceData,
|
|
||||||
authInfo: authRes.data.data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteResourceId =
|
|
||||||
resource.siteResourceId ?? resource.resourceId;
|
|
||||||
const res = await meta!.api.get<
|
|
||||||
AxiosResponse<GetSiteResourceResponse>
|
|
||||||
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
|
|
||||||
return {
|
|
||||||
resourceType: "site" as const,
|
|
||||||
data: res.data.data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
32
src/lib/setClientCookie.ts
Normal file
32
src/lib/setClientCookie.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Set a cookie on the client side in javascript code, not on the server
|
||||||
|
* @param name
|
||||||
|
* @param value
|
||||||
|
* @param days
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function setClientCookie(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
options: {
|
||||||
|
days?: number;
|
||||||
|
path?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
||||||
|
|
||||||
|
if (options.days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + options.days * 864e5);
|
||||||
|
cookie += `; expires=${date.toUTCString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie += `; path=${options.path ?? "/"}`;
|
||||||
|
|
||||||
|
if (options.secure) cookie += "; Secure";
|
||||||
|
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
||||||
|
|
||||||
|
document.cookie = cookie;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user