diff --git a/messages/en-US.json b/messages/en-US.json index a4e99d5c5..45393e184 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3587,6 +3587,7 @@ "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", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 2ce6d87d4..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,7 +32,8 @@ import { isNull, like, or, - sql + sql, + type SQL } from "drizzle-orm"; import { formatPublicResourceAccess, @@ -60,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, @@ -195,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 @@ -206,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) { @@ -239,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) @@ -263,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); } @@ -589,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 }; } @@ -789,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( @@ -1019,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" @@ -1051,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); @@ -1129,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, @@ -1160,7 +1354,6 @@ export async function listLauncherResourcesForUser( ]); let items = [...publicItems, ...siteItems]; - items = filterResourcesBySearch(items, query.query); if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { if (query.groupBy === "label") { @@ -1172,11 +1365,37 @@ export async function listLauncherResourcesForUser( 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 index 5d9eb42c2..8d512834b 100644 --- a/server/routers/launcher/launcherScale.ts +++ b/server/routers/launcher/launcherScale.ts @@ -14,32 +14,57 @@ 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_TTL_SEC = 60; +const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60; -function launcherScaleCacheKey( +type LauncherScaleCountsCacheEntry = { + resourceCount: number; + siteGroupCount: number; + labelGroupCount: number; + mode: LauncherScaleInfo["mode"]; +}; + +function launcherScaleCountsCacheKey( orgId: string, userId: string, - roleIds: number[], - query: LauncherScaleQuery + roleIds: number[] ) { const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); - const filterKey = [ - query.query, - query.groupBy, - query.siteIds ?? "", - query.labelIds ?? "", - query.sort_by, - query.order - ].join("|"); - return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`; + return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`; } -async function getLauncherScaleForUserUncached( +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[], - query: LauncherScaleQuery -): Promise { + 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; @@ -75,33 +100,15 @@ async function getLauncherScaleForUserUncached( ? "full" : "compact"; - const siteFilterIds = parseIdListParam(query.siteIds); - const labelFilterIds = parseIdListParam(query.labelIds); - - const allowSiteGrouping = - siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || - (siteFilterIds.length > 0 && - siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const allowLabelGrouping = - labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || - (labelFilterIds.length > 0 && - labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const requireSearchOrFilter = - mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; - - return { - mode, + const result: LauncherScaleCountsCacheEntry = { resourceCount, siteGroupCount, labelGroupCount, - capabilities: { - allowSiteGrouping, - allowLabelGrouping, - requireSearchOrFilter - } + mode }; + + await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC); + return result; } export async function getLauncherScaleForUser( @@ -110,18 +117,17 @@ export async function getLauncherScaleForUser( userRoleIds: number[], query: LauncherScaleQuery ): Promise { - const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query); - const cached = await cache.get(cacheKey); - if (cached) { - return cached; - } - - const result = await getLauncherScaleForUserUncached( + const counts = await getLauncherScaleCountsForUser( orgId, userId, - userRoleIds, - query + userRoleIds ); - await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC); - return result; + + return { + mode: counts.mode, + resourceCount: counts.resourceCount, + siteGroupCount: counts.siteGroupCount, + labelGroupCount: counts.labelGroupCount, + capabilities: buildScaleCapabilities(counts, query) + }; } diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 9306a752c..8f44b1139 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -10,6 +10,7 @@ import { type SelectedLabel } from "@app/components/labels-selector"; import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { Popover, @@ -96,14 +97,32 @@ export function LauncherFilterPopover({ [labels, selectedLabels] ); + const activeFilterCount = selectedSites.length + selectedLabels.length; + return ( -