mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 04:39:52 +00:00
add more caching
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string, string>
|
||||
) {
|
||||
const payload = JSON.stringify({
|
||||
roles: [...userRoleIds].sort((a, b) => a - b),
|
||||
query: query.query,
|
||||
groupBy: query.groupBy,
|
||||
siteIds: query.siteIds ?? "",
|
||||
labelIds: query.labelIds ?? "",
|
||||
sort_by: query.sort_by,
|
||||
order: query.order,
|
||||
...extra
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function launcherResourcesQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query, {
|
||||
groupKey: query.groupKey
|
||||
});
|
||||
}
|
||||
|
||||
function launcherGroupsQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query);
|
||||
}
|
||||
|
||||
function launcherResourcesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:results:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherGroupsCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherAccessibleIdsCacheKey(
|
||||
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<number[]> {
|
||||
if (!query.trim() || resourceIds.length === 0) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForPublic(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
async function filterSiteResourceIdsByTextSearch(
|
||||
orgId: string,
|
||||
siteResourceIds: number[],
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
): Promise<number[]> {
|
||||
if (!query.trim() || siteResourceIds.length === 0) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.leftJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.siteResourceId);
|
||||
}
|
||||
|
||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
||||
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<LauncherGroupsCacheEntry> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
if (query.groupBy === "label") {
|
||||
return listLabelGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
return listSiteGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
export async function listLauncherGroupsForUser(
|
||||
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<LauncherGroupsCacheEntry>(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<LauncherResourcesCacheEntry> {
|
||||
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<LauncherResourcesCacheEntry>(cacheKey);
|
||||
|
||||
let result = cached;
|
||||
if (!result) {
|
||||
result = await listLauncherResourcesForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
|
||||
}
|
||||
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
resources: items.slice(offset, offset + query.pageSize),
|
||||
total
|
||||
resources: result.items.slice(offset, offset + query.pageSize),
|
||||
total: result.total
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LauncherScaleInfo> {
|
||||
userRoleIds: number[]
|
||||
): Promise<LauncherScaleCountsCacheEntry> {
|
||||
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
|
||||
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
const resourceCount =
|
||||
accessible.resourceIds.length + accessible.siteResourceIds.length;
|
||||
@@ -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<LauncherScaleInfo> {
|
||||
const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query);
|
||||
const cached = await cache.get<LauncherScaleInfo>(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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("resourceLauncherFilter")}
|
||||
{activeFilterCount > 0
|
||||
? t("resourceLauncherFilterWithCount", {
|
||||
count: activeFilterCount
|
||||
})
|
||||
: t("resourceLauncherFilter")}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
|
||||
>
|
||||
{activeFilterCount > 99 ? "99+" : activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
|
||||
Reference in New Issue
Block a user