diff --git a/messages/en-US.json b/messages/en-US.json
index 4701d4da1..64526b405 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -864,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured",
- "policyAuthHeaderName": "Header name",
- "policyAuthHeaderValue": "Expected value",
+ "policyAuthHeaderName": "Username",
+ "policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -3577,18 +3577,23 @@
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher",
- "resourceLauncherDescription": "View resource details and launch them from one place",
- "resourceLauncherSearchPlaceholder": "Search all sites...",
+ "resourceLauncherDescription": "View all available resources and launch them from one central hub",
+ "resourceLauncherSearchPlaceholder": "Search your resources...",
"resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View",
+ "resourceLauncherSaveDefaultPersonal": "Save for Me",
"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",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter",
+ "resourceLauncherFilterWithCount": "Filter, {count} applied",
"resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending",
@@ -3596,6 +3601,7 @@
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
+ "resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
@@ -3603,8 +3609,20 @@
"resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View",
+ "resourceLauncherDeleteViewTitle": "Delete View",
+ "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
+ "resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin",
- "resourceLauncherResourceDetailsDescription": "View details for this resource.",
+ "resourceLauncherResourceDetailsDescription": "Connection information and status 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",
"resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -3613,6 +3631,12 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"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.",
+ "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",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
diff --git a/server/db/pg/poolConfig.ts b/server/db/pg/poolConfig.ts
index b893c2159..af6c10520 100644
--- a/server/db/pg/poolConfig.ts
+++ b/server/db/pg/poolConfig.ts
@@ -18,7 +18,17 @@ export function createPoolConfig(
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
// Allow connections to be released and recreated more aggressively
// 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"
};
}
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 683c572b6..5375e4fc2 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", {
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
+ isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 28797bcd9..5f0c2f9a7 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", {
}),
name: text("name").notNull(),
config: text("config").notNull(),
+ isDefault: integer("isDefault", { mode: "boolean" })
+ .notNull()
+ .default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts
index 6b91b3462..59e7103d8 100644
--- a/server/lib/blueprints/applyNewtDockerBlueprint.ts
+++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT"
});
} catch (error) {
- logger.error(`Failed to update database from config: ${error}`);
+ logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts
index 92d5d9cfe..a2dce7818 100644
--- a/server/private/middlewares/verifySubscription.ts
+++ b/server/private/middlewares/verifySubscription.ts
@@ -65,6 +65,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
return next();
} catch (e) {
+ console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
diff --git a/server/routers/blueprints/applyJSONBlueprint.ts b/server/routers/blueprints/applyJSONBlueprint.ts
index 8ad41e9e4..ae231f8c8 100644
--- a/server/routers/blueprints/applyJSONBlueprint.ts
+++ b/server/routers/blueprints/applyJSONBlueprint.ts
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API"
});
} catch (error) {
- logger.error(`Failed to update database from config: ${error}`);
+ logger.debug(`Failed to update database from config: ${error}`);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 5d770c064..8a22393ca 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -327,6 +327,14 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg
);
+authenticated.get(
+ "/org/:orgId/site-resource/:siteResourceId",
+ verifyOrgAccess,
+ verifySiteResourceAccess,
+ verifyUserHasAction(ActionsEnum.getSiteResource),
+ siteResource.getSiteResource
+);
+
authenticated.get(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
@@ -470,6 +478,12 @@ authenticated.get(
launcher.listLauncherGroups
);
+authenticated.get(
+ "/org/:orgId/launcher/scale",
+ verifyOrgAccess,
+ launcher.listLauncherScale
+);
+
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
@@ -494,6 +508,12 @@ authenticated.get(
launcher.listLauncherViews
);
+authenticated.post(
+ "/org/:orgId/launcher/invalidate-cache",
+ verifyOrgAccess,
+ launcher.invalidateLauncherCache
+);
+
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
@@ -506,6 +526,18 @@ authenticated.put(
launcher.updateLauncherView
);
+authenticated.put(
+ "/org/:orgId/launcher/default-view",
+ verifyOrgAccess,
+ launcher.upsertLauncherDefaultView
+);
+
+authenticated.delete(
+ "/org/:orgId/launcher/default-view",
+ verifyOrgAccess,
+ launcher.deleteLauncherDefaultView
+);
+
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts
index 593bad304..195dfc555 100644
--- a/server/routers/launcher/createLauncherView.ts
+++ b/server/routers/launcher/createLauncherView.ts
@@ -79,7 +79,8 @@ export async function createLauncherView(
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
- isOrgWide: created.userId == null
+ isOrgWide: created.userId == null,
+ isDefault: created.isDefault
},
success: true,
error: false,
diff --git a/server/routers/launcher/deleteLauncherDefaultView.ts b/server/routers/launcher/deleteLauncherDefaultView.ts
new file mode 100644
index 000000000..50a1a7beb
--- /dev/null
+++ b/server/routers/launcher/deleteLauncherDefaultView.ts
@@ -0,0 +1,104 @@
+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 {
+ 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"
+ )
+ );
+ }
+}
diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts
index beaf1b433..8377872fb 100644
--- a/server/routers/launcher/deleteLauncherView.ts
+++ b/server/routers/launcher/deleteLauncherView.ts
@@ -46,6 +46,15 @@ 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 isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts
index 939bf74bc..1c3fed44c 100644
--- a/server/routers/launcher/index.ts
+++ b/server/routers/launcher/index.ts
@@ -1,5 +1,6 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
+export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
+export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
+export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
+export { invalidateLauncherCache } from "./invalidateLauncherCache";
diff --git a/server/routers/launcher/invalidateLauncherCache.ts b/server/routers/launcher/invalidateLauncherCache.ts
new file mode 100644
index 000000000..5e4ff1618
--- /dev/null
+++ b/server/routers/launcher/invalidateLauncherCache.ts
@@ -0,0 +1,65 @@
+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 {
+ 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 {
+ 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"
+ )
+ );
+ }
+}
diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts
new file mode 100644
index 000000000..a0c2fd066
--- /dev/null
+++ b/server/routers/launcher/launcherDefaultView.ts
@@ -0,0 +1,137 @@
+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
+): 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
+): 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 });
+}
diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts
index 8fe5e8ef8..246f86688 100644
--- a/server/routers/launcher/launcherResourceAccess.ts
+++ b/server/routers/launcher/launcherResourceAccess.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { db } from "@server/db";
import {
exitNodes,
@@ -31,13 +32,15 @@ import {
isNull,
like,
or,
- sql
+ sql,
+ type SQL
} from "drizzle-orm";
import {
formatPublicResourceAccess,
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
+ LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -59,6 +62,68 @@ export type AccessibleIds = {
};
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
+) {
+ 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(
orgId: string,
@@ -194,6 +259,21 @@ function searchPattern(query: string) {
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(
query: string,
labelsFeatureEnabled: boolean
@@ -205,7 +285,17 @@ function buildSearchConditionForPublic(
const queryList = [
like(sql`LOWER(${resources.name})`, 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) {
@@ -238,6 +328,11 @@ function buildSearchConditionForSiteResource(
const queryList = [
like(sql`LOWER(${siteResources.name})`, 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.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
@@ -262,6 +357,79 @@ function buildSearchConditionForSiteResource(
return or(...queryList);
}
+async function filterPublicResourceIdsByTextSearch(
+ orgId: string,
+ resourceIds: number[],
+ query: string,
+ labelsFeatureEnabled: boolean
+): Promise {
+ 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 {
+ 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 {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
@@ -588,9 +756,8 @@ async function listSiteGroups(
});
const total = groups.length;
- const offset = (query.page - 1) * query.pageSize;
return {
- groups: groups.slice(offset, offset + query.pageSize),
+ groups,
total
};
}
@@ -788,26 +955,53 @@ async function listLabelGroups(
});
const total = groups.length;
- const offset = (query.page - 1) * query.pageSize;
return {
- groups: groups.slice(offset, offset + query.pageSize),
+ groups,
total
};
}
+async function listLauncherGroupsForUserUncached(
+ orgId: string,
+ userId: string,
+ userRoleIds: number[],
+ query: LauncherListQuery
+): Promise {
+ 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(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> {
- const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
+ const queryHash = launcherGroupsQueryHash(userRoleIds, query);
+ const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
+ const cached = await cache.get(cacheKey);
- if (query.groupBy === "label") {
- return listLabelGroups(orgId, accessible, query);
+ let result = cached;
+ if (!result) {
+ result = await listLauncherGroupsForUserUncached(
+ orgId,
+ userId,
+ userRoleIds,
+ query
+ );
+ await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
}
- return listSiteGroups(orgId, accessible, query);
+ const offset = (query.page - 1) * query.pageSize;
+ return {
+ groups: result.groups.slice(offset, offset + query.pageSize),
+ total: result.total
+ };
}
async function mapPublicResources(
@@ -1018,26 +1212,6 @@ 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(
items: LauncherResource[],
order: "asc" | "desc"
@@ -1050,12 +1224,12 @@ function sortLauncherResources(
});
}
-export async function listLauncherResourcesForUser(
+async function listLauncherResourcesForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
-): Promise<{ resources: LauncherResource[]; total: number }> {
+): Promise {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1128,6 +1302,27 @@ export async function listLauncherResourcesForUser(
}
}
+ 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(
orgId,
filteredResourceIds,
@@ -1159,21 +1354,48 @@ export async function listLauncherResourcesForUser(
]);
let items = [...publicItems, ...siteItems];
- items = filterResourcesBySearch(items, query.query);
- if (query.groupBy === "label") {
- items = filterResourcesByLabel(items, query.groupKey);
- } else if (query.groupBy === "site") {
- items = filterResourcesBySite(items, query.groupKey);
+ if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
+ if (query.groupBy === "label") {
+ items = filterResourcesByLabel(items, query.groupKey);
+ } else if (query.groupBy === "site") {
+ items = filterResourcesBySite(items, query.groupKey);
+ }
}
items = sortLauncherResources(items, query.order);
- const total = items.length;
+ return {
+ 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(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;
return {
- resources: items.slice(offset, offset + query.pageSize),
- total
+ resources: result.items.slice(offset, offset + query.pageSize),
+ total: result.total
};
}
diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts
new file mode 100644
index 000000000..8d512834b
--- /dev/null
+++ b/server/routers/launcher/launcherScale.ts
@@ -0,0 +1,133 @@
+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 {
+ const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
+ const cached = await cache.get(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 {
+ const counts = await getLauncherScaleCountsForUser(
+ orgId,
+ userId,
+ userRoleIds
+ );
+
+ return {
+ mode: counts.mode,
+ resourceCount: counts.resourceCount,
+ siteGroupCount: counts.siteGroupCount,
+ labelGroupCount: counts.labelGroupCount,
+ capabilities: buildScaleCapabilities(counts, query)
+ };
+}
diff --git a/server/routers/launcher/listLauncherScale.ts b/server/routers/launcher/listLauncherScale.ts
new file mode 100644
index 000000000..2fc907b17
--- /dev/null
+++ b/server/routers/launcher/listLauncherScale.ts
@@ -0,0 +1,60 @@
+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 {
+ 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"
+ )
+ );
+ }
+}
diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts
index e176bae46..8cdcf76ef 100644
--- a/server/routers/launcher/listLauncherViews.ts
+++ b/server/routers/launcher/listLauncherViews.ts
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
-import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
-
-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
- };
-}
+import {
+ extractDefaultViewOverrides,
+ listVisibleLauncherViews
+} from "./launcherDefaultView";
export async function listLauncherViews(
req: Request,
@@ -51,7 +39,8 @@ export async function listLauncherViews(
return response(res, {
data: {
- views: rows.map(mapViewRow)
+ views: listVisibleLauncherViews(rows),
+ defaultViewOverrides: extractDefaultViewOverrides(rows)
},
success: true,
error: false,
diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts
index 0a252d74d..f235e824d 100644
--- a/server/routers/launcher/types.ts
+++ b/server/routers/launcher/types.ts
@@ -2,9 +2,10 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
+export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
- groupBy: z.enum(["site", "label"]).default("site"),
+ groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
createdAt: string;
updatedAt: string;
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 = {
views: LauncherViewRecord[];
+ defaultViewOverrides: LauncherDefaultViewOverrides;
};
export const launcherFilterListQuerySchema = z.strictObject({
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int()
.positive()
.optional()
- .catch(500)
- .default(500),
+ .catch(20)
+ .default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
- groupBy: z.enum(["site", "label"]).optional().default("site"),
+ groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { 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;
diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts
index 9450da153..e4373151f 100644
--- a/server/routers/launcher/updateLauncherView.ts
+++ b/server/routers/launcher/updateLauncherView.ts
@@ -66,6 +66,15 @@ 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 isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
@@ -135,7 +144,8 @@ export async function updateLauncherView(
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
- isOrgWide: updated.userId == null
+ isOrgWide: updated.userId == null,
+ isDefault: updated.isDefault
},
success: true,
error: false,
diff --git a/server/routers/launcher/upsertLauncherDefaultView.ts b/server/routers/launcher/upsertLauncherDefaultView.ts
new file mode 100644
index 000000000..dd9c8bdba
--- /dev/null
+++ b/server/routers/launcher/upsertLauncherDefaultView.ts
@@ -0,0 +1,82 @@
+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 {
+ 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"
+ )
+ );
+ }
+}
diff --git a/server/routers/newt/handleApplyBlueprintMessage.ts b/server/routers/newt/handleApplyBlueprintMessage.ts
index 908ff048a..ccc1a6a82 100644
--- a/server/routers/newt/handleApplyBlueprintMessage.ts
+++ b/server/routers/newt/handleApplyBlueprintMessage.ts
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT"
});
} catch (error) {
- logger.error(`Failed to update database from config: ${error}`);
+ logger.debug(`Failed to update database from config: ${error}`);
return {
message: {
type: "newt/blueprint/results",
diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts
index 506c2caa9..6d5ef22cd 100644
--- a/server/routers/resource/addEmailToResourceWhitelist.ts
+++ b/server/routers/resource/addEmailToResourceWhitelist.ts
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { resources, resourceWhitelist } from "@server/db";
+import {
+ resources,
+ resourceWhitelist,
+ resourcePolicies,
+ resourcePolicyWhiteList
+} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,40 +108,99 @@ export async function addEmailToResourceWhitelist(
);
}
- if (!resource.emailWhitelistEnabled) {
- return next(
- createHttpError(
- HttpCode.BAD_REQUEST,
- "Email whitelist is not enabled for this resource"
- )
- );
+ // A shared policy takes precedence over the resource's inline
+ // (default) policy, which takes precedence over the resource's own
+ // direct whitelist fields. This mirrors the precedence used at
+ // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
+ const policyId =
+ resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
+
+ if (policyId !== null) {
+ const [policy] = await db
+ .select()
+ .from(resourcePolicies)
+ .where(eq(resourcePolicies.resourcePolicyId, policyId));
+
+ if (!policy) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Resource policy not found"
+ )
+ );
+ }
+
+ if (!policy.emailWhitelistEnabled) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Email whitelist is not enabled for this resource"
+ )
+ );
+ }
+
+ const existingEntry = await db
+ .select()
+ .from(resourcePolicyWhiteList)
+ .where(
+ and(
+ eq(
+ resourcePolicyWhiteList.resourcePolicyId,
+ policyId
+ ),
+ eq(resourcePolicyWhiteList.email, email)
+ )
+ );
+
+ if (existingEntry.length > 0) {
+ return next(
+ createHttpError(
+ HttpCode.CONFLICT,
+ "Email already exists in whitelist"
+ )
+ );
+ }
+
+ await db.insert(resourcePolicyWhiteList).values({
+ email,
+ resourcePolicyId: policyId
+ });
+ } else {
+ if (!resource.emailWhitelistEnabled) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Email whitelist is not enabled for this resource"
+ )
+ );
+ }
+
+ // Check if email already exists in whitelist
+ const existingEntry = await db
+ .select()
+ .from(resourceWhitelist)
+ .where(
+ and(
+ eq(resourceWhitelist.resourceId, resourceId),
+ eq(resourceWhitelist.email, email)
+ )
+ );
+
+ if (existingEntry.length > 0) {
+ return next(
+ createHttpError(
+ HttpCode.CONFLICT,
+ "Email already exists in whitelist"
+ )
+ );
+ }
+
+ await db.insert(resourceWhitelist).values({
+ email,
+ resourceId
+ });
}
- // Check if email already exists in whitelist
- const existingEntry = await db
- .select()
- .from(resourceWhitelist)
- .where(
- and(
- eq(resourceWhitelist.resourceId, resourceId),
- eq(resourceWhitelist.email, email)
- )
- );
-
- if (existingEntry.length > 0) {
- return next(
- createHttpError(
- HttpCode.CONFLICT,
- "Email already exists in whitelist"
- )
- );
- }
-
- await db.insert(resourceWhitelist).values({
- email,
- resourceId
- });
-
return response(res, {
data: {},
success: true,
diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts
index 2d882d49e..0bebe6a71 100644
--- a/server/routers/resource/getResourceWhitelist.ts
+++ b/server/routers/resource/getResourceWhitelist.ts
@@ -96,13 +96,17 @@ export async function getResourceWhitelist(
);
}
- const isInlinePolicy =
- resource.resourcePolicyId === null &&
- resource.defaultResourcePolicyId !== null;
+ // A shared policy takes precedence over the resource's inline
+ // (default) policy, which takes precedence over the resource's own
+ // direct whitelist fields. This mirrors the precedence used at
+ // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
+ const policyId =
+ resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
- const whitelist = isInlinePolicy
- ? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
- : await queryWhitelist(resourceId);
+ const whitelist =
+ policyId !== null
+ ? await queryPolicyWhitelist(policyId)
+ : await queryWhitelist(resourceId);
return response(res, {
data: {
diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts
index f15a3beda..d1deb9d39 100644
--- a/server/routers/resource/listResources.ts
+++ b/server/routers/resource/listResources.ts
@@ -616,8 +616,8 @@ export async function listResources(
and(
inArray(resources.mode, browserGatewayModes),
or(
- eq(effectiveSso, true),
- eq(effectiveWhitelist, true),
+ effectiveSso,
+ effectiveWhitelist,
not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push(
and(
inArray(resources.mode, browserGatewayModes),
- not(eq(effectiveSso, true)),
- not(eq(effectiveWhitelist, true)),
+ not(effectiveSso),
+ not(effectiveWhitelist),
isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId),
isNull(effectivePasswordId)
diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts
index 615e62438..03d961335 100644
--- a/server/routers/resource/removeEmailFromResourceWhitelist.ts
+++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { resources, resourceWhitelist } from "@server/db";
+import {
+ resources,
+ resourceWhitelist,
+ resourcePolicies,
+ resourcePolicyWhiteList
+} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -102,44 +107,110 @@ export async function removeEmailFromResourceWhitelist(
);
}
- if (!resource.emailWhitelistEnabled) {
- return next(
- createHttpError(
- HttpCode.BAD_REQUEST,
- "Email whitelist is not enabled for this resource"
- )
- );
+ // A shared policy takes precedence over the resource's inline
+ // (default) policy, which takes precedence over the resource's own
+ // direct whitelist fields. This mirrors the precedence used at
+ // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
+ const policyId =
+ resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
+
+ if (policyId !== null) {
+ const [policy] = await db
+ .select()
+ .from(resourcePolicies)
+ .where(eq(resourcePolicies.resourcePolicyId, policyId));
+
+ if (!policy) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Resource policy not found"
+ )
+ );
+ }
+
+ if (!policy.emailWhitelistEnabled) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Email whitelist is not enabled for this resource"
+ )
+ );
+ }
+
+ const existingEntry = await db
+ .select()
+ .from(resourcePolicyWhiteList)
+ .where(
+ and(
+ eq(
+ resourcePolicyWhiteList.resourcePolicyId,
+ policyId
+ ),
+ eq(resourcePolicyWhiteList.email, email)
+ )
+ );
+
+ if (existingEntry.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Email not found in whitelist"
+ )
+ );
+ }
+
+ await db
+ .delete(resourcePolicyWhiteList)
+ .where(
+ and(
+ eq(
+ resourcePolicyWhiteList.resourcePolicyId,
+ policyId
+ ),
+ eq(resourcePolicyWhiteList.email, email)
+ )
+ );
+ } else {
+ if (!resource.emailWhitelistEnabled) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Email whitelist is not enabled for this resource"
+ )
+ );
+ }
+
+ // Check if email exists in whitelist
+ const existingEntry = await db
+ .select()
+ .from(resourceWhitelist)
+ .where(
+ and(
+ eq(resourceWhitelist.resourceId, resourceId),
+ eq(resourceWhitelist.email, email)
+ )
+ );
+
+ if (existingEntry.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Email not found in whitelist"
+ )
+ );
+ }
+
+ await db
+ .delete(resourceWhitelist)
+ .where(
+ and(
+ eq(resourceWhitelist.resourceId, resourceId),
+ eq(resourceWhitelist.email, email)
+ )
+ );
}
- // Check if email exists in whitelist
- const existingEntry = await db
- .select()
- .from(resourceWhitelist)
- .where(
- and(
- eq(resourceWhitelist.resourceId, resourceId),
- eq(resourceWhitelist.email, email)
- )
- );
-
- if (existingEntry.length === 0) {
- return next(
- createHttpError(
- HttpCode.NOT_FOUND,
- "Email not found in whitelist"
- )
- );
- }
-
- await db
- .delete(resourceWhitelist)
- .where(
- and(
- eq(resourceWhitelist.resourceId, resourceId),
- eq(resourceWhitelist.email, email)
- )
- );
-
return response(res, {
data: {},
success: true,
diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts
index 697ae4541..10bf5b480 100644
--- a/server/routers/resource/setResourceWhitelist.ts
+++ b/server/routers/resource/setResourceWhitelist.ts
@@ -109,13 +109,14 @@ export async function setResourceWhitelist(
);
}
- const isInlinePolicy =
- resource.resourcePolicyId === null &&
- resource.defaultResourcePolicyId !== null;
-
- if (isInlinePolicy) {
- const policyId = resource.defaultResourcePolicyId!;
+ // A shared policy takes precedence over the resource's inline
+ // (default) policy, which takes precedence over the resource's own
+ // direct whitelist fields. This mirrors the precedence used at
+ // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
+ const policyId =
+ resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
+ if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index 22159f1c6..fbba73d4b 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
example: "service.example.internal"
}),
- userIds: z.array(z.string()),
- roleIds: z.array(z.int()),
- clientIds: z.array(z.int()),
+ userIds: z.array(z.string()).optional(),
+ roleIds: z.array(z.int()).optional(),
+ clientIds: z.array(z.int()).optional(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
@@ -167,6 +167,10 @@ const updateSiteResourceSchema = z
)
.refine(
(data) => {
+ // if neither is provided, the existing site associations are left unchanged
+ if (data.siteIds === undefined && data.siteId === undefined) {
+ return true;
+ }
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
@@ -263,7 +267,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
- siteIds: siteIdsInput = [], // because it can change
+ siteIds: siteIdsInput,
siteId,
niceId,
mode,
@@ -287,9 +291,14 @@ export async function updateSiteResource(
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
- const siteIds = [...siteIdsInput];
- if (siteId !== undefined && !siteIds.includes(siteId)) {
- siteIds.push(siteId);
+ const siteIdsProvided =
+ siteIdsInput !== undefined || siteId !== undefined;
+ let siteIds: number[] | undefined;
+ if (siteIdsProvided) {
+ siteIds = [...(siteIdsInput ?? [])];
+ if (siteId !== undefined && !siteIds.includes(siteId)) {
+ siteIds.push(siteId);
+ }
}
// Check if site resource exists
@@ -356,20 +365,22 @@ export async function updateSiteResource(
}
// Verify the site exists and belongs to the org
- const sitesToAssign = await db
- .select()
- .from(sites)
- .where(
- and(
- inArray(sites.siteId, siteIds),
- eq(sites.orgId, existingSiteResource.orgId)
- )
- );
+ if (siteIds !== undefined) {
+ const sitesToAssign = await db
+ .select()
+ .from(sites)
+ .where(
+ and(
+ inArray(sites.siteId, siteIds),
+ eq(sites.orgId, existingSiteResource.orgId)
+ )
+ );
- if (sitesToAssign.length !== siteIds.length) {
- return next(
- createHttpError(HttpCode.NOT_FOUND, "Some site not found")
- );
+ if (sitesToAssign.length !== siteIds.length) {
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
+ }
}
// Only check if destination is an IP address
@@ -463,7 +474,8 @@ export async function updateSiteResource(
}
let updatedSiteResource: SiteResource | undefined;
- let updatedSiteIds: number[] = [];
+ // defaults to the existing sites; only overwritten below if siteIds/siteId was provided
+ let updatedSiteIds: number[] = [...existingSiteIds];
await db.transaction(async (trx) => {
// Update the site resource
const sshPamSet =
@@ -522,81 +534,103 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
- // delete the site - site resources associations
- await trx
- .delete(siteNetworks)
- .where(
- eq(siteNetworks.networkId, updatedSiteResource.networkId!)
- );
-
- for (const siteId of siteIds) {
- await trx.insert(siteNetworks).values({
- siteId: siteId,
- networkId: updatedSiteResource.networkId!
- });
- updatedSiteIds.push(siteId);
- }
-
- await trx
- .delete(clientSiteResources)
- .where(eq(clientSiteResources.siteResourceId, siteResourceId));
-
- if (clientIds.length > 0) {
- await trx.insert(clientSiteResources).values(
- clientIds.map((clientId) => ({
- clientId,
- siteResourceId
- }))
- );
- }
-
- await trx
- .delete(userSiteResources)
- .where(eq(userSiteResources.siteResourceId, siteResourceId));
-
- if (userIds.length > 0) {
- await trx.insert(userSiteResources).values(
- userIds.map((userId) => ({
- userId,
- siteResourceId
- }))
- );
- }
-
- // Get all admin role IDs for this org to exclude from deletion
- const adminRoles = await trx
- .select()
- .from(roles)
- .where(
- and(
- eq(roles.isAdmin, true),
- eq(roles.orgId, updatedSiteResource.orgId)
- )
- );
- const adminRoleIds = adminRoles.map((role) => role.roleId);
-
- if (adminRoleIds.length > 0) {
- await trx.delete(roleSiteResources).where(
- and(
- eq(roleSiteResources.siteResourceId, siteResourceId),
- ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
- )
- );
- } else {
+ if (siteIds !== undefined) {
+ // delete the site - site resources associations
await trx
- .delete(roleSiteResources)
+ .delete(siteNetworks)
.where(
- eq(roleSiteResources.siteResourceId, siteResourceId)
+ eq(
+ siteNetworks.networkId,
+ updatedSiteResource.networkId!
+ )
);
+
+ updatedSiteIds = [];
+ for (const siteId of siteIds) {
+ await trx.insert(siteNetworks).values({
+ siteId: siteId,
+ networkId: updatedSiteResource.networkId!
+ });
+ updatedSiteIds.push(siteId);
+ }
}
- if (roleIds.length > 0) {
- await trx.insert(roleSiteResources).values(
- roleIds.map((roleId) => ({
- roleId,
- siteResourceId
- }))
- );
+ if (clientIds !== undefined) {
+ await trx
+ .delete(clientSiteResources)
+ .where(
+ eq(clientSiteResources.siteResourceId, siteResourceId)
+ );
+
+ if (clientIds.length > 0) {
+ await trx.insert(clientSiteResources).values(
+ clientIds.map((clientId) => ({
+ clientId,
+ siteResourceId
+ }))
+ );
+ }
+ }
+
+ if (userIds !== undefined) {
+ await trx
+ .delete(userSiteResources)
+ .where(
+ eq(userSiteResources.siteResourceId, siteResourceId)
+ );
+
+ if (userIds.length > 0) {
+ await trx.insert(userSiteResources).values(
+ userIds.map((userId) => ({
+ userId,
+ siteResourceId
+ }))
+ );
+ }
+ }
+
+ if (roleIds !== undefined) {
+ // Get all admin role IDs for this org to exclude from deletion
+ const adminRoles = await trx
+ .select()
+ .from(roles)
+ .where(
+ and(
+ eq(roles.isAdmin, true),
+ eq(roles.orgId, updatedSiteResource.orgId)
+ )
+ );
+ const adminRoleIds = adminRoles.map((role) => role.roleId);
+
+ if (adminRoleIds.length > 0) {
+ await trx.delete(roleSiteResources).where(
+ and(
+ eq(
+ roleSiteResources.siteResourceId,
+ siteResourceId
+ ),
+ ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
+ )
+ );
+ } else {
+ await trx
+ .delete(roleSiteResources)
+ .where(
+ eq(
+ roleSiteResources.siteResourceId,
+ siteResourceId
+ )
+ );
+ }
+
+ if (roleIds.length > 0) {
+ await trx.insert(roleSiteResources).values(
+ roleIds.map((roleId) => ({
+ roleId,
+ siteResourceId
+ }))
+ );
+ }
}
logger.info(`Updated site resource ${siteResourceId}`);
diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx
index 7abb1b317..d543b2752 100644
--- a/src/app/[orgId]/page.tsx
+++ b/src/app/[orgId]/page.tsx
@@ -82,9 +82,11 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId}
isAdmin={isAdminOrOwner}
views={launcherData.views}
+ defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
+ scale={launcherData.scale}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
/>
diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
index d9e2fefe0..e991d7b97 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
-import { useActionState } from "react";
+import { useActionState, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
});
const [, formAction, saveLoading] = useActionState(onSubmit, null);
+ const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
+ if (!headersValid) {
+ toast({
+ variant: "destructive",
+ title: t("settingsErrorUpdate"),
+ description: t("headersValidationError")
+ });
+ return;
+ }
+
const data = form.getValues();
const res = await api
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
onChange={
field.onChange
}
+ onValidityChange={
+ setHeadersValid
+ }
rows={4}
/>
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
+ {isCompactMode ? (
+
+ {t("resourceLauncherCompactGroupingHint")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowSiteGrouping ? (
+
+ {t("resourceLauncherSiteGroupingDisabled")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowLabelGrouping ? (
+
+ {t("resourceLauncherLabelGroupingDisabled")}
+
+ ) : null}
@@ -116,16 +153,6 @@ export function LauncherSettingsMenu({
/>
-
- {!isDefaultView ? (
-
- {t("resourceLauncherDeleteView")}
-
- ) : null}
diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx
index 98d8f7289..0b0261560 100644
--- a/src/components/resource-launcher/LauncherViewTabs.tsx
+++ b/src/components/resource-launcher/LauncherViewTabs.tsx
@@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = {
isAdmin: boolean;
isOrgWideView: boolean;
hasUnsavedChanges: boolean;
+ canResetSystemDefault: boolean;
onSaveToCurrent: () => void;
onSaveAsNew: () => void;
onSaveForEveryone: () => void;
onMakePersonal: () => void;
onResetView: () => void;
+ onResetSystemDefault: () => void;
+ onDeleteView: () => void;
};
export function LauncherSaveViewMenu({
@@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({
isAdmin,
isOrgWideView,
hasUnsavedChanges,
+ canResetSystemDefault,
onSaveToCurrent,
onSaveAsNew,
onSaveForEveryone,
onMakePersonal,
- onResetView
+ onResetView,
+ onResetSystemDefault,
+ onDeleteView
}: LauncherSaveViewMenuProps) {
const t = useTranslations();
+ const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
return (
@@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({
>
) : null}
- {!isDefaultView && (isAdmin || !isOrgWideView) ? (
+ {isDefaultView && canResetSystemDefault ? (
+ <>
+
+ {t("resourceLauncherResetSystemDefault")}
+
+
+ >
+ ) : null}
+ {isDefaultView && hasUnsavedChanges ? (
+ <>
+
+ {t("resourceLauncherSaveDefaultPersonal")}
+
+ {isAdmin ? (
+
+ {t("resourceLauncherSaveForEveryone")}
+
+ ) : null}
+ >
+ ) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
{t("resourceLauncherSaveToCurrentView")}
@@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({
{t("resourceLauncherMakePersonal")}
) : null}
+ {canDeleteView ? (
+ <>
+
+
+ {t("resourceLauncherDeleteView")}
+
+ >
+ ) : null}
);
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index be5620d25..f55137d33 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -29,12 +29,23 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
-import type {
- LauncherGroup,
- LauncherViewConfig,
- LauncherViewRecord
+import {
+ getEffectiveLauncherConfig,
+ shouldShowFlatResourceList,
+ shouldShowLauncherGroupList,
+ 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";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -52,20 +63,26 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
+import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList";
+import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
+import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
+import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = {
orgId: string;
isAdmin: boolean;
views: LauncherViewRecord[];
+ defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -78,9 +95,11 @@ export default function ResourceLauncher({
orgId,
isAdmin,
views,
+ defaultViewOverrides,
activeViewId,
config,
savedConfig,
+ scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -88,6 +107,7 @@ export default function ResourceLauncher({
const { toast } = useToast();
const { env } = useEnvContext();
const api = createApiClient({ env });
+ const queryClient = useQueryClient();
const router = useRouter();
const { navigate, isNavigating, searchParams } = useNavigationContext();
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -95,11 +115,47 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false);
+ const [selectedResource, setSelectedResource] =
+ useState(null);
+ const [panelOpen, setPanelOpen] = useState(false);
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);
configRef.current = config;
const searchInputRef = useRef(config.query);
@@ -129,13 +185,24 @@ export default function ResourceLauncher({
return;
}
- const baseConfig = getLauncherUrlBaseConfig(lastView, views);
+ const baseConfig = getLauncherUrlBaseConfig(
+ lastView,
+ views,
+ defaultViewOverrides
+ );
const params = serializeLauncherUrlState({
viewId: lastView,
config: baseConfig
});
navigate({ searchParams: params, replace: true });
- }, [activeViewId, navigate, orgId, searchParams, views]);
+ }, [
+ activeViewId,
+ defaultViewOverrides,
+ navigate,
+ orgId,
+ searchParams,
+ views
+ ]);
const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
@@ -158,10 +225,14 @@ export default function ResourceLauncher({
const selectView = useCallback(
(viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId);
- const baseConfig = getLauncherUrlBaseConfig(viewId, views);
+ const baseConfig = getLauncherUrlBaseConfig(
+ viewId,
+ views,
+ defaultViewOverrides
+ );
navigateToConfig(viewId, baseConfig);
},
- [navigateToConfig, orgId, views]
+ [defaultViewOverrides, navigateToConfig, orgId, views]
);
const activeSavedView = useMemo(
@@ -175,6 +246,10 @@ export default function ResourceLauncher({
const isDefaultView = activeViewId === "default";
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
+ const canResetSystemDefault =
+ isDefaultView &&
+ (Boolean(defaultViewOverrides.personal) ||
+ (isAdmin && Boolean(defaultViewOverrides.orgWide)));
const selectedSites: Selectedsite[] = useMemo(
() =>
@@ -270,6 +345,87 @@ 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({
mutationFn: async (viewId: number) => {
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
@@ -278,7 +434,11 @@ export default function ResourceLauncher({
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
- config: getLauncherUrlBaseConfig("default", views)
+ config: getLauncherUrlBaseConfig(
+ "default",
+ views,
+ defaultViewOverrides
+ )
});
navigate({ searchParams: params, replace: true });
router.refresh();
@@ -328,9 +488,17 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]);
+ const handleResetSystemDefault = useCallback(() => {
+ resetSystemDefaultMutation.mutate();
+ }, [resetSystemDefaultMutation]);
+
const refreshData = () => {
startRefreshTransition(async () => {
try {
+ await api.post(`/org/${orgId}/launcher/invalidate-cache`);
+ await queryClient.invalidateQueries({
+ queryKey: ["ORG", orgId, "LAUNCHER"]
+ });
router.refresh();
} catch {
toast({
@@ -343,7 +511,14 @@ export default function ResourceLauncher({
};
const handleSaveToCurrent = () => {
- if (isDefaultView || (isOrgWideView && !isAdmin)) {
+ if (isDefaultView) {
+ saveDefaultViewMutation.mutate({
+ config,
+ orgWide: false
+ });
+ return;
+ }
+ if (isOrgWideView && !isAdmin) {
return;
}
updateViewMutation.mutate({
@@ -360,6 +535,10 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => {
if (isDefaultView) {
+ saveDefaultViewMutation.mutate({
+ config,
+ orgWide: true
+ });
return;
}
updateViewMutation.mutate({
@@ -389,6 +568,18 @@ 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) => ({
viewId: view.viewId,
name: view.name
@@ -412,19 +603,8 @@ export default function ResourceLauncher({
);
- const renderToolbarActions = () => (
+ const renderToolbarFilterSort = () => (
<>
-
+ >
+ );
+
+ const renderToolbarActions = () => (
+ <>
+ setDeleteDialogOpen(true)}
+ />
{
- if (!isDefaultView) {
- deleteViewMutation.mutate(activeViewId);
- }
- }}
/>
{renderToolbarSearch("w-64")}
+
+ {renderToolbarFilterSort()}
+
{renderToolbarViews()}
@@ -495,22 +694,64 @@ export default function ResourceLauncher({
{renderToolbarActions()}
- {renderToolbarSearch("w-full")}
+
+
+ {renderToolbarSearch("w-full")}
+
+ {renderToolbarFilterSort()}
+
{renderToolbarViews()}
)}
-
+ ) : showGroupList ? (
+
+ ) : showFlatResourceList ? (
+
+ ) : null}
+
+
+ {activeSavedView ? (
+ {t("resourceLauncherDeleteViewQuestion")}}
+ onConfirm={async () => {
+ await deleteViewMutation.mutateAsync(
+ activeSavedView.viewId
+ );
+ }}
+ />
+ ) : null}
+
diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts
index 6e1fb60a5..1d2b64219 100644
--- a/src/lib/launcherLocalStorage.ts
+++ b/src/lib/launcherLocalStorage.ts
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
): Record {
return readJson>(
groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
isOpen: boolean
) {
diff --git a/src/lib/launcherResourceDetails.ts b/src/lib/launcherResourceDetails.ts
new file mode 100644
index 000000000..9920ceeef
--- /dev/null
+++ b/src/lib/launcherResourceDetails.ts
@@ -0,0 +1,93 @@
+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
+): 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
+ }
+ };
+}
diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts
new file mode 100644
index 000000000..a9e03ecb4
--- /dev/null
+++ b/src/lib/launcherScale.ts
@@ -0,0 +1,114 @@
+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);
+}
diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts
index 2c542c5fc..8abb0d2bc 100644
--- a/src/lib/launcherServerData.ts
+++ b/src/lib/launcherServerData.ts
@@ -1,21 +1,27 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
+ LauncherScaleInfo,
+ LauncherDefaultViewOverrides,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
+ ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
export type LauncherPageData = {
views: LauncherViewRecord[];
+ defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -32,19 +38,56 @@ export async function fetchLauncherPageData(
>
): Promise {
let views: LauncherViewRecord[] = [];
+ let defaultViewOverrides: LauncherDefaultViewOverrides = {
+ personal: null,
+ orgWide: null
+ };
try {
const viewsRes = await internal.get<
AxiosResponse
>(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views;
+ defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
} catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams,
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
+ >(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
+ scale = scaleRes.data.data.scale;
+ } catch (e) {}
+
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
@@ -62,20 +105,24 @@ export async function fetchLauncherPageData(
pageSize: 20
};
- try {
- const sp = buildLauncherSearchParams(groupFilters, 1);
- const groupsRes = await internal.get<
- AxiosResponse
- >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
- groups = groupsRes.data.data.groups;
- groupsPagination = groupsRes.data.data.pagination;
- } catch (e) {}
+ if (shouldFetchLauncherGroups(scale, config)) {
+ try {
+ const sp = buildLauncherSearchParams(groupFilters, 1);
+ const groupsRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
+ groups = groupsRes.data.data.groups;
+ groupsPagination = groupsRes.data.data.pagination;
+ } catch (e) {}
+ }
return {
views,
+ defaultViewOverrides,
activeViewId,
config,
savedConfig,
+ scale,
groups,
groupsPagination
};
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
index cfe8e26c4..dff904568 100644
--- a/src/lib/launcherUrlState.ts
+++ b/src/lib/launcherUrlState.ts
@@ -1,7 +1,9 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import {
defaultLauncherViewConfig,
+ getEffectiveDefaultLauncherConfig,
parseIdListParam,
+ type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
@@ -64,10 +66,13 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId,
- views: LauncherViewRecord[]
+ views: LauncherViewRecord[],
+ defaultViewOverrides?: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (viewId === "default") {
- return defaultLauncherViewConfig;
+ return defaultViewOverrides
+ ? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
+ : defaultLauncherViewConfig;
}
const savedView = views.find((view) => view.viewId === viewId);
@@ -113,7 +118,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
- if (groupBy === "site" || groupBy === "label") {
+ if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
@@ -172,7 +177,8 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams,
views: LauncherViewRecord[],
- fallbackViewId: LauncherActiveViewId | null
+ fallbackViewId: LauncherActiveViewId | null,
+ defaultViewOverrides?: LauncherDefaultViewOverrides
): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams);
@@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl(
: "default";
}
- const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
+ const savedConfig = getLauncherUrlBaseConfig(
+ activeViewId,
+ views,
+ defaultViewOverrides
+ );
let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig(
- defaultLauncherViewConfig,
+ getEffectiveDefaultLauncherConfig(
+ defaultViewOverrides ?? { personal: null, orgWide: null }
+ ),
parsed.configOverrides
);
} else if (activeViewId !== "default") {
config = savedConfig;
} else {
- config = defaultLauncherViewConfig;
+ config = getEffectiveDefaultLauncherConfig(
+ defaultViewOverrides ?? { personal: null, orgWide: null }
+ );
}
return {
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 14fa1d3da..a086963ed 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -50,11 +50,16 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
+ ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
+ LauncherResource,
LauncherViewConfig
} 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 { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
@@ -1189,13 +1194,13 @@ export const launcherQueries = {
const res = await meta!.api.get<
AxiosResponse
>(`/org/${orgId}/launcher/views`, { signal });
- return res.data.data.views;
+ return res.data.data;
}
}),
sites: ({
orgId,
query,
- perPage = 500
+ perPage = 20
}: {
orgId: string;
query?: string;
@@ -1227,7 +1232,7 @@ export const launcherQueries = {
labels: ({
orgId,
query,
- perPage = 500
+ perPage = 20
}: {
orgId: string;
query?: string;
@@ -1298,5 +1303,62 @@ export const launcherQueries = {
const nextPage = page + 1;
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
+ >(`/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
+ >(`/org/${orgId}/resource/${resource.niceId}`, { signal });
+ const resourceData = res.data.data;
+ const authRes = await meta!.api.get<
+ AxiosResponse
+ >(`/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
+ >(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
+ return {
+ resourceType: "site" as const,
+ data: res.data.data
+ };
+ }
})
};