diff --git a/messages/en-US.json b/messages/en-US.json
index 5d49b0ca9..64526b405 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -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/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/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/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/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx
index 7187694f7..147972e78 100644
--- a/src/components/CopyToClipboard.tsx
+++ b/src/components/CopyToClipboard.tsx
@@ -1,3 +1,4 @@
+import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
+ className?: string;
};
const CopyToClipboard = ({
text,
displayText,
- isLink
+ isLink,
+ className
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
href={text}
target="_blank"
rel="noopener noreferrer"
- className="truncate hover:underline text-sm min-w-0 max-w-full"
+ className={cn(
+ "truncate hover:underline text-sm min-w-0 max-w-full",
+ className
+ )}
title={text} // Shows full text on hover
>
{displayValue}
) : (
boolean;
onToggle: (label: LabelFilterOption) => void;
+ selectedLabels?: LabelFilterOption[];
onClear?: () => void;
showClear?: boolean;
scope?: "org" | "launcher";
@@ -34,6 +35,7 @@ export function LabelsFilterSelector({
orgId,
isSelected,
onToggle,
+ selectedLabels = [],
onClear,
showClear = false,
scope = "org"
@@ -54,7 +56,7 @@ export function LabelsFilterSelector({
...launcherQueries.labels({
orgId,
query: debouncedQuery,
- perPage: 500
+ perPage: 20
}),
enabled: scope === "launcher"
});
@@ -63,6 +65,18 @@ export function LabelsFilterSelector({
? (launcherLabelsQuery.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 (
)}
- {labels.map((label) => (
+ {labelsShown.map((label) => (
void;
filterTypes?: string[];
scope?: "org" | "launcher";
+ onClear?: () => void;
+ showClear?: boolean;
};
export function formatMultiSitesSelectorLabel(
@@ -42,7 +44,9 @@ export function MultiSitesSelector({
selectedSites,
onSelectionChange,
filterTypes,
- scope = "org"
+ scope = "org",
+ onClear,
+ showClear = false
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -60,7 +64,7 @@ export function MultiSitesSelector({
...launcherQueries.sites({
orgId,
query: debouncedQuery,
- perPage: 500
+ perPage: 20
}),
enabled: scope === "launcher"
});
@@ -107,6 +111,14 @@ export function MultiSitesSelector({
{t("siteNotFound")}
+ {showClear && onClear && (
+
+ {t("accessFilterClear")}
+
+ )}
{sitesShown.map((site) => (
-
@@ -139,6 +158,10 @@ export function LauncherFilterPopover({
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
+ showClear={selectedSites.length > 0}
+ onClear={() => {
+ onSitesChange([]);
+ }}
/>
@@ -172,6 +195,7 @@ export function LauncherFilterPopover({
selectedLabelIds.has(label.labelId)
}
diff --git a/src/components/resource-launcher/LauncherFlatResourceList.tsx b/src/components/resource-launcher/LauncherFlatResourceList.tsx
new file mode 100644
index 000000000..7bbc01ccb
--- /dev/null
+++ b/src/components/resource-launcher/LauncherFlatResourceList.tsx
@@ -0,0 +1,125 @@
+"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(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 (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ {config.layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+ {isFetchingNextPage ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx
index c7fe6b905..ecf60a773 100644
--- a/src/components/resource-launcher/LauncherGroupList.tsx
+++ b/src/components/resource-launcher/LauncherGroupList.tsx
@@ -69,16 +69,20 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters),
- initialData: {
- pages: [
- {
- groups: initialGroups,
- pagination: groupsPagination
- }
- ],
- pageParams: [1]
- },
- refetchOnMount: false
+ ...(initialGroups.length > 0
+ ? {
+ initialData: {
+ pages: [
+ {
+ groups: initialGroups,
+ pagination: groupsPagination
+ }
+ ],
+ pageParams: [1]
+ },
+ refetchOnMount: false as const
+ }
+ : {})
});
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx
index 854128736..1d0a3d2f3 100644
--- a/src/components/resource-launcher/LauncherRefreshButton.tsx
+++ b/src/components/resource-launcher/LauncherRefreshButton.tsx
@@ -23,9 +23,8 @@ export function LauncherRefreshButton({
className="shrink-0"
>
- {t("refresh")}
);
}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx
index 1bb61bd9e..b173ff32f 100644
--- a/src/components/resource-launcher/LauncherResourcePanel.tsx
+++ b/src/components/resource-launcher/LauncherResourcePanel.tsx
@@ -1,17 +1,55 @@
"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 {
SidePanel,
SidePanelBody,
SidePanelContent,
- SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle
} from "@app/components/SidePanel";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
+import {
+ derivePublicAuthState,
+ formatPortRestrictionDisplay,
+ formatPublicResourceType
+} from "@app/lib/launcherResourceDetails";
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 { 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 Link from "next/link";
@@ -23,6 +61,460 @@ type LauncherResourcePanelProps = {
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 -;
+ }
+
+ const href = accessUrl ?? undefined;
+ const canLink = Boolean(href && isSafeUrlForLink(href));
+
+ if (canLink && href) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function HealthStatusDisplay({
+ health
+}: {
+ health: string | null | undefined;
+}) {
+ const t = useTranslations();
+ const status = health ?? "unknown";
+
+ if (status === "healthy") {
+ return (
+
+
+ {t("resourcesTableHealthy")}
+
+ );
+ }
+
+ if (status === "degraded") {
+ return (
+
+
+ {t("resourcesTableDegraded")}
+
+ );
+ }
+
+ if (status === "unhealthy") {
+ return (
+
+
+ {t("resourcesTableUnhealthy")}
+
+ );
+ }
+
+ return (
+
+
+ {t("resourcesTableUnknown")}
+
+ );
+}
+
+const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
+
+function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
+ const t = useTranslations();
+
+ return (
+
+ {enabled ? (
+
+ ) : (
+
+ )}
+ {enabled ? t("enabled") : t("disabled")}
+
+ );
+}
+
+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 (
+
+
+
+ {t("authentication")}
+
+
+ {t("resourceLauncherAuthMethodsDescription")}
+
+
+
+
+ {authMethods.map((method) => (
+
+ {method.title}
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ {t("resourceLauncherResourceDetails")}
+
+
+ {t("resourceLauncherResourceDetailsDescription")}
+
+
+
+
+
+ {t("type")}
+
+ {formatPublicResourceType(resource)}
+
+
+
+ {t("access")}
+
+
+
+
+ {supportsAuth ? (
+
+
+ {t("authentication")}
+
+
+ {authState === "protected" ? (
+
+
+ {t("protected")}
+
+ ) : (
+
+
+ {t("notProtected")}
+
+ )}
+
+
+ ) : null}
+
+ {t("health")}
+
+
+
+
+
+
+
+ {supportsAuth ? (
+
+ ) : null}
+
+ );
+}
+
+function PrivateResourceDetails({
+ launcherResource,
+ resource
+}: {
+ launcherResource: LauncherResource;
+ resource: GetSiteResourceResponse;
+}) {
+ const t = useTranslations();
+ const modeLabel: Record = {
+ 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 (
+
+
+
+
+ {t("resourceLauncherPrivateClientRequiredTitle")}
+
+
+
+ {t("resourceLauncherPrivateClientRequired")}{" "}
+
+ {t("resourceLauncherDownloadClient")}
+
+
+
+
+
+
+
+
+
+ {t("resourceLauncherResourceDetails")}
+
+
+ {t("resourceLauncherResourceDetailsDescription")}
+
+
+
+
+
+ {t("type")}
+
+ {modeLabel[resource.mode]}
+
+
+
+ {t("access")}
+
+
+
+
+ {showDestination ? (
+
+
+ {t("editInternalResourceDialogDestination")}
+
+
+ {destination || "-"}
+
+
+ ) : null}
+ {showAlias ? (
+
+
+ {t("editInternalResourceDialogAlias")}
+
+
+ {resource.alias?.trim()
+ ? resource.alias
+ : "-"}
+
+
+ ) : null}
+
+
+ {t("portRestrictions")}
+
+
+ {!portRestrictions.hasNonDefaultPorts ? (
+
+ {t(
+ "resourceLauncherNoPortRestrictions"
+ )}
+
+ ) : (
+
+ {portRestrictions.tcp.state !==
+ "all" ? (
+
+ {t("resourceLauncherTcp")}:{" "}
+ {portRestrictions.tcp.state ===
+ "blocked"
+ ? t("blocked")
+ : portRestrictions.tcp
+ .ports}
+
+ ) : null}
+ {portRestrictions.udp.state !==
+ "all" ? (
+
+ {t("resourceLauncherUdp")}:{" "}
+ {portRestrictions.udp.state ===
+ "blocked"
+ ? t("blocked")
+ : portRestrictions.udp
+ .ports}
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+ {t("resourceLauncherFailedToLoadDetails")}
+
+ );
+ }
+
+ const detail = data as LauncherResourceDetailResult;
+
+ if (detail.resourceType === "public") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
export function LauncherResourcePanel({
open,
onOpenChange,
@@ -37,11 +529,16 @@ export function LauncherResourcePanel({
{resource?.name ?? ""}
-
- {t("resourceLauncherResourceDetailsDescription")}
-
-
+
+ {resource ? (
+
+ ) : null}
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function GhostResourceList() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function LauncherSearchFirstGate({
+ layout
+}: LauncherSearchFirstGateProps) {
+ const t = useTranslations();
+
+ return (
+
+
+ {layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {t("resourceLauncherSearchFirstTitle")}
+
+
+ {t("resourceLauncherSearchFirstDescription")}
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index 41143cd10..fc10d4a7f 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -15,22 +15,27 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
-import type { LauncherViewConfig } from "@server/routers/launcher/types";
+import type {
+ LauncherScaleCapabilities,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
import { useTranslations } from "next-intl";
import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = {
config: LauncherViewConfig;
- isDefaultView: boolean;
+ capabilities: LauncherScaleCapabilities;
+ isCompactMode: boolean;
+ selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial) => void;
- onDeleteView: () => void;
};
export function LauncherSettingsMenu({
config,
- isDefaultView,
- onConfigChange,
- onDeleteView
+ capabilities,
+ isCompactMode,
+ selectedGroupBy,
+ onConfigChange
}: LauncherSettingsMenuProps) {
const t = useTranslations();
@@ -51,7 +56,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
+ {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
+ };
+ }
})
};