From 1717a99cee573a60f02a5ebf1eb4145c5024855a Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 21:53:52 -0400
Subject: [PATCH 1/9] add compact mode for large orgs
---
messages/en-US.json | 7 +
server/routers/external.ts | 6 +
server/routers/launcher/index.ts | 1 +
.../launcher/launcherResourceAccess.ts | 11 +-
server/routers/launcher/launcherScale.ts | 124 +++++++++++++++++
server/routers/launcher/listLauncherScale.ts | 60 +++++++++
server/routers/launcher/types.ts | 34 ++++-
src/app/[orgId]/page.tsx | 1 +
src/components/multi-site-selector.tsx | 14 +-
.../LauncherFilterPopover.tsx | 4 +
.../LauncherFlatResourceList.tsx | 125 ++++++++++++++++++
.../resource-launcher/LauncherGroupList.tsx | 24 ++--
.../LauncherSearchFirstGate.tsx | 86 ++++++++++++
.../LauncherSettingsMenu.tsx | 47 ++++++-
.../resource-launcher/ResourceLauncher.tsx | 82 ++++++++++--
src/lib/launcherLocalStorage.ts | 8 +-
src/lib/launcherScale.ts | 95 +++++++++++++
src/lib/launcherServerData.ts | 54 ++++++--
src/lib/launcherUrlState.ts | 2 +-
src/lib/queries.ts | 15 +++
20 files changed, 757 insertions(+), 43 deletions(-)
create mode 100644 server/routers/launcher/launcherScale.ts
create mode 100644 server/routers/launcher/listLauncherScale.ts
create mode 100644 src/components/resource-launcher/LauncherFlatResourceList.tsx
create mode 100644 src/components/resource-launcher/LauncherSearchFirstGate.tsx
create mode 100644 src/lib/launcherScale.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index 0129c1159..e699aba9f 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3590,6 +3590,7 @@
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
+ "resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
@@ -3607,6 +3608,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": "This organization has 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.",
+ "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 5d770c064..43cf88a43 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -470,6 +470,12 @@ authenticated.get(
launcher.listLauncherGroups
);
+authenticated.get(
+ "/org/:orgId/launcher/scale",
+ verifyOrgAccess,
+ launcher.listLauncherScale
+);
+
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts
index 939bf74bc..e941bbe10 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";
diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts
index 8fe5e8ef8..2ce6d87d4 100644
--- a/server/routers/launcher/launcherResourceAccess.ts
+++ b/server/routers/launcher/launcherResourceAccess.ts
@@ -38,6 +38,7 @@ import {
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
+ LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -1161,10 +1162,12 @@ 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);
diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts
new file mode 100644
index 000000000..ec7750cfb
--- /dev/null
+++ b/server/routers/launcher/launcherScale.ts
@@ -0,0 +1,124 @@
+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_TTL_SEC = 60;
+
+function launcherScaleCacheKey(
+ orgId: string,
+ userId: string,
+ roleIds: number[],
+ query: LauncherScaleQuery
+) {
+ 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}`;
+}
+
+async function getLauncherScaleForUserUncached(
+ orgId: string,
+ userId: string,
+ userRoleIds: number[],
+ query: LauncherScaleQuery
+): Promise {
+ 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 siteFilterIds = parseIdListParam(query.siteIds);
+
+ 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;
+
+ const requireSearchOrFilter =
+ mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES;
+
+ return {
+ mode,
+ resourceCount,
+ siteGroupCount,
+ labelGroupCount,
+ capabilities: {
+ allowSiteGrouping,
+ allowLabelGrouping,
+ requireSearchOrFilter
+ }
+ };
+}
+
+export async function getLauncherScaleForUser(
+ orgId: string,
+ userId: string,
+ 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(
+ orgId,
+ userId,
+ userRoleIds,
+ query
+ );
+ await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC);
+ return result;
+}
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/types.ts b/server/routers/launcher/types.ts
index 0a252d74d..2b7ee7f6e 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"),
@@ -138,7 +139,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 +164,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/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx
index 7abb1b317..0197a5502 100644
--- a/src/app/[orgId]/page.tsx
+++ b/src/app/[orgId]/page.tsx
@@ -85,6 +85,7 @@ export default async function OrgPage(props: OrgPageProps) {
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
+ scale={launcherData.scale}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
/>
diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx
index fe81dc697..15db6ceea 100644
--- a/src/components/multi-site-selector.tsx
+++ b/src/components/multi-site-selector.tsx
@@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = {
onSelectionChange: (sites: Selectedsite[]) => 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("");
@@ -107,6 +111,14 @@ export function MultiSitesSelector({
{t("siteNotFound")}
+ {showClear && onClear && (
+
+ {t("accessFilterClear")}
+
+ )}
{sitesShown.map((site) => (
0}
+ onClear={() => {
+ onSitesChange([]);
+ }}
/>
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/LauncherSearchFirstGate.tsx b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
new file mode 100644
index 000000000..ab9ea1226
--- /dev/null
+++ b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import { Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type LauncherSearchFirstGateProps = {
+ layout: "grid" | "list";
+};
+
+function GhostResourceGrid() {
+ return (
+
+ {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..abce2e5e6 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -15,13 +15,19 @@ 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;
};
@@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = {
export function LauncherSettingsMenu({
config,
isDefaultView,
+ capabilities,
+ isCompactMode,
+ selectedGroupBy,
onConfigChange,
onDeleteView
}: LauncherSettingsMenuProps) {
@@ -51,7 +60,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
+ {isCompactMode ? (
+
+ {t("resourceLauncherCompactGroupingLocked")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowSiteGrouping ? (
+
+ {t("resourceLauncherSiteGroupingDisabled")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowLabelGrouping ? (
+
+ {t("resourceLauncherLabelGroupingDisabled")}
+
+ ) : null}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index be5620d25..2cc9d50a2 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -29,12 +29,20 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import {
+ getEffectiveLauncherConfig,
+ shouldShowFlatResourceList,
+ shouldShowLauncherGroupList,
+ shouldShowSearchFirstGate
+} from "@app/lib/launcherScale";
+import { launcherQueries } from "@app/lib/queries";
import type {
LauncherGroup,
+ LauncherScaleInfo,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -52,7 +60,9 @@ 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 { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
@@ -66,6 +76,7 @@ type ResourceLauncherProps = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -81,6 +92,7 @@ export default function ResourceLauncher({
activeViewId,
config,
savedConfig,
+ scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -100,6 +112,38 @@ export default function ResourceLauncher({
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);
@@ -451,6 +495,9 @@ export default function ResourceLauncher({
{
if (!isDefaultView) {
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
)}
-
+ {scale.mode === "compact" && !showSearchFirstGate ? (
+
+ {t("resourceLauncherCompactModeHint")}
+
+ ) : null}
+
+ {showSearchFirstGate ? (
+
+ ) : showGroupList ? (
+
+ ) : showFlatResourceList ? (
+
+ ) : 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/launcherScale.ts b/src/lib/launcherScale.ts
new file mode 100644
index 000000000..6b086bab9
--- /dev/null
+++ b/src/lib/launcherScale.ts
@@ -0,0 +1,95 @@
+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 "none";
+ }
+
+ return config.groupBy;
+}
+
+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
+ );
+}
+
+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..994ca2ff4 100644
--- a/src/lib/launcherServerData.ts
+++ b/src/lib/launcherServerData.ts
@@ -1,12 +1,15 @@
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,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
+ ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
@@ -16,6 +19,7 @@ export type LauncherPageData = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -45,6 +49,37 @@ export async function fetchLauncherPageData(
null
);
+ 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 +97,23 @@ 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,
activeViewId,
config,
savedConfig,
+ scale,
groups,
groupsPagination
};
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
index cfe8e26c4..c3b2da568 100644
--- a/src/lib/launcherUrlState.ts
+++ b/src/lib/launcherUrlState.ts
@@ -113,7 +113,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
- if (groupBy === "site" || groupBy === "label") {
+ if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 14fa1d3da..5ca6624d6 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -50,6 +50,7 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
+ ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
@@ -1298,5 +1299,19 @@ 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;
+ }
})
};
From 12f9ac94fd63ca02c7cdfcd09a40abbfdf1f2b3d Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 22:07:30 -0400
Subject: [PATCH 2/9] allow grouping when filter applied
---
messages/en-US.json | 2 +-
server/routers/launcher/launcherScale.ts | 5 ++-
.../LauncherSettingsMenu.tsx | 12 ++++---
.../resource-launcher/ResourceLauncher.tsx | 6 ----
src/lib/launcherScale.ts | 31 +++++++++++++++----
5 files changed, 37 insertions(+), 19 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index e699aba9f..fede28c11 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3613,7 +3613,7 @@
"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.",
- "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.",
+ "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts
index ec7750cfb..5d9eb42c2 100644
--- a/server/routers/launcher/launcherScale.ts
+++ b/server/routers/launcher/launcherScale.ts
@@ -76,6 +76,7 @@ async function getLauncherScaleForUserUncached(
: "compact";
const siteFilterIds = parseIdListParam(query.siteIds);
+ const labelFilterIds = parseIdListParam(query.labelIds);
const allowSiteGrouping =
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
@@ -83,7 +84,9 @@ async function getLauncherScaleForUserUncached(
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX);
const allowLabelGrouping =
- labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS;
+ 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;
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index abce2e5e6..2cf2a3cbb 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -78,8 +78,9 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBySite")}
@@ -87,8 +88,9 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupByLabel")}
@@ -97,7 +99,7 @@ export function LauncherSettingsMenu({
{isCompactMode ? (
- {t("resourceLauncherCompactGroupingLocked")}
+ {t("resourceLauncherCompactGroupingHint")}
) : null}
{!isCompactMode && !capabilities.allowSiteGrouping ? (
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index 2cc9d50a2..23db498ea 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -549,12 +549,6 @@ export default function ResourceLauncher({
)}
- {scale.mode === "compact" && !showSearchFirstGate ? (
-
- {t("resourceLauncherCompactModeHint")}
-
- ) : null}
-
{showSearchFirstGate ? (
) : showGroupList ? (
diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts
index 6b086bab9..a9e03ecb4 100644
--- a/src/lib/launcherScale.ts
+++ b/src/lib/launcherScale.ts
@@ -15,11 +15,27 @@ export function getEffectiveGroupBy(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig["groupBy"] {
- if (scale.mode === "compact") {
- return "none";
+ if (scale.mode !== "compact") {
+ return config.groupBy;
}
- 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(
@@ -49,9 +65,12 @@ export function shouldFetchLauncherGroups(
}
return (
- scale.capabilities.allowSiteGrouping &&
- groupBy === "site" &&
- config.siteIds.length > 0
+ (scale.capabilities.allowSiteGrouping &&
+ groupBy === "site" &&
+ config.siteIds.length > 0) ||
+ (scale.capabilities.allowLabelGrouping &&
+ groupBy === "label" &&
+ config.labelIds.length > 0)
);
}
From f0546eb6224eb17a15a1a1bbb1a12d093bbea1dd Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 22:27:51 -0400
Subject: [PATCH 3/9] add save default view
---
messages/en-US.json | 13 +-
server/routers/external.ts | 12 +
server/routers/launcher/createLauncherView.ts | 10 +
.../launcher/deleteLauncherDefaultView.ts | 104 +++++++++
server/routers/launcher/deleteLauncherView.ts | 10 +
server/routers/launcher/index.ts | 2 +
.../routers/launcher/launcherDefaultView.ts | 144 ++++++++++++
server/routers/launcher/listLauncherViews.ts | 23 +-
server/routers/launcher/types.ts | 22 ++
server/routers/launcher/updateLauncherView.ts | 10 +
.../launcher/upsertLauncherDefaultView.ts | 82 +++++++
src/app/[orgId]/page.tsx | 1 +
.../LauncherSettingsMenu.tsx | 16 +-
.../resource-launcher/LauncherViewTabs.tsx | 41 +++-
.../resource-launcher/ResourceLauncher.tsx | 211 +++++++++++++++---
src/lib/launcherServerData.ts | 11 +-
src/lib/launcherUrlState.ts | 26 ++-
src/lib/queries.ts | 2 +-
18 files changed, 665 insertions(+), 75 deletions(-)
create mode 100644 server/routers/launcher/deleteLauncherDefaultView.ts
create mode 100644 server/routers/launcher/launcherDefaultView.ts
create mode 100644 server/routers/launcher/upsertLauncherDefaultView.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index fede28c11..a4e99d5c5 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3571,12 +3571,16 @@
"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",
@@ -3598,6 +3602,9 @@
"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.",
"resourceLauncherUnlabeled": "Unlabeled",
@@ -3609,7 +3616,7 @@
"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": "This organization has many resources. Use search or filter by site or label to find what you need.",
+ "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.",
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 43cf88a43..2b95c2bfc 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -512,6 +512,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..4c77cd4f0 100644
--- a/server/routers/launcher/createLauncherView.ts
+++ b/server/routers/launcher/createLauncherView.ts
@@ -7,6 +7,7 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const createLauncherViewBodySchema = z.strictObject({
@@ -40,6 +41,15 @@ export async function createLauncherView(
);
}
+ if (isLauncherDefaultOverrideViewName(parsed.data.name)) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "This view name is reserved"
+ )
+ );
+ }
+
if (parsed.data.orgWide) {
const canCreateOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
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..41d7dd214 100644
--- a/server/routers/launcher/deleteLauncherView.ts
+++ b/server/routers/launcher/deleteLauncherView.ts
@@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
export async function deleteLauncherView(
req: Request,
@@ -46,6 +47,15 @@ export async function deleteLauncherView(
);
}
+ if (isLauncherDefaultOverrideViewName(existing.name)) {
+ 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 e941bbe10..b9edafc22 100644
--- a/server/routers/launcher/index.ts
+++ b/server/routers/launcher/index.ts
@@ -8,3 +8,5 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
+export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
+export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts
new file mode 100644
index 000000000..4d180bd1d
--- /dev/null
+++ b/server/routers/launcher/launcherDefaultView.ts
@@ -0,0 +1,144 @@
+import { db, launcherViews } from "@server/db";
+import { and, eq, isNull } from "drizzle-orm";
+import moment from "moment";
+import {
+ LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME,
+ launcherViewConfigSchema,
+ type LauncherDefaultViewOverrides,
+ type LauncherViewConfig,
+ type LauncherViewRecord
+} from "./types";
+
+export function isLauncherDefaultOverrideViewName(name: string) {
+ return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME;
+}
+
+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
+ };
+}
+
+export function extractDefaultViewOverrides(
+ rows: Array
+): LauncherDefaultViewOverrides {
+ const overrideRows = rows.filter((row) =>
+ isLauncherDefaultOverrideViewName(row.name)
+ );
+
+ 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) => !isLauncherDefaultOverrideViewName(row.name))
+ .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.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME),
+ 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: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME,
+ 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/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 2b7ee7f6e..9f9701c96 100644
--- a/server/routers/launcher/types.ts
+++ b/server/routers/launcher/types.ts
@@ -91,8 +91,28 @@ export type LauncherViewRecord = {
isOrgWide: 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({
@@ -161,6 +181,8 @@ export function parseIdListParam(value: string | undefined): number[] {
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
+export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__";
+
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts
index 9450da153..757a16dac 100644
--- a/server/routers/launcher/updateLauncherView.ts
+++ b/server/routers/launcher/updateLauncherView.ts
@@ -9,6 +9,7 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const updateLauncherViewBodySchema = z.strictObject({
@@ -66,6 +67,15 @@ export async function updateLauncherView(
);
}
+ if (isLauncherDefaultOverrideViewName(existing.name)) {
+ 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(
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 0197a5502..d543b2752 100644
--- a/src/app/[orgId]/page.tsx
+++ b/src/app/[orgId]/page.tsx
@@ -82,6 +82,7 @@ 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}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index 2cf2a3cbb..fc10d4a7f 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -24,22 +24,18 @@ 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,
capabilities,
isCompactMode,
selectedGroupBy,
- onConfigChange,
- onDeleteView
+ onConfigChange
}: LauncherSettingsMenuProps) {
const t = useTranslations();
@@ -157,16 +153,6 @@ export function LauncherSettingsMenu({
/>
-
- {!isDefaultView ? (
-
- ) : 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 23db498ea..9f9d44d11 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -36,11 +36,13 @@ import {
shouldShowSearchFirstGate
} from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
-import type {
- LauncherGroup,
- LauncherScaleInfo,
- LauncherViewConfig,
- LauncherViewRecord
+import {
+ getEffectiveDefaultLauncherConfig,
+ type LauncherDefaultViewOverrides,
+ type LauncherGroup,
+ type LauncherScaleInfo,
+ type LauncherViewConfig,
+ type LauncherViewRecord
} from "@server/routers/launcher/types";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
@@ -67,12 +69,14 @@ import { LauncherRefreshButton } from "./LauncherRefreshButton";
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;
@@ -89,6 +93,7 @@ export default function ResourceLauncher({
orgId,
isAdmin,
views,
+ defaultViewOverrides,
activeViewId,
config,
savedConfig,
@@ -107,6 +112,7 @@ 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);
@@ -173,13 +179,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) => {
@@ -202,10 +219,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(
@@ -219,6 +240,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(
() =>
@@ -314,6 +339,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}`);
@@ -322,7 +428,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();
@@ -372,6 +482,10 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]);
+ const handleResetSystemDefault = useCallback(() => {
+ resetSystemDefaultMutation.mutate();
+ }, [resetSystemDefaultMutation]);
+
const refreshData = () => {
startRefreshTransition(async () => {
try {
@@ -387,7 +501,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({
@@ -404,6 +525,10 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => {
if (isDefaultView) {
+ saveDefaultViewMutation.mutate({
+ config,
+ orgWide: true
+ });
return;
}
updateViewMutation.mutate({
@@ -456,19 +581,8 @@ export default function ResourceLauncher({
);
- const renderToolbarActions = () => (
+ const renderToolbarFilterSort = () => (
<>
-
+ >
+ );
+
+ const renderToolbarActions = () => (
+ <>
+ setDeleteDialogOpen(true)}
+ />
{
- if (!isDefaultView) {
- deleteViewMutation.mutate(activeViewId);
- }
- }}
/>
{renderToolbarSearch("w-64")}
+
+ {renderToolbarFilterSort()}
+
{renderToolbarViews()}
@@ -542,7 +672,12 @@ export default function ResourceLauncher({
{renderToolbarActions()}
- {renderToolbarSearch("w-full")}
+
+
+ {renderToolbarSearch("w-full")}
+
+ {renderToolbarFilterSort()}
+
{renderToolbarViews()}
@@ -569,6 +704,22 @@ export default function ResourceLauncher({
/>
) : null}
+ {activeSavedView ? (
+ {t("resourceLauncherDeleteViewQuestion")}}
+ onConfirm={async () => {
+ await deleteViewMutation.mutateAsync(
+ activeSavedView.viewId
+ );
+ }}
+ />
+ ) : null}
+
diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts
index 994ca2ff4..8abb0d2bc 100644
--- a/src/lib/launcherServerData.ts
+++ b/src/lib/launcherServerData.ts
@@ -6,6 +6,7 @@ import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
LauncherScaleInfo,
+ LauncherDefaultViewOverrides,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
@@ -16,6 +17,7 @@ import { AxiosResponse } from "axios";
export type LauncherPageData = {
views: LauncherViewRecord[];
+ defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
@@ -36,17 +38,23 @@ 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 = {
@@ -110,6 +118,7 @@ export async function fetchLauncherPageData(
return {
views,
+ defaultViewOverrides,
activeViewId,
config,
savedConfig,
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
index c3b2da568..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);
@@ -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 5ca6624d6..2626a3ba3 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -1190,7 +1190,7 @@ 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: ({
From a89509c8bd09034527ec790de58b5094f83543fc Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 23:32:16 -0400
Subject: [PATCH 4/9] add more caching
---
messages/en-US.json | 1 +
.../launcher/launcherResourceAccess.ts | 291 +++++++++++++++---
server/routers/launcher/launcherScale.ts | 108 ++++---
.../LauncherFilterPopover.tsx | 23 +-
4 files changed, 334 insertions(+), 89 deletions(-)
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 (
-
From b53f80d31711f4f35d9519e908675e095df3c20f Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 3 Jul 2026 09:59:49 -0400
Subject: [PATCH 5/9] use column for default view
---
messages/en-US.json | 2 +-
server/db/pg/schema/schema.ts | 1 +
server/db/sqlite/schema/schema.ts | 3 +++
server/routers/launcher/createLauncherView.ts | 13 ++----------
server/routers/launcher/deleteLauncherView.ts | 3 +--
.../routers/launcher/launcherDefaultView.ts | 21 +++++++------------
server/routers/launcher/types.ts | 3 +--
server/routers/launcher/updateLauncherView.ts | 6 +++---
8 files changed, 19 insertions(+), 33 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index 45393e184..90be25679 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3621,7 +3621,7 @@
"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 in compact mode.",
+ "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/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts
index 4c77cd4f0..195dfc555 100644
--- a/server/routers/launcher/createLauncherView.ts
+++ b/server/routers/launcher/createLauncherView.ts
@@ -7,7 +7,6 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
-import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const createLauncherViewBodySchema = z.strictObject({
@@ -41,15 +40,6 @@ export async function createLauncherView(
);
}
- if (isLauncherDefaultOverrideViewName(parsed.data.name)) {
- return next(
- createHttpError(
- HttpCode.BAD_REQUEST,
- "This view name is reserved"
- )
- );
- }
-
if (parsed.data.orgWide) {
const canCreateOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
@@ -89,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/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts
index 41d7dd214..8377872fb 100644
--- a/server/routers/launcher/deleteLauncherView.ts
+++ b/server/routers/launcher/deleteLauncherView.ts
@@ -6,7 +6,6 @@ import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
-import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
export async function deleteLauncherView(
req: Request,
@@ -47,7 +46,7 @@ export async function deleteLauncherView(
);
}
- if (isLauncherDefaultOverrideViewName(existing.name)) {
+ if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts
index 4d180bd1d..a0c2fd066 100644
--- a/server/routers/launcher/launcherDefaultView.ts
+++ b/server/routers/launcher/launcherDefaultView.ts
@@ -2,17 +2,12 @@ import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
- LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME,
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
-export function isLauncherDefaultOverrideViewName(name: string) {
- return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME;
-}
-
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
@@ -24,16 +19,15 @@ export function mapViewRow(
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
- isOrgWide: row.userId == null
+ isOrgWide: row.userId == null,
+ isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array
): LauncherDefaultViewOverrides {
- const overrideRows = rows.filter((row) =>
- isLauncherDefaultOverrideViewName(row.name)
- );
+ const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
@@ -47,9 +41,7 @@ export function extractDefaultViewOverrides(
export function listVisibleLauncherViews(
rows: Array
): LauncherViewRecord[] {
- return rows
- .filter((row) => !isLauncherDefaultOverrideViewName(row.name))
- .map(mapViewRow);
+ return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
@@ -63,7 +55,7 @@ export async function findDefaultViewOverride(
.where(
and(
eq(launcherViews.orgId, orgId),
- eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME),
+ eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
@@ -106,7 +98,8 @@ export async function upsertDefaultViewOverride({
.values({
orgId,
userId: orgWide ? null : userId,
- name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME,
+ name: "",
+ isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts
index 9f9701c96..4a986118c 100644
--- a/server/routers/launcher/types.ts
+++ b/server/routers/launcher/types.ts
@@ -89,6 +89,7 @@ export type LauncherViewRecord = {
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
+ isDefault: boolean;
};
export type LauncherDefaultViewOverrides = {
@@ -181,8 +182,6 @@ export function parseIdListParam(value: string | undefined): number[] {
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
-export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__";
-
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts
index 757a16dac..e4373151f 100644
--- a/server/routers/launcher/updateLauncherView.ts
+++ b/server/routers/launcher/updateLauncherView.ts
@@ -9,7 +9,6 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
-import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const updateLauncherViewBodySchema = z.strictObject({
@@ -67,7 +66,7 @@ export async function updateLauncherView(
);
}
- if (isLauncherDefaultOverrideViewName(existing.name)) {
+ if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -145,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,
From 811119a9a6ec3b496f2562032feaed9a94beb4bc Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 3 Jul 2026 10:13:58 -0400
Subject: [PATCH 6/9] reduce filter dropdown page size
---
server/routers/launcher/types.ts | 4 ++--
src/components/LabelsFilterSelector.tsx | 20 ++++++++++++++++---
src/components/multi-site-selector.tsx | 2 +-
.../LauncherFilterPopover.tsx | 5 +++--
src/lib/queries.ts | 4 ++--
5 files changed, 25 insertions(+), 10 deletions(-)
diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts
index 4a986118c..f235e824d 100644
--- a/server/routers/launcher/types.ts
+++ b/server/routers/launcher/types.ts
@@ -122,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("")
});
diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx
index 3c9410e43..84a1819d8 100644
--- a/src/components/LabelsFilterSelector.tsx
+++ b/src/components/LabelsFilterSelector.tsx
@@ -11,7 +11,7 @@ import {
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Checkbox } from "./ui/checkbox";
@@ -25,6 +25,7 @@ type LabelsFilterSelectorProps = {
orgId: string;
isSelected: (label: LabelFilterOption) => 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) => (
selectedLabelIds.has(label.labelId)
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 2626a3ba3..211ab7824 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -1196,7 +1196,7 @@ export const launcherQueries = {
sites: ({
orgId,
query,
- perPage = 500
+ perPage = 20
}: {
orgId: string;
query?: string;
@@ -1228,7 +1228,7 @@ export const launcherQueries = {
labels: ({
orgId,
query,
- perPage = 500
+ perPage = 20
}: {
orgId: string;
query?: string;
From 9edb86fd73d95beb869009779f89953567ed987b Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 3 Jul 2026 10:30:41 -0400
Subject: [PATCH 7/9] add invalidate launcher cache on refresh
---
server/routers/external.ts | 6 ++
server/routers/launcher/index.ts | 1 +
.../launcher/invalidateLauncherCache.ts | 65 +++++++++++++++++++
.../resource-launcher/ResourceLauncher.tsx | 7 +-
4 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 server/routers/launcher/invalidateLauncherCache.ts
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 2b95c2bfc..33fcb3ab3 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -500,6 +500,12 @@ authenticated.get(
launcher.listLauncherViews
);
+authenticated.post(
+ "/org/:orgId/launcher/invalidate-cache",
+ verifyOrgAccess,
+ launcher.invalidateLauncherCache
+);
+
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts
index b9edafc22..1c3fed44c 100644
--- a/server/routers/launcher/index.ts
+++ b/server/routers/launcher/index.ts
@@ -10,3 +10,4 @@ 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/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index 9f9d44d11..3f1249efc 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -44,7 +44,7 @@ import {
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
-import { useMutation, useQuery } 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";
@@ -105,6 +105,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();
@@ -489,6 +490,10 @@ export default function ResourceLauncher({
const refreshData = () => {
startRefreshTransition(async () => {
try {
+ await api.post(`/org/${orgId}/launcher/invalidate-cache`);
+ await queryClient.invalidateQueries({
+ queryKey: ["ORG", orgId, "LAUNCHER"]
+ });
router.refresh();
} catch {
toast({
From 4087d7fb6b67fdb69f3b38d48ba7397683082c36 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 3 Jul 2026 11:18:06 -0400
Subject: [PATCH 8/9] remove text from refresh button
---
src/components/resource-launcher/LauncherRefreshButton.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
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")}
);
}
From a82bae8f9387ce89ace980531e304216063499a4 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Mon, 6 Jul 2026 12:45:07 -0400
Subject: [PATCH 9/9] add resource info to side panel
---
messages/en-US.json | 11 +-
server/routers/external.ts | 8 +
src/components/CopyToClipboard.tsx | 15 +-
src/components/InfoSection.tsx | 14 +-
.../LauncherResourcePanel.tsx | 507 +++++++++++++++++-
.../resource-launcher/ResourceLauncher.tsx | 27 +
src/lib/launcherResourceDetails.ts | 93 ++++
src/lib/queries.ts | 47 ++
8 files changed, 710 insertions(+), 12 deletions(-)
create mode 100644 src/lib/launcherResourceDetails.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index 90be25679..c7164aca3 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3607,7 +3607,16 @@
"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",
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 33fcb3ab3..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,
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}
) : (
-;
+ }
+
+ 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}
+
(null);
+ const [panelOpen, setPanelOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -563,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
@@ -699,6 +716,7 @@ export default function ResourceLauncher({
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
+ onResourceSelect={handleResourceSelect}
/>
) : showFlatResourceList ? (
) : null}
+
+
{activeSavedView ? (
+): 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/queries.ts b/src/lib/queries.ts
index 211ab7824..a086963ed 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -54,8 +54,12 @@ import type {
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";
@@ -1313,5 +1317,48 @@ export const launcherQueries = {
>(`/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
+ };
+ }
})
};