From 35d16ac6835b8b3dc68e302d711136715b2fce48 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:53:10 +0530 Subject: [PATCH 001/112] fix: redirect lost after Pangolin auth session timeout (#3001) --- messages/en-US.json | 3 + src/app/auth/resource/[resourceGuid]/page.tsx | 2 + src/components/OrgPolicyRequired.tsx | 60 +++++++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4a3937d5b..4920dd8e4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2401,6 +2401,9 @@ "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "additionalSecurityRequired": "Additional Security Required", "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "sessionExpired": "Session Expired", + "sessionExpiredReauthRequired": "Your session has expired per your organization's security policy. Please re-authenticate to continue.", + "reauthenticate": "Re-authenticate", "completeTheseSteps": "Complete these steps", "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index c78c277b6..c3f604476 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -166,11 +166,13 @@ export default async function ResourceAuthPage(props: { // If user is not compliant with org policies, show policy requirements if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { + const resourceAuthPageUrl = `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; return (
); diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx index efc3d5321..444be5a46 100644 --- a/src/components/OrgPolicyRequired.tsx +++ b/src/components/OrgPolicyRequired.tsx @@ -11,24 +11,76 @@ import { import { Shield, ArrowRight } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { api } from "@app/lib/api"; type OrgPolicyRequiredProps = { orgId: string; policies: { requiredTwoFactor?: boolean; + maxSessionLength?: { + compliant: boolean; + maxSessionLengthHours: number; + sessionAgeHours: number; + }; + passwordAge?: { + compliant: boolean; + maxPasswordAgeDays: number; + passwordAgeDays: number; + }; }; + redirectAfterAuth?: string; }; export default function OrgPolicyRequired({ orgId, - policies + policies, + redirectAfterAuth }: OrgPolicyRequiredProps) { const t = useTranslations(); + const router = useRouter(); - const policySteps = []; + const sessionExpired = + policies?.maxSessionLength && + policies.maxSessionLength.compliant === false; - if (policies?.requiredTwoFactor === false) { - policySteps.push(t("enableTwoFactorAuthentication")); + function reauthenticate() { + api.post("/auth/logout") + .catch(() => {}) + .then(() => { + const destination = redirectAfterAuth ?? `/${orgId}`; + router.push(destination); + router.refresh(); + }); + } + + if (sessionExpired) { + return ( + + +
+ +
+ + {t("sessionExpired")} + + + {t("sessionExpiredReauthRequired")} + +
+ +
+ +
+
+
+ ); } return ( From 444d293a29575402575c16e92955df1033f7b30a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 23:46:11 +0200 Subject: [PATCH 002/112] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20copy=20command=20?= =?UTF-8?q?bar=20from=20existing=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 23 ++ src/app/page.tsx | 4 +- src/components/Layout.tsx | 79 ++-- .../command-palette/CommandPalette.tsx | 368 ++++++++++++++++++ .../command-palette/CommandPaletteTrigger.tsx | 69 ++++ .../useCommandPaletteActions.tsx | 161 ++++++++ .../useCommandPaletteNavigation.ts | 57 +++ .../useCommandPaletteOrganizations.ts | 45 +++ .../useCommandPaletteSearch.ts | 142 +++++++ src/lib/flattenNavItems.ts | 42 ++ src/lib/hydrateNavHref.ts | 35 ++ 11 files changed, 986 insertions(+), 39 deletions(-) create mode 100644 src/components/command-palette/CommandPalette.tsx create mode 100644 src/components/command-palette/CommandPaletteTrigger.tsx create mode 100644 src/components/command-palette/useCommandPaletteActions.tsx create mode 100644 src/components/command-palette/useCommandPaletteNavigation.ts create mode 100644 src/components/command-palette/useCommandPaletteOrganizations.ts create mode 100644 src/components/command-palette/useCommandPaletteSearch.ts create mode 100644 src/lib/flattenNavItems.ts create mode 100644 src/lib/hydrateNavHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b5..e18385159 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1490,6 +1490,29 @@ "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", + "commandPaletteTitle": "Command palette", + "commandPaletteDescription": "Search for pages, organizations, resources, and actions", + "commandPaletteSearchPlaceholder": "Search pages, resources, actions...", + "commandPaletteNoResults": "No results found.", + "commandPaletteSearching": "Searching...", + "commandPaletteNavigation": "Navigation", + "commandPaletteOrganizations": "Organizations", + "commandPaletteSites": "Sites", + "commandPaletteResources": "Resources", + "commandPaletteUsers": "Users", + "commandPaletteClients": "Machine clients", + "commandPaletteActions": "Actions", + "commandPaletteCreateSite": "Create site", + "commandPaletteCreateProxyResource": "Create public resource", + "commandPaletteCreateUser": "Create user", + "commandPaletteCreateApiKey": "Create API key", + "commandPaletteCreateMachineClient": "Create machine client", + "commandPaletteCreateAlertRule": "Create alert rule", + "commandPaletteCreateIdentityProvider": "Create identity provider", + "commandPaletteToggleTheme": "Toggle theme", + "commandPaletteChooseOrganization": "Choose organization", + "commandPaletteShortcutMac": "⌘K", + "commandPaletteShortcutWindows": "Ctrl K", "otpErrorEnable": "Unable to enable 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA", "otpSetupCheckCode": "Please enter a 6-digit code", diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..d662acb22 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,9 +21,11 @@ export default async function Page(props: { searchParams: Promise<{ redirect: string | undefined; t: string | undefined; + orgs?: string | undefined; }>; }) { const params = await props.searchParams; // this is needed to prevent static optimization + const showOrgPicker = params.orgs === "1"; const env = pullEnv(); @@ -106,7 +108,7 @@ export default async function Page(props: { } } - if (targetOrgId) { + if (targetOrgId && !showOrgPicker) { return ; } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..c0f814453 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; import { cookies } from "next/headers"; +import { CommandPaletteProvider } from "./command-palette/CommandPalette"; interface LayoutProps { children: React.ReactNode; @@ -37,51 +38,53 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Desktop Sidebar */} - {showSidebar && ( - - )} - - {/* Main content area */} -
- {/* Mobile header */} - {showHeader && ( - +
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} - {/* Desktop header */} - {showHeader && } + {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + + )} - {/* Main content */} -
-
- {children} -
-
+ {/* Desktop header */} + {showHeader && } + + {/* Main content */} +
+
+ {children} +
+
+
-
+ ); } diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 000000000..02556b7dd --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@app/components/ui/command"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { Badge } from "@app/components/ui/badge"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPaletteActions } from "./useCommandPaletteActions"; +import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; +import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; +import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; + +type CommandPaletteProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { + const t = useTranslations(); + const router = useRouter(); + const { open, setOpen } = useCommandPalette(); + const [search, setSearch] = useState(""); + + const navigationGroups = useCommandPaletteNavigation(navItems); + const organizations = useCommandPaletteOrganizations(orgs); + const actions = useCommandPaletteActions(orgId, orgs); + const { shouldSearch, sites, resources, users, machineClients, isLoading } = + useCommandPaletteSearch({ + orgId, + query: search, + enabled: open + }); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }, + [setOpen] + ); + + const runCommand = useCallback( + (command: () => void) => { + setOpen(false); + setSearch(""); + command(); + }, + [setOpen] + ); + + const hasEntityResults = + sites.length > 0 || + resources.length > 0 || + users.length > 0 || + machineClients.length > 0; + + return ( + + + + {t("commandPaletteNoResults")} + + {navigationGroups.map((group) => ( + + {group.items.map((item) => ( + + runCommand(() => router.push(item.href)) + } + > + {item.icon} + {item.title} + + ))} + + ))} + + {organizations.length > 1 && ( + <> + + + {organizations.map((org) => ( + + runCommand(() => router.push(org.href)) + } + > + {org.name} + + {org.orgId} + + {org.isPrimaryOrg && ( + + {t("primary")} + + )} + + ))} + + + )} + + {shouldSearch && orgId && ( + <> + + {isLoading && !hasEntityResults ? ( +
+ + {t("commandPaletteSearching")} +
+ ) : ( + <> + {sites.length > 0 && ( + + {sites.map((site) => ( + + runCommand(() => + router.push(site.href) + ) + } + > + + {site.name} + + + ))} + + )} + {resources.length > 0 && ( + + {resources.map((resource) => ( + + runCommand(() => + router.push( + resource.href + ) + ) + } + > + + {resource.name} + + + ))} + + )} + {users.length > 0 && ( + + {users.map((user) => ( + + runCommand(() => + router.push(user.href) + ) + } + > +
+ + {user.name} + + + {user.email} + +
+
+ ))} +
+ )} + {machineClients.length > 0 && ( + + {machineClients.map((client) => ( + + runCommand(() => + router.push(client.href) + ) + } + > + + {client.name} + + + ))} + + )} + + )} + + )} + + {actions.length > 0 && ( + <> + + + {actions.map((action) => ( + + runCommand(() => { + if (action.onSelect) { + action.onSelect(); + } else if (action.href) { + router.push(action.href); + } + }) + } + > + {action.icon} + {action.label} + + ))} + + + )} +
+
+ ); +} + +/*******************************/ +/* COMMAND PALETTE CONTEXT */ +/*******************************/ +export type CommandPaletteContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +}; + +const CommandPaletteContext = createContext( + null +); + +export function useCommandPalette() { + const context = useContext(CommandPaletteContext); + if (!context) { + throw new Error( + "useCommandPalette must be used within CommandPaletteProvider" + ); + } + return context; +} + +//*******************************/ +/* COMMAND PALETTE PROVIDER */ +/*******************************/ +type CommandPaletteProviderProps = { + children: React.ReactNode; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tagName = target.tagName; + return ( + tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" + ); +} + +export function CommandPaletteProvider({ + children, + orgId, + orgs, + navItems +}: CommandPaletteProviderProps) { + const [open, setOpen] = useState(false); + + const toggle = useCallback(() => { + setOpen((current) => !current); + }, []); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + toggle + }), + [open, toggle] + ); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) + ) { + return; + } + + if (!open && isEditableTarget(event.target)) { + return; + } + + event.preventDefault(); + toggle(); + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, toggle]); + + return ( + + {children} + + + ); +} diff --git a/src/components/command-palette/CommandPaletteTrigger.tsx b/src/components/command-palette/CommandPaletteTrigger.tsx new file mode 100644 index 000000000..785013ae2 --- /dev/null +++ b/src/components/command-palette/CommandPaletteTrigger.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { CommandShortcut } from "@app/components/ui/command"; +import { cn } from "@app/lib/cn"; +import { Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPalette } from "./CommandPalette"; + +type CommandPaletteTriggerProps = { + variant?: "header" | "mobile"; + className?: string; +}; + +function useIsMac() { + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform)); + }, []); + + return isMac; +} + +export function CommandPaletteTrigger({ + variant = "header", + className +}: CommandPaletteTriggerProps) { + const t = useTranslations(); + const { setOpen } = useCommandPalette(); + const isMac = useIsMac(); + + if (variant === "mobile") { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx new file mode 100644 index 000000000..998fb97b8 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { build } from "@server/build"; +import { + BellRing, + Building2, + Globe, + KeyRound, + MonitorUp, + Plus, + SunMoon, + UserPlus +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { ListUserOrgsResponse } from "@server/routers/org"; + +export type CommandPaletteAction = { + id: string; + label: string; + icon: ReactNode; + href?: string; + onSelect?: () => void; +}; + +export function useCommandPaletteActions( + orgId?: string, + orgs?: ListUserOrgsResponse["orgs"] +): CommandPaletteAction[] { + const t = useTranslations(); + const pathname = usePathname(); + const { env } = useEnvContext(); + const { user } = useUserContext(); + const { setTheme, theme } = useTheme(); + const isAdminPage = pathname?.startsWith("/admin"); + + return useMemo(() => { + const actions: CommandPaletteAction[] = []; + + function cycleTheme() { + const currentTheme = theme || "system"; + if (currentTheme === "light") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + } + + if (isAdminPage) { + actions.push({ + id: "create-admin-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: "/admin/api-keys/create" + }); + + if ( + build === "oss" || + env?.app.identityProviderMode === "global" || + env?.app.identityProviderMode === undefined + ) { + actions.push({ + id: "create-admin-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: "/admin/idp/create" + }); + } + } else if (orgId) { + actions.push({ + id: "create-site", + label: t("commandPaletteCreateSite"), + icon: , + href: `/${orgId}/settings/sites/create` + }); + actions.push({ + id: "create-proxy-resource", + label: t("commandPaletteCreateProxyResource"), + icon: , + href: `/${orgId}/settings/resources/proxy/create` + }); + actions.push({ + id: "create-user", + label: t("commandPaletteCreateUser"), + icon: , + href: `/${orgId}/settings/access/users/create` + }); + actions.push({ + id: "create-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: `/${orgId}/settings/api-keys/create` + }); + actions.push({ + id: "create-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/create` + }); + + if (!env?.flags.disableEnterpriseFeatures) { + actions.push({ + id: "create-alert-rule", + label: t("commandPaletteCreateAlertRule"), + icon: , + href: `/${orgId}/settings/alerting/create` + }); + } + + if ( + (build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && build !== "oss") + ) { + actions.push({ + id: "create-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: `/${orgId}/settings/idp/create` + }); + } + } + + const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1; + + if (canChooseOrganization) { + actions.push({ + id: "choose-org", + label: t("commandPaletteChooseOrganization"), + icon: , + href: "/?orgs=1" + }); + } + + actions.push({ + id: "toggle-theme", + label: t("commandPaletteToggleTheme"), + icon: , + onSelect: cycleTheme + }); + + if (user.serverAdmin && !isAdminPage) { + actions.push({ + id: "go-admin", + label: t("serverAdmin"), + icon: , + href: "/admin/users" + }); + } + + return actions; + }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]); +} \ No newline at end of file diff --git a/src/components/command-palette/useCommandPaletteNavigation.ts b/src/components/command-palette/useCommandPaletteNavigation.ts new file mode 100644 index 000000000..1968d1a89 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteNavigation.ts @@ -0,0 +1,57 @@ +"use client"; + +import type { SidebarNavSection } from "@app/components/SidebarNav"; +import { flattenNavSections } from "@app/lib/flattenNavItems"; +import { + hydrateNavHref, + navHrefParamsFromRoute +} from "@app/lib/hydrateNavHref"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; + +export type NavigationCommand = { + id: string; + title: string; + href: string; + icon?: React.ReactNode; + sectionHeading: string; +}; + +export type NavigationCommandGroup = { + heading: string; + items: NavigationCommand[]; +}; + +export function useCommandPaletteNavigation( + navItems: SidebarNavSection[] +): NavigationCommandGroup[] { + const params = useParams(); + const t = useTranslations(); + + return useMemo(() => { + const hrefParams = navHrefParamsFromRoute(params); + const flat = flattenNavSections(navItems); + const groups = new Map(); + + for (const item of flat) { + const href = hydrateNavHref(item.href, hrefParams); + if (!href) continue; + + const groupItems = groups.get(item.sectionHeading) ?? []; + groupItems.push({ + id: `nav-${item.sectionHeading}-${item.title}-${href}`, + title: t(item.title), + href, + icon: item.icon, + sectionHeading: item.sectionHeading + }); + groups.set(item.sectionHeading, groupItems); + } + + return Array.from(groups.entries()).map(([heading, items]) => ({ + heading: t(heading), + items + })); + }, [navItems, params, t]); +} diff --git a/src/components/command-palette/useCommandPaletteOrganizations.ts b/src/components/command-palette/useCommandPaletteOrganizations.ts new file mode 100644 index 000000000..1e5343ccd --- /dev/null +++ b/src/components/command-palette/useCommandPaletteOrganizations.ts @@ -0,0 +1,45 @@ +"use client"; + +import { ListUserOrgsResponse } from "@server/routers/org"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; + +export type OrganizationCommand = { + id: string; + orgId: string; + name: string; + isPrimaryOrg?: boolean; + href: string; +}; + +export function useCommandPaletteOrganizations( + orgs: ListUserOrgsResponse["orgs"] | undefined +): OrganizationCommand[] { + const pathname = usePathname(); + + return useMemo(() => { + if (!orgs?.length) return []; + + const sortedOrgs = [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) return -1; + if (!aPrimary && bPrimary) return 1; + return 0; + }); + + return sortedOrgs.map((org) => { + const newPath = pathname.includes("/settings/") + ? pathname.replace(/^\/[^/]+/, `/${org.orgId}`) + : `/${org.orgId}`; + + return { + id: `org-${org.orgId}`, + orgId: org.orgId, + name: org.name, + isPrimaryOrg: org.isPrimaryOrg, + href: newPath + }; + }); + }, [orgs, pathname]); +} diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts new file mode 100644 index 000000000..984892354 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -0,0 +1,142 @@ +"use client"; + +import { orgQueries } from "@app/lib/queries"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useDebounce } from "use-debounce"; + +const SEARCH_PER_PAGE = 5; +const MIN_QUERY_LENGTH = 2; + +export type SiteSearchResult = { + id: string; + name: string; + href: string; +}; + +export type ResourceSearchResult = { + id: string; + name: string; + href: string; +}; + +export type UserSearchResult = { + id: string; + name: string; + email: string; + href: string; +}; + +export type ClientSearchResult = { + id: string; + name: string; + href: string; +}; + +export function useCommandPaletteSearch({ + orgId, + query, + enabled +}: { + orgId?: string; + query: string; + enabled: boolean; +}) { + const [debouncedQuery] = useDebounce(query, 150); + const trimmedQuery = debouncedQuery.trim(); + const shouldSearch = + enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH; + + const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({ + queries: [ + { + ...orgQueries.sites({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.resources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.users({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.machineClients({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + } + ] + }); + + const sites = useMemo((): SiteSearchResult[] => { + if (!orgId || !sitesQuery.data) return []; + return sitesQuery.data.map((site) => ({ + id: `site-${site.siteId}`, + name: site.name, + href: `/${orgId}/settings/sites/${site.niceId}` + })); + }, [orgId, sitesQuery.data]); + + const resources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !resourcesQuery.data) return []; + return resourcesQuery.data.map((resource) => ({ + id: `resource-${resource.resourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/proxy/${resource.niceId}` + })); + }, [orgId, resourcesQuery.data]); + + const users = useMemo((): UserSearchResult[] => { + if (!orgId || !usersQuery.data) return []; + return usersQuery.data.map((user) => ({ + id: `user-${user.id}`, + name: user.name ?? user.email ?? user.username ?? "", + email: user.email ?? user.username ?? "", + href: `/${orgId}/settings/access/users/${user.id}` + })); + }, [orgId, usersQuery.data]); + + const machineClients = useMemo((): ClientSearchResult[] => { + if (!orgId || !clientsQuery.data) return []; + return clientsQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/machine/${client.niceId}` + })); + }, [orgId, clientsQuery.data]); + + const isLoading = + shouldSearch && + (sitesQuery.isFetching || + resourcesQuery.isFetching || + usersQuery.isFetching || + clientsQuery.isFetching); + + return { + debouncedQuery: trimmedQuery, + shouldSearch, + sites, + resources, + users, + machineClients, + isLoading + }; +} diff --git a/src/lib/flattenNavItems.ts b/src/lib/flattenNavItems.ts new file mode 100644 index 000000000..b64268322 --- /dev/null +++ b/src/lib/flattenNavItems.ts @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import type { + SidebarNavItem, + SidebarNavSection +} from "@app/components/SidebarNav"; + +export type FlatNavItem = { + title: string; + href: string; + icon?: ReactNode; + sectionHeading: string; +}; + +function flattenItems( + items: SidebarNavItem[], + sectionHeading: string, + result: FlatNavItem[] +) { + for (const item of items) { + if (item.href) { + result.push({ + title: item.title, + href: item.href, + icon: item.icon, + sectionHeading + }); + } + if (item.items?.length) { + flattenItems(item.items, sectionHeading, result); + } + } +} + +export function flattenNavSections( + sections: SidebarNavSection[] +): FlatNavItem[] { + const result: FlatNavItem[] = []; + for (const section of sections) { + flattenItems(section.items, section.heading, result); + } + return result; +} diff --git a/src/lib/hydrateNavHref.ts b/src/lib/hydrateNavHref.ts new file mode 100644 index 000000000..45fa0f5ee --- /dev/null +++ b/src/lib/hydrateNavHref.ts @@ -0,0 +1,35 @@ +export type NavHrefParams = { + orgId?: string; + niceId?: string; + resourceId?: string; + userId?: string; + apiKeyId?: string; + clientId?: string; +}; + +export function hydrateNavHref( + val: string | undefined, + params: NavHrefParams +): string | undefined { + if (!val) return undefined; + return val + .replace("{orgId}", params.orgId ?? "") + .replace("{niceId}", params.niceId ?? "") + .replace("{resourceId}", params.resourceId ?? "") + .replace("{userId}", params.userId ?? "") + .replace("{apiKeyId}", params.apiKeyId ?? "") + .replace("{clientId}", params.clientId ?? ""); +} + +export function navHrefParamsFromRoute( + params: Record +): NavHrefParams { + return { + orgId: params.orgId as string | undefined, + niceId: params.niceId as string | undefined, + resourceId: params.resourceId as string | undefined, + userId: params.userId as string | undefined, + apiKeyId: params.apiKeyId as string | undefined, + clientId: params.clientId as string | undefined + }; +} From a68b57067c5b93662e18c1da0f9f48d3d41ebff9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 12 Jun 2026 23:51:05 +0200 Subject: [PATCH 003/112] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20Use=20command=20b?= =?UTF-8?q?ar=20nav=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/layout.tsx | 5 +- src/app/navigation.tsx | 250 ++++++++++++++++++ src/components/Layout.tsx | 8 +- src/components/SitesTable.tsx | 7 - .../command-palette/CommandPalette.tsx | 85 +++--- 5 files changed, 315 insertions(+), 40 deletions(-) diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 6a3d648a4..cac644be3 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; -import { orgNavSections } from "@app/app/navigation"; +import { commandBarNavSections, orgNavSections } from "@app/app/navigation"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; export const dynamic = "force-dynamic"; @@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { navItems={orgNavSections(env, { isPrimaryOrg: primaryOrg })} + commandNavItems={commandBarNavSections(env, { + isPrimaryOrg: primaryOrg + })} > {children} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 43323ba2f..e0ada4c1c 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -340,3 +340,253 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ ] } ]; + +export const commandBarNavSections = ( + env?: Env, + options?: OrgNavSectionsOptions +): SidebarNavSection[] => [ + { + heading: "network", + items: [ + { + title: "sidebarSites", + href: "/{orgId}/settings/sites", + icon: + }, + { + title: "sidebarResources", + icon: , + items: [ + { + title: "sidebarProxyResources", + href: "/{orgId}/settings/resources/public", + icon: + }, + { + title: "sidebarClientResources", + href: "/{orgId}/settings/resources/private", + icon: + } + ] + }, + { + title: "sidebarClients", + icon: , + items: [ + { + href: "/{orgId}/settings/clients/user", + title: "sidebarUserDevices", + icon: + }, + { + href: "/{orgId}/settings/clients/machine", + title: "sidebarMachineClients", + icon: + } + ] + }, + { + title: "sidebarDomains", + href: "/{orgId}/settings/domains", + icon: + }, + ...(build === "saas" + ? [ + { + title: "sidebarRemoteExitNodes", + href: "/{orgId}/settings/remote-exit-nodes", + icon: + } + ] + : []) + ] + }, + { + heading: "accessControl", + items: [ + { + title: "sidebarTeam", + icon: , + items: [ + { + title: "sidebarUsers", + href: "/{orgId}/settings/access/users", + icon: + }, + { + title: "sidebarRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "sidebarInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + } + ] + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarPolicies", + + icon: , + items: [ + { + title: "sidebarResourcePolicies", + href: "/{orgId}/settings/policies/resources/public", + icon: ( + + ) + } + ] + } + ] + : []), + // PaidFeaturesAlert + ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && build !== "oss") + ? [ + { + title: "sidebarIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + } + ] + : []), + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + } + ] + : []), + { + title: "sidebarShareableLinks", + href: "/{orgId}/settings/share-links", + icon: + } + ] + }, + { + heading: "sidebarOrganization", + items: [ + { + title: "sidebarLogsAndAnalytics", + icon: , + items: [ + { + title: "sidebarLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }, + { + title: "sidebarLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: ( + + ) + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + }, + { + title: "sidebarLogsConnection", + href: "/{orgId}/settings/logs/connection", + icon: + }, + { + title: "sidebarLogsStreaming", + href: "/{orgId}/settings/logs/streaming", + icon: + } + ] + : []) + ] + }, + { + title: "sidebarManagement", + icon: , + items: [ + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: ( + + ) + }, + { + title: "sidebarProvisioning", + href: "/{orgId}/settings/provisioning", + icon: + } + ] + : []), + { + title: "sidebarBluePrints", + href: "/{orgId}/settings/blueprints", + icon: + }, + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "labels", + href: "/{orgId}/settings/labels", + icon: + } + ] + : []) + ] + }, + ...(build === "saas" && options?.isPrimaryOrg + ? [ + { + title: "sidebarBillingAndLicenses", + icon: , + items: [ + { + title: "sidebarBilling", + href: "/{orgId}/settings/billing", + icon: ( + + ) + }, + { + title: "sidebarEnterpriseLicenses", + href: "/{orgId}/settings/license", + icon: ( + + ) + } + ] + } + ] + : []), + { + title: "sidebarSettings", + href: "/{orgId}/settings/general", + icon: + } + ] + } +]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c0f814453..8dc95968f 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -13,6 +13,7 @@ interface LayoutProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; navItems?: SidebarNavSection[]; + commandNavItems?: SidebarNavSection[]; showSidebar?: boolean; showHeader?: boolean; showTopBar?: boolean; @@ -24,6 +25,7 @@ export async function Layout({ orgId, orgs, navItems = [], + commandNavItems = [], showSidebar = true, showHeader = true, showTopBar = true, @@ -38,7 +40,11 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( - +
{/* Desktop Sidebar */} {showSidebar && ( diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a..98b69fa3e 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -113,13 +113,6 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); - const booleanSearchFilterSchema = z .enum(["true", "false"]) .optional() diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 02556b7dd..b0536d8a9 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -14,7 +14,7 @@ import { Badge } from "@app/components/ui/badge"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { +import React, { createContext, useCallback, useContext, @@ -27,6 +27,7 @@ import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; +import { useUserContext } from "@app/hooks/useUserContext"; type CommandPaletteProps = { orgId?: string; @@ -34,6 +35,12 @@ type CommandPaletteProps = { navItems: SidebarNavSection[]; }; +/** + * Plan for command bar: + * - the nav items should be custom items instead of all of the ones in the sidebar + * - actions should be triggered by using `>` (like in Github) + */ + export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const t = useTranslations(); const router = useRouter(); @@ -41,7 +48,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const [search, setSearch] = useState(""); const navigationGroups = useCommandPaletteNavigation(navItems); - const organizations = useCommandPaletteOrganizations(orgs); + // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); const { shouldSearch, sites, resources, users, machineClients, isLoading } = useCommandPaletteSearch({ @@ -69,45 +76,58 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { [setOpen] ); - const hasEntityResults = - sites.length > 0 || - resources.length > 0 || - users.length > 0 || - machineClients.length > 0; + // const hasEntityResults = + // sites.length > 0 || + // resources.length > 0 || + // users.length > 0 || + // machineClients.length > 0; return ( - + {t("commandPaletteNoResults")} - {navigationGroups.map((group) => ( - - {group.items.map((item) => ( - - runCommand(() => router.push(item.href)) - } - > - {item.icon} - {item.title} - - ))} - + + + {navigationGroups.map((group, idx) => ( + + {idx > 0 && } + + {group.items.map((item) => ( + + runCommand(() => router.push(item.href)) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + ))} - {organizations.length > 1 && ( + {/* {organizations.length > 1 && ( <> - )} + )} */} - {shouldSearch && orgId && ( + {/* {shouldSearch && orgId && ( <> {isLoading && !hasEntityResults ? ( @@ -243,12 +263,15 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} - )} + )} */} - {actions.length > 0 && ( + {/* {actions.length > 0 && ( <> - + {actions.map((action) => ( - )} + )} */} ); From abc0a41d9ecf9a27a023202a729311d748c63310 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:27:39 +0200 Subject: [PATCH 004/112] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20reorganize=20comma?= =?UTF-8?q?nd=20bar=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 36 ++ src/app/navigation.tsx | 328 +++++++++--------- src/components/Layout.tsx | 7 +- .../command-palette/CommandPalette.tsx | 27 +- .../useCommandPaletteNavigation.ts | 6 +- src/components/ui/command.tsx | 6 +- 6 files changed, 223 insertions(+), 187 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e18385159..e6567cc08 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1590,6 +1590,42 @@ "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", + "commandSites": "Sites", + "commandActionModeInfo": "Type \">\" to open action mode", + "commandResources": "Resources", + "commandProxyResources": "Public Resources", + "commandClientResources": "Private Resources", + "commandClients": "Clients", + "commandUserDevices": "User Devices", + "commandMachineClients": "Machine Clients", + "commandDomains": "Domains", + "commandRemoteExitNodes": "Remote Nodes", + "commandTeam": "Team", + "commandUsers": "Users", + "commandRoles": "Roles", + "commandInvitations": "Invitations", + "commandPolicies": "Shared Policies", + "commandResourcePolicies": "Public Resources Policies", + "commandIdentityProviders": "Identity Providers", + "commandApprovals": "Approval Requests", + "commandShareableLinks": "Shareable Links", + "commandOrganization": "Organization", + "commandLogsAndAnalytics": "Logs & Analytics", + "commandLogsAnalytics": "Analytics", + "commandLogsRequest": "HTTP Request Logs", + "commandLogsAccess": "Authentication Logs", + "commandLogsAction": "Admin Action Logs", + "commandLogsConnection": "Network Logs", + "commandLogsStreaming": "Event Streaming", + "commandManagement": "Management", + "commandAlerting": "Alerting", + "commandProvisioning": "Provisioning", + "commandBluePrints": "Blueprints", + "commandApiKeys": "API Keys", + "commandBillingAndLicenses": "Billing & Licenses", + "commandBilling": "Billing", + "commandEnterpriseLicenses": "Licenses", + "commandSettings": "Settings", "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index e0ada4c1c..b3eab82ed 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -341,59 +341,56 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ } ]; +export type CommandBarNavSection = { + // Added from 'dev' branch + heading: string; + items: CommandBarNavItem[]; +}; + +export type CommandBarNavItem = { + href?: string; + title: string; + icon?: React.ReactNode; + showEE?: boolean; + isBeta?: boolean; +}; + export const commandBarNavSections = ( env?: Env, options?: OrgNavSectionsOptions -): SidebarNavSection[] => [ +): CommandBarNavSection[] => [ { heading: "network", items: [ { - title: "sidebarSites", + title: "commandSites", href: "/{orgId}/settings/sites", icon: }, { - title: "sidebarResources", - icon: , - items: [ - { - title: "sidebarProxyResources", - href: "/{orgId}/settings/resources/public", - icon: - }, - { - title: "sidebarClientResources", - href: "/{orgId}/settings/resources/private", - icon: - } - ] - }, - { - title: "sidebarClients", - icon: , - items: [ - { - href: "/{orgId}/settings/clients/user", - title: "sidebarUserDevices", - icon: - }, - { - href: "/{orgId}/settings/clients/machine", - title: "sidebarMachineClients", - icon: - } - ] - }, - { - title: "sidebarDomains", - href: "/{orgId}/settings/domains", + title: "commandProxyResources", + href: "/{orgId}/settings/resources/public", icon: }, + { + title: "commandClientResources", + href: "/{orgId}/settings/resources/private", + icon: + }, + { + href: "/{orgId}/settings/clients/user", + title: "commandUserDevices", + icon: + }, + { + href: "/{orgId}/settings/clients/machine", + title: "commandMachineClients", + icon: + }, ...(build === "saas" ? [ { - title: "sidebarRemoteExitNodes", + title: "commandRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", icon: } @@ -402,44 +399,34 @@ export const commandBarNavSections = ( ] }, { - heading: "accessControl", + heading: "commandTeam", items: [ { - title: "sidebarTeam", - icon: , - items: [ - { - title: "sidebarUsers", - href: "/{orgId}/settings/access/users", - icon: - }, - { - title: "sidebarRoles", - href: "/{orgId}/settings/access/roles", - icon: - }, - { - title: "sidebarInvitations", - href: "/{orgId}/settings/access/invitations", - icon: - } - ] + title: "commandUsers", + href: "/{orgId}/settings/access/users", + icon: }, + { + title: "commandRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "commandInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + } + ] + }, + { + heading: "accessControl", + items: [ ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarPolicies", - - icon: , - items: [ - { - title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resources/public", - icon: ( - - ) - } - ] + title: "commandResourcePolicies", + href: "/{orgId}/settings/policies/resources/public", + icon: } ] : []), @@ -450,7 +437,7 @@ export const commandBarNavSections = ( (env?.app.identityProviderMode === undefined && build !== "oss") ? [ { - title: "sidebarIdentityProviders", + title: "commandIdentityProviders", href: "/{orgId}/settings/idp", icon: } @@ -459,134 +446,129 @@ export const commandBarNavSections = ( ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarApprovals", + title: "commandApprovals", href: "/{orgId}/settings/access/approvals", icon: } ] : []), { - title: "sidebarShareableLinks", + title: "commandShareableLinks", href: "/{orgId}/settings/share-links", icon: } ] }, { - heading: "sidebarOrganization", + heading: "commandLogsAndAnalytics", items: [ { - title: "sidebarLogsAndAnalytics", - icon: , - items: [ - { - title: "sidebarLogsAnalytics", - href: "/{orgId}/settings/logs/analytics", - icon: - }, - { - title: "sidebarLogsRequest", - href: "/{orgId}/settings/logs/request", - icon: ( - - ) - }, - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - }, - { - title: "sidebarLogsConnection", - href: "/{orgId}/settings/logs/connection", - icon: - }, - { - title: "sidebarLogsStreaming", - href: "/{orgId}/settings/logs/streaming", - icon: - } - ] - : []) - ] + title: "commandLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: }, { - title: "sidebarManagement", - icon: , - items: [ - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "sidebarAlerting", - href: "/{orgId}/settings/alerting", - icon: ( - - ) - }, - { - title: "sidebarProvisioning", - href: "/{orgId}/settings/provisioning", - icon: - } - ] - : []), - { - title: "sidebarBluePrints", - href: "/{orgId}/settings/blueprints", - icon: - }, - { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: - }, - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "labels", - href: "/{orgId}/settings/labels", - icon: - } - ] - : []) - ] + title: "commandLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: }, - ...(build === "saas" && options?.isPrimaryOrg + ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarBillingAndLicenses", - icon: , - items: [ - { - title: "sidebarBilling", - href: "/{orgId}/settings/billing", - icon: ( - - ) - }, - { - title: "sidebarEnterpriseLicenses", - href: "/{orgId}/settings/license", - icon: ( - - ) - } - ] + title: "commandLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "commandLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + }, + { + title: "commandLogsConnection", + href: "/{orgId}/settings/logs/connection", + icon: + }, + { + title: "commandLogsStreaming", + href: "/{orgId}/settings/logs/streaming", + icon: + } + ] + : []) + ] + }, + { + heading: "commandManagement", + items: [ + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "commandAlerting", + href: "/{orgId}/settings/alerting", + icon: + }, + { + title: "commandProvisioning", + href: "/{orgId}/settings/provisioning", + icon: } ] : []), { - title: "sidebarSettings", + title: "commandBluePrints", + href: "/{orgId}/settings/blueprints", + icon: + }, + { + title: "commandApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "labels", + href: "/{orgId}/settings/labels", + icon: + } + ] + : []) + ] + }, + + { + heading: "commandOrganization", + items: [ + { + title: "commandSettings", href: "/{orgId}/settings/general", icon: + }, + { + title: "commandDomains", + href: "/{orgId}/settings/domains", + icon: } ] - } + }, + ...(build === "saas" && options?.isPrimaryOrg + ? [ + { + heading: "commandBillingAndLicenses", + items: [ + { + title: "commandBilling", + href: "/{orgId}/settings/billing", + icon: + }, + { + title: "commandEnterpriseLicenses", + href: "/{orgId}/settings/license", + icon: + } + ] + } + ] + : []) ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8dc95968f..991c489a7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,7 +1,10 @@ import React from "react"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import type { SidebarNavSection } from "@app/app/navigation"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; @@ -13,7 +16,7 @@ interface LayoutProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; navItems?: SidebarNavSection[]; - commandNavItems?: SidebarNavSection[]; + commandNavItems?: CommandBarNavSection[]; showSidebar?: boolean; showHeader?: boolean; showTopBar?: boolean; diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index b0536d8a9..bbf5b9187 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -9,7 +9,10 @@ import { CommandList, CommandSeparator } from "@app/components/ui/command"; -import type { SidebarNavSection } from "@app/app/navigation"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { Badge } from "@app/components/ui/badge"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Loader2 } from "lucide-react"; @@ -28,11 +31,12 @@ import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; type CommandPaletteProps = { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; - navItems: SidebarNavSection[]; + navItems: CommandBarNavSection[]; }; /** @@ -89,28 +93,35 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { title={t("commandPaletteTitle")} description={t("commandPaletteDescription")} className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15" + commandProps={{ + loop: true + }} > - + {t("commandPaletteNoResults")} - {navigationGroups.map((group, idx) => ( + {navigationGroups.map((group, groupIndex) => ( - {idx > 0 && } + {groupIndex > 0 && } 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} > - {group.items.map((item) => ( + {group.items.map((item, itemIndex) => ( ; }) { return ( @@ -49,7 +50,10 @@ function CommandDialog({ {description} - + {children} From ce77268c82a7d2b1ed4618e80e752d3856612718 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:47:36 +0200 Subject: [PATCH 005/112] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20block=20top=20pos?= =?UTF-8?q?ition=20of=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 9 +++++++-- src/components/ui/command.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index bbf5b9187..3045e534e 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -88,13 +88,18 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { return ( {title} {description} - + {children} From 3bc5ab2136f030c0bc5477ba95001c9b69b7b546 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:48:32 +0200 Subject: [PATCH 006/112] =?UTF-8?q?=F0=9F=9A=A7=20=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 3045e534e..f0c022430 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -43,6 +43,7 @@ type CommandPaletteProps = { * Plan for command bar: * - the nav items should be custom items instead of all of the ones in the sidebar * - actions should be triggered by using `>` (like in Github) + * -> if search starts with `>`, the filter should exclude that char in the filter string */ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { From c0a9db92ef13556a6fe158dbae0cfb86e9ac341a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 19:28:22 +0200 Subject: [PATCH 007/112] =?UTF-8?q?=F0=9F=92=84=20update=20command=20bar?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command-palette/CommandPalette.tsx | 110 +++++++++++------- src/components/ui/command.tsx | 13 ++- 2 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index f0c022430..726d1c999 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,5 +1,9 @@ "use client"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { CommandDialog, CommandEmpty, @@ -9,13 +13,9 @@ import { CommandList, CommandSeparator } from "@app/components/ui/command"; -import type { - CommandBarNavSection, - SidebarNavSection -} from "@app/app/navigation"; -import { Badge } from "@app/components/ui/badge"; +import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { createContext, @@ -25,13 +25,10 @@ import React, { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; -import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { cn } from "@app/lib/cn"; +import { search } from "jmespath"; type CommandPaletteProps = { orgId?: string; @@ -52,14 +49,16 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const { open, setOpen } = useCommandPalette(); const [search, setSearch] = useState(""); + const isActionMode = search.startsWith(">"); + const navigationGroups = useCommandPaletteNavigation(navItems); // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); const { shouldSearch, sites, resources, users, machineClients, isLoading } = useCommandPaletteSearch({ orgId, - query: search, - enabled: open + query: search.substring(1), + enabled: open && isActionMode }); const handleOpenChange = useCallback( @@ -96,9 +95,24 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15" commandProps={{ loop: true, - filter(value, search) { - if (value.toLowerCase().includes(search.toLowerCase())) + filter(value, query) { + let search = query; + if (query.startsWith(">")) { + search = query.substring(1); + + console.log({ + search, + value + }); + } + + if ( + value + .toLowerCase() + .includes(search.trim().toLowerCase()) + ) { return 1; + } return 0; } }} @@ -107,8 +121,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { placeholder={t("commandPaletteSearchPlaceholder")} value={search} onValueChange={setSearch} + // isLoading /> - + {t("commandPaletteNoResults")} - {navigationGroups.map((group, groupIndex) => ( - - {groupIndex > 0 && } - 0 && - "[&_[cmdk-group-heading]]:pt-3" - )} - > - {group.items.map((item, itemIndex) => ( - - runCommand(() => router.push(item.href)) - } - className="h-9" + {!isActionMode && + navigationGroups.map((group, groupIndex) => { + console.log({ + groupIndex, + groupHeading: group.heading + }); + return ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} > - {item.icon} - {item.title} - - ))} - - - ))} + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ); + })} {/* {organizations.length > 1 && ( <> @@ -282,7 +306,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} */} - {/* {actions.length > 0 && ( + {isActionMode && actions.length > 0 && ( <> - )} */} + )} ); diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index f4b9e1bfe..efd1c35f5 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon } from "lucide-react"; +import { LoaderIcon, SearchIcon } from "lucide-react"; import { Dialog, @@ -68,14 +68,21 @@ function CommandDialog({ function CommandInput({ className, + isLoading, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + isLoading?: boolean; +}) { return (
- + {isLoading ? ( + + ) : ( + + )} Date: Tue, 16 Jun 2026 22:28:04 +0200 Subject: [PATCH 008/112] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20query,=20?= =?UTF-8?q?=20add=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreateShareLinkForm.tsx | 2 +- .../alert-rule-editor/AlertRuleFields.tsx | 2 +- src/components/resource-selector.tsx | 2 +- src/lib/queries.ts | 82 ++++++++++++++++++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 78f705445..95884b486 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -90,7 +90,7 @@ export default function CreateShareLinkForm({ const t = useTranslations(); const { data: allResources = [] } = useQuery( - orgQueries.resources({ orgId: org?.org.orgId ?? "" }) + orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" }) ); const [selectedResource, setSelectedResource] = diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index d787595ed..7ae6d0a04 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -348,7 +348,7 @@ function ResourceMultiSelect({ const [debounced] = useDebounce(q, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 }) ); const shown = useMemo(() => { diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index b01263333..625bb2d64 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -39,7 +39,7 @@ export function ResourceSelector({ const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ + orgQueries.proxyResources({ orgId: orgId, query: debouncedSearchQuery, perPage: 10 diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b1..18e937ceb 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -7,7 +7,10 @@ import type { QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; -import type { ListClientsResponse } from "@server/routers/client"; +import type { + ListClientsResponse, + ListUserDevicesResponse +} from "@server/routers/client"; import type { GetDNSRecordsResponse, ListDomainsResponse @@ -25,6 +28,7 @@ import type { import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { + ListAllSiteResourcesByOrgResponse, ListSiteResourceClientsResponse, ListSiteResourceRolesResponse, ListSiteResourceUsersResponse @@ -38,7 +42,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; -import z from "zod"; +import z, { meta } from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; @@ -138,6 +142,38 @@ export const orgQueries = { return res.data.data.clients; } }), + userDevices: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "USER_DEVICES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/user-devices?${sp.toString()}`, { signal }); + + return res.data.data.devices; + } + }), users: ({ orgId, query, @@ -282,7 +318,7 @@ export const orgQueries = { } }), - resources: ({ + proxyResources: ({ orgId, query, perPage = 10_000 @@ -292,7 +328,12 @@ export const orgQueries = { perPage?: number; }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const, + queryKey: [ + "ORG", + orgId, + "PROXY_RESOURCES", + { query, perPage } + ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ pageSize: perPage.toString() @@ -310,6 +351,39 @@ export const orgQueries = { } }), + privateResources: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "PRIVATE_RESOURCES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-resources?${sp.toString()}`, { signal }); + + return res.data.data.siteResources; + } + }), + healthChecks: ({ orgId, perPage = 10_000 From 6380079239a599996255a0883348ed2fd9106b35 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 22:28:31 +0200 Subject: [PATCH 009/112] =?UTF-8?q?=E2=9C=A8=20Command=20palette=20with=20?= =?UTF-8?q?search=20&=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../command-palette/CommandPalette.tsx | 362 ++++++++++-------- .../useCommandPaletteSearch.ts | 74 +++- 3 files changed, 270 insertions(+), 167 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e6567cc08..cf32d15fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1626,6 +1626,7 @@ "commandBilling": "Billing", "commandEnterpriseLicenses": "Licenses", "commandSettings": "Settings", + "commandSearchResults": "Search Results", "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 726d1c999..816c83fb3 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -15,6 +15,15 @@ import { } from "@app/components/ui/command"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { + ChevronRightIcon, + GlobeIcon, + GlobeLockIcon, + LaptopIcon, + PlugIcon, + ServerIcon, + UserIcon +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { @@ -28,7 +37,7 @@ import React, { import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; -import { search } from "jmespath"; +import { resources } from "@server/db"; type CommandPaletteProps = { orgId?: string; @@ -54,12 +63,21 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const navigationGroups = useCommandPaletteNavigation(navItems); // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); - const { shouldSearch, sites, resources, users, machineClients, isLoading } = - useCommandPaletteSearch({ - orgId, - query: search.substring(1), - enabled: open && isActionMode - }); + const { + shouldSearch, + sites, + publicResources, + privateResources, + users, + machineClients, + userDevices, + isLoading, + hasResults: hasEntityResults + } = useCommandPaletteSearch({ + orgId, + query: search, + enabled: !isActionMode && open + }); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -80,15 +98,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { [setOpen] ); - // const hasEntityResults = - // sites.length > 0 || - // resources.length > 0 || - // users.length > 0 || - // machineClients.length > 0; - return ( - + {t("commandPaletteNoResults")} {!isActionMode && - navigationGroups.map((group, groupIndex) => { - console.log({ - groupIndex, - groupHeading: group.heading - }); - return ( - - {groupIndex > 0 && } - 0 && - "[&_[cmdk-group-heading]]:pt-3" - )} - > - {group.items.map((item) => ( - - runCommand(() => - router.push(item.href) - ) - } - className="h-9" - > - {item.icon} - {item.title} - - ))} - - - ); - })} + navigationGroups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} + > + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ))} {/* {organizations.length > 1 && ( <> @@ -200,118 +206,157 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} */} - {/* {shouldSearch && orgId && ( - <> - - {isLoading && !hasEntityResults ? ( -
- - {t("commandPaletteSearching")} -
- ) : ( - <> - {sites.length > 0 && ( - - {sites.map((site) => ( - - runCommand(() => - router.push(site.href) - ) - } - > - - {site.name} - - - ))} - - )} - {resources.length > 0 && ( - - {resources.map((resource) => ( - - runCommand(() => - router.push( - resource.href - ) - ) - } - > - - {resource.name} - - - ))} - - )} - {users.length > 0 && ( - - {users.map((user) => ( - - runCommand(() => - router.push(user.href) - ) - } - > -
- - {user.name} - - - {user.email} - -
-
- ))} -
- )} - {machineClients.length > 0 && ( - - {machineClients.map((client) => ( - - runCommand(() => - router.push(client.href) - ) - } - > - - {client.name} - - - ))} - - )} - + {!isActionMode && shouldSearch && orgId && hasEntityResults && ( + - )} */} + > + {sites.map((site) => ( + + runCommand(() => router.push(site.href)) + } + className="h-9" + > +
+ + + {t("commandSites")} + + + + {site.name} + +
+
+ ))} + {publicResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandProxyResources")} + + + + {resource.name} + +
+
+ ))} + {privateResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandClientResources")} + + + + {resource.name} + +
+
+ ))} + {users.map((user) => ( + + runCommand(() => router.push(user.href)) + } + className="h-9" + > +
+ + + {t("commandUsers")} + + +
+ + {user.name} + + + · + + + {user.email} + +
+
+
+ ))} + {machineClients.map((client) => ( + + runCommand(() => router.push(client.href)) + } + className="h-9" + > +
+ + + {t("commandMachineClients")} + + + + {client.name} + +
+
+ ))} + {userDevices.map((device) => ( + + runCommand(() => router.push(device.href)) + } + className="h-9" + > +
+ + + {t("commandUserDevices")} + + + + {device.name} + +
+
+ ))} +
+ )} {isActionMode && actions.length > 0 && ( <> - {actions.map((action) => ( {action.icon} {action.label} @@ -387,7 +433,7 @@ export function CommandPaletteProvider({ orgs, navItems }: CommandPaletteProviderProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(true); // FIXME: should be set to `false` by default, this is temporary const toggle = useCallback(() => { setOpen((current) => !current); diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts index 984892354..7471438d5 100644 --- a/src/components/command-palette/useCommandPaletteSearch.ts +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -47,7 +47,14 @@ export function useCommandPaletteSearch({ const shouldSearch = enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH; - const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({ + const [ + sitesQuery, + proxyResourcesQuery, + privateResourcesQuery, + usersQuery, + clientsQuery, + userDevicesQuery + ] = useQueries({ queries: [ { ...orgQueries.sites({ @@ -58,7 +65,15 @@ export function useCommandPaletteSearch({ enabled: shouldSearch }, { - ...orgQueries.resources({ + ...orgQueries.proxyResources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.privateResources({ orgId: orgId ?? "", query: trimmedQuery, perPage: SEARCH_PER_PAGE @@ -80,6 +95,14 @@ export function useCommandPaletteSearch({ perPage: SEARCH_PER_PAGE }), enabled: shouldSearch + }, + { + ...orgQueries.userDevices({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch } ] }); @@ -93,14 +116,23 @@ export function useCommandPaletteSearch({ })); }, [orgId, sitesQuery.data]); - const resources = useMemo((): ResourceSearchResult[] => { - if (!orgId || !resourcesQuery.data) return []; - return resourcesQuery.data.map((resource) => ({ + const publicResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !proxyResourcesQuery.data) return []; + return proxyResourcesQuery.data.map((resource) => ({ id: `resource-${resource.resourceId}`, name: resource.name, href: `/${orgId}/settings/resources/proxy/${resource.niceId}` })); - }, [orgId, resourcesQuery.data]); + }, [orgId, proxyResourcesQuery.data]); + + const privateResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !privateResourcesQuery.data) return []; + return privateResourcesQuery.data.map((resource) => ({ + id: `site-resource-${resource.siteResourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/private?query=${resource.name}` + })); + }, [orgId, privateResourcesQuery.data]); const users = useMemo((): UserSearchResult[] => { if (!orgId || !usersQuery.data) return []; @@ -123,20 +155,44 @@ export function useCommandPaletteSearch({ })); }, [orgId, clientsQuery.data]); + const userDevices = useMemo((): ClientSearchResult[] => { + if (!orgId || !userDevicesQuery.data) return []; + return userDevicesQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/user/${client.niceId}` + })); + }, [orgId, userDevicesQuery.data]); + const isLoading = shouldSearch && (sitesQuery.isFetching || - resourcesQuery.isFetching || + proxyResourcesQuery.isFetching || + privateResourcesQuery.isFetching || usersQuery.isFetching || + userDevicesQuery.isFetching || clientsQuery.isFetching); + const hasResults = + sites.length > 0 || + publicResources.length > 0 || + users.length > 0 || + privateResources.length > 0 || + userDevices.length > 0 || + machineClients.length > 0; + return { debouncedQuery: trimmedQuery, shouldSearch, sites, - resources, + publicResources, + privateResources, users, machineClients, - isLoading + userDevices, + isLoading, + hasResults }; } From 4e7328a1cc2c37b4fa5f17e648395323dd1659ea Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 22:29:03 +0200 Subject: [PATCH 010/112] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20private?= =?UTF-8?q?=20resource=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/private/page.tsx | 45 ++----------------- src/components/PrivateResourcesTable.tsx | 6 +-- 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 23ff86296..3cd2507fc 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -1,18 +1,15 @@ +import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; -import type { ListResourcesResponse } from "@server/routers/resource"; -import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; -import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; export const metadata: Metadata = { @@ -24,13 +21,6 @@ export interface ClientResourcesPageProps { searchParams: Promise>; } -function parsePositiveInt(s: string | undefined): number | undefined { - if (!s) return undefined; - const n = Number(s); - if (!Number.isInteger(n) || n <= 0) return undefined; - return n; -} - export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -39,7 +29,7 @@ export default async function ClientResourcesPage( const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - let pagination: ListResourcesResponse["pagination"] = { + let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { total: 0, page: 1, pageSize: 20 @@ -56,34 +46,6 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} - const siteIdParam = parsePositiveInt( - searchParams.get("siteId") ?? undefined - ); - - let initialFilterSite: { - siteId: number; - name: string; - type: string; - } | null = null; - if (siteIdParam) { - try { - const siteRes = await internal.get( - `/site/${siteIdParam}`, - await authCookieHeader() - ); - const s = (siteRes.data as ResponseT).data; - if (s && s.orgId === params.orgId) { - initialFilterSite = { - siteId: s.siteId, - name: s.name, - type: s.type - }; - } - } catch { - // leave null - } - } - let org = null; try { const res = await getCachedOrg(params.orgId); @@ -153,7 +115,6 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} - initialFilterSite={initialFilterSite} /> diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 2a4d63e85..c7b1fdf9f 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -117,15 +117,13 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; - initialFilterSite?: Selectedsite | null; }; export default function PrivateResourcesTable({ internalResources, orgId, pagination, - rowCount, - initialFilterSite = null + rowCount }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -142,7 +140,7 @@ export default function PrivateResourcesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedInternalResource, setSelectedInternalResource] = - useState(); + useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = useState(); From 2ab5540085bfb9415638455848bddcbb4ef903b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 19 Jun 2026 18:08:36 +0200 Subject: [PATCH 011/112] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20command=20pa?= =?UTF-8?q?lette=20trigger=20on=20the=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LayoutHeader.tsx | 2 ++ src/components/LayoutMobileMenu.tsx | 33 ++++++++++--------- .../command-palette/CommandPalette.tsx | 4 +-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..5e825a8e2 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -8,6 +8,7 @@ import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; interface LayoutHeaderProps { showTopBar: boolean; @@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { {showTopBar && (
+
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..8e7d0dfc0 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -1,27 +1,27 @@ "use client"; -import React, { useState } from "react"; -import { SidebarNav } from "@app/components/SidebarNav"; -import { OrgSelector } from "@app/components/OrgSelector"; -import { cn } from "@app/lib/cn"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, Menu, Server } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; -import ProfileIcon from "@app/components/ProfileIcon"; -import ThemeSwitcher from "@app/components/ThemeSwitcher"; import type { SidebarNavSection } from "@app/app/navigation"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; +import { OrgSelector } from "@app/components/OrgSelector"; +import ProfileIcon from "@app/components/ProfileIcon"; +import { SidebarNav } from "@app/components/SidebarNav"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { Button } from "@app/components/ui/button"; import { Sheet, SheetContent, - SheetTrigger, + SheetDescription, SheetTitle, - SheetDescription + SheetTrigger } from "@app/components/ui/sheet"; -import { Abel } from "next/font/google"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Menu, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; interface LayoutMobileMenuProps { orgId?: string; @@ -121,6 +121,7 @@ export function LayoutMobileMenu({ {showTopBar && (
+
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 816c83fb3..514ef6e1a 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -100,7 +100,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { return ( { setOpen((current) => !current); From 8f0e17774f07292d9187f22924e3f9a85d7d8e63 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 22 Jun 2026 21:22:55 +0200 Subject: [PATCH 012/112] =?UTF-8?q?=F0=9F=92=84=20Update=20command=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useCommandPaletteActions.tsx | 25 ++++++------------- src/components/ui/command.tsx | 3 ++- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx index 998fb97b8..4b288c3bc 100644 --- a/src/components/command-palette/useCommandPaletteActions.tsx +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -86,6 +86,12 @@ export function useCommandPaletteActions( icon: , href: `/${orgId}/settings/resources/proxy/create` }); + actions.push({ + id: "create-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/create` + }); actions.push({ id: "create-user", label: t("commandPaletteCreateUser"), @@ -98,12 +104,6 @@ export function useCommandPaletteActions( icon: , href: `/${orgId}/settings/api-keys/create` }); - actions.push({ - id: "create-machine-client", - label: t("commandPaletteCreateMachineClient"), - icon: , - href: `/${orgId}/settings/clients/machine/create` - }); if (!env?.flags.disableEnterpriseFeatures) { actions.push({ @@ -129,17 +129,6 @@ export function useCommandPaletteActions( } } - const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1; - - if (canChooseOrganization) { - actions.push({ - id: "choose-org", - label: t("commandPaletteChooseOrganization"), - icon: , - href: "/?orgs=1" - }); - } - actions.push({ id: "toggle-theme", label: t("commandPaletteToggleTheme"), @@ -158,4 +147,4 @@ export function useCommandPaletteActions( return actions; }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]); -} \ No newline at end of file +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index efd1c35f5..3d577c275 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -174,10 +174,11 @@ function CommandShortcut({ ...props }: React.ComponentProps<"span">) { return ( - Date: Thu, 25 Jun 2026 20:28:57 +0200 Subject: [PATCH 013/112] =?UTF-8?q?=F0=9F=9A=A7=20add=20country=20is=20not?= =?UTF-8?q?=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/db/pg/schema/schema.ts | 11 ++++++++++- server/db/sqlite/schema/schema.ts | 10 +++++++++- server/lib/blueprints/publicResources.ts | 2 +- server/lib/blueprints/resourcePolicies.ts | 3 ++- server/lib/validators.ts | 2 ++ .../PolicyAccessRulesTable.tsx | 19 ++++++++++++++----- .../policy-access-rule-validation.ts | 1 + 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 83e8a488d..3bb70a34f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2431,6 +2431,7 @@ "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", "exitNode": "Exit Node", "country": "Country", + "countryIsNot": "Country Is Not", "rulesMatchCountry": "Currently based on source IP", "region": "Region", "selectRegion": "Select region", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1b48aa520..934eba1f6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -981,7 +981,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { priority: integer("priority").notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), match: varchar("match") - .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() .notNull(), value: varchar("value").notNull() }); @@ -1553,3 +1561,4 @@ export type Label = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; +export type ResourcePolicyRule = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0c4a143f5..76e6d22cf 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1252,7 +1252,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { priority: integer("priority").notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), match: text("match") - .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .$type< + | "CIDR" + | "PATH" + | "IP" + | "COUNTRY" + | "COUNTRY_IS_NOT" + | "ASN" + | "REGION" + >() .notNull(), value: text("value").notNull() }); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 1bbe0a4f1..a6147ea86 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -1321,7 +1321,7 @@ function getRuleAction(input: string) { function getRuleValue(match: string, value: string) { // if the match is a country, uppercase the value - if (match == "COUNTRY") { + if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") { return value.toUpperCase(); } return value; diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index f8d8d1269..aa5f4bc9b 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" { function getRuleMatch( input: string -): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" { +): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" { return input.toUpperCase() as | "CIDR" | "IP" | "PATH" | "COUNTRY" + | "COUNTRY_IS_NOT" | "ASN" | "REGION"; } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index ec19bd852..ff0c5fb3d 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [ "IP", "PATH", "COUNTRY", + "COUNTRY_IS_NOT", "ASN", "REGION" ] as const; @@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError( case "REGION": return isValidRegionId(value) ? null : "Invalid region ID provided"; case "COUNTRY": + case "COUNTRY_IS_NOT": return COUNTRIES.some((country) => country.code === value) ? null : "Invalid country code provided"; diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index a701b92ff..2818f3652 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -229,6 +229,7 @@ export function PolicyAccessRulesTable({ IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), + COUNTRY_IS_NOT: t("countryIsNot"), ASN: "ASN", REGION: t("region") }), @@ -441,13 +442,15 @@ export function PolicyAccessRulesTable({ | "IP" | "PATH" | "COUNTRY" + | "COUNTRY_IS_NOT" | "ASN" | "REGION" ) => updateRule(row.original.ruleId, { match: value, value: - value === "COUNTRY" + value === "COUNTRY" || + value === "COUNTRY_IS_NOT" ? "US" : value === "ASN" ? "AS15169" @@ -469,9 +472,14 @@ export function PolicyAccessRulesTable({ {RuleMatch.CIDR} {isMaxmindAvailable && ( - - {RuleMatch.COUNTRY} - + <> + + {RuleMatch.COUNTRY} + + + {RuleMatch.COUNTRY_IS_NOT} + + )} {includeRegionMatch && isMaxmindAvailable && ( @@ -491,7 +499,8 @@ export function PolicyAccessRulesTable({ accessorKey: "value", header: () => {t("value")}, cell: ({ row }) => - row.original.match === "COUNTRY" ? ( + row.original.match === "COUNTRY" || + row.original.match === "COUNTRY_IS_NOT" ? ( - ) : 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 025/112] 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 1b1fba60f141cc3ef6dcf24efee59dab36291e1b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 09:02:08 -0400 Subject: [PATCH 026/112] Make sure to retry rebuilds --- server/lib/dbRetry.ts | 83 +++++++++++++++++++++++++ server/lib/rebuildClientAssociations.ts | 56 ++++++++++++++--- server/lib/rebuildQueue.ts | 35 +++++++++-- server/private/lib/rebuildQueue.ts | 40 ++++++++++-- server/routers/newt/pingAccumulator.ts | 83 +------------------------ 5 files changed, 200 insertions(+), 97 deletions(-) create mode 100644 server/lib/dbRetry.ts diff --git a/server/lib/dbRetry.ts b/server/lib/dbRetry.ts new file mode 100644 index 000000000..7f8d1eea8 --- /dev/null +++ b/server/lib/dbRetry.ts @@ -0,0 +1,83 @@ +import logger from "@server/logger"; + +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 50; + +/** + * Detect transient errors that are safe to retry (connection drops, deadlocks, + * serialization failures). PostgreSQL deadlocks (40P01) are always safe to + * retry: the database guarantees exactly one winner per deadlock pair, so the + * loser just needs to try again. + */ +export function isTransientError(error: any): boolean { + if (!error) return false; + + const message = (error.message || "").toLowerCase(); + const causeMessage = (error.cause?.message || "").toLowerCase(); + const code = error.code || error.cause?.code || ""; + + // Connection timeout / terminated + if ( + message.includes("connection timeout") || + message.includes("connection terminated") || + message.includes("timeout exceeded when trying to connect") || + causeMessage.includes("connection terminated unexpectedly") || + causeMessage.includes("connection timeout") + ) { + return true; + } + + // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) + if (code === "40P01" || message.includes("deadlock")) { + return true; + } + + // PostgreSQL serialization failure + if (code === "40001") { + return true; + } + + // ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT + if ( + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT" + ) { + return true; + } + + return false; +} + +/** + * Simple retry wrapper with exponential backoff for transient errors + * (deadlocks, connection timeouts, unexpected disconnects). + */ +export async function withRetry( + operation: () => Promise, + context: string, + maxRetries: number = MAX_RETRIES, + baseDelayMs: number = BASE_DELAY_MS +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isTransientError(error) && attempt < maxRetries) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, + { code: error?.code ?? error?.cause?.code } + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 150001662..b7647a182 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -45,6 +45,7 @@ import { } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; +import { withRetry, isTransientError } from "@server/lib/dbRetry"; import { checkOrgRebuildRateLimit, decrementOrgRebuildCount, @@ -285,10 +286,20 @@ export async function rebuildClientAssociationsFromSiteResource( ) { await incrementOrgRebuildCount(siteResource.orgId); try { - return await lockManager.withLock( - `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => rebuildClientAssociationsFromSiteResourceImpl(siteResource), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS + // The whole locked rebuild is idempotent (it diffs full expected vs. + // actual state each time), so on a transient DB error it's safe to + // retry the entire thing rather than just the failed query. + return await withRetry( + () => + lockManager.withLock( + `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, + () => + rebuildClientAssociationsFromSiteResourceImpl( + siteResource + ), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ), + `rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}` ); } catch (err: any) { if ( @@ -304,6 +315,17 @@ export async function rebuildClientAssociationsFromSiteResource( }); return { mergedAllClients: [] }; } + if (isTransientError(err)) { + logger.warn( + `rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`, + err + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + return { mergedAllClients: [] }; + } throw err; } finally { await decrementOrgRebuildCount(siteResource.orgId); @@ -1656,10 +1678,17 @@ export async function rebuildClientAssociationsFromClient( await incrementOrgRebuildCount(client.orgId); try { const trx = primaryDb; - return await lockManager.withLock( - `rebuild-client-associations:client:${client.clientId}`, - () => rebuildClientAssociationsFromClientImpl(client, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS + // The whole locked rebuild is idempotent (it diffs full expected vs. + // actual state each time), so on a transient DB error it's safe to + // retry the entire thing rather than just the failed query. + return await withRetry( + () => + lockManager.withLock( + `rebuild-client-associations:client:${client.clientId}`, + () => rebuildClientAssociationsFromClientImpl(client, trx), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ), + `rebuildClientAssociationsFromClient:${client.clientId}` ); } catch (err: any) { if ( @@ -1675,6 +1704,17 @@ export async function rebuildClientAssociationsFromClient( }); return; } + if (isTransientError(err)) { + logger.warn( + `rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`, + err + ); + await rebuildQueue.enqueue({ + type: "client", + id: client.clientId + }); + return; + } throw err; } finally { await decrementOrgRebuildCount(client.orgId); diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 07f2cb003..7491f5f52 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -1,10 +1,14 @@ import logger from "@server/logger"; +import { isTransientError } from "@server/lib/dbRetry"; export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { type: RebuildJobType; id: number; + // Number of times this job has already been re-queued after a transient + // failure. Absent/0 means it has not failed yet. + attempt?: number; } export interface RebuildJobHandlers { @@ -24,6 +28,10 @@ export interface RebuildQueueManager { // retried shortly after against fresh DB state. const POLL_INTERVAL_MS = 500; const BATCH_SIZE = 5; +// A job that fails with a transient DB error gets re-queued with backoff +// instead of being dropped, up to this many times. +const MAX_JOB_ATTEMPTS = 5; +const JOB_RETRY_BASE_DELAY_MS = 1000; function dedupeKey(job: RebuildJob): string { return `${job.type}:${job.id}`; @@ -106,10 +114,29 @@ class InMemoryRebuildQueue implements RebuildQueueManager { `Rebuild queue: completed ${job.type}:${job.id}` ); } catch (err) { - logger.error( - `Rebuild queue: job ${job.type}:${job.id} threw an error:`, - err - ); + const attempt = (job.attempt ?? 0) + 1; + if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) { + const delay = + JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + logger.warn( + `Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`, + err + ); + setTimeout(() => { + this.enqueue({ ...job, attempt }).catch( + (enqueueErr) => + logger.error( + `Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`, + enqueueErr + ) + ); + }, delay); + } else { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } } } } finally { diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index b5e112545..87da95f60 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -14,12 +14,16 @@ import { redis } from "#private/lib/redis"; import { lockManager } from "#private/lib/lock"; import logger from "@server/logger"; +import { isTransientError } from "@server/lib/dbRetry"; export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { type: RebuildJobType; id: number; + // Number of times this job has already been re-queued after a transient + // failure. Absent/0 means it has not failed yet. + attempt?: number; } export interface RebuildJobHandlers { @@ -43,6 +47,11 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s const POLL_INTERVAL_MS = 500; +// A job that fails with a transient DB error gets re-queued with backoff +// instead of being dropped, up to this many times. +const MAX_JOB_ATTEMPTS = 5; +const JOB_RETRY_BASE_DELAY_MS = 1000; + class RedisRebuildQueue { private processingStarted = false; @@ -180,10 +189,33 @@ class RedisRebuildQueue { `Rebuild queue: completed ${job.type}:${job.id}` ); } catch (err) { - logger.error( - `Rebuild queue: job ${job.type}:${job.id} threw an error:`, - err - ); + const attempt = (job.attempt ?? 0) + 1; + if ( + isTransientError(err) && + attempt <= MAX_JOB_ATTEMPTS + ) { + const delay = + JOB_RETRY_BASE_DELAY_MS * + Math.pow(2, attempt - 1); + logger.warn( + `Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`, + err + ); + setTimeout(() => { + this.enqueue({ ...job, attempt }).catch( + (enqueueErr) => + logger.error( + `Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`, + enqueueErr + ) + ); + }, delay); + } else { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } } } }, diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index be9cd4972..f82190790 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOnlineAlert } from "@server/lib/alerts"; +import { withRetry } from "@server/lib/dbRetry"; /** * Ping Accumulator @@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts"; */ const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds -const MAX_RETRIES = 5; -const BASE_DELAY_MS = 50; // ── Site (newt) pings ────────────────────────────────────────────────── // Map of siteId -> latest ping timestamp (unix seconds) @@ -266,85 +265,7 @@ export async function flushPingsToDb(): Promise { } // ── Retry / Error Helpers ────────────────────────────────────────────── - -/** - * Simple retry wrapper with exponential backoff for transient errors - * (deadlocks, connection timeouts, unexpected disconnects). - * - * PostgreSQL deadlocks (40P01) are always safe to retry: the database - * guarantees exactly one winner per deadlock pair, so the loser just needs - * to try again. MAX_RETRIES is intentionally higher than typical connection - * retry budgets to give deadlock victims enough chances to succeed. - */ -async function withRetry( - operation: () => Promise, - context: string -): Promise { - let attempt = 0; - while (true) { - try { - return await operation(); - } catch (error: any) { - if (isTransientError(error) && attempt < MAX_RETRIES) { - attempt++; - const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; - const jitter = Math.random() * baseDelay; - const delay = baseDelay + jitter; - logger.warn( - `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`, - { code: error?.code ?? error?.cause?.code } - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - throw error; - } - } -} - -/** - * Detect transient errors that are safe to retry. - */ -function isTransientError(error: any): boolean { - if (!error) return false; - - const message = (error.message || "").toLowerCase(); - const causeMessage = (error.cause?.message || "").toLowerCase(); - const code = error.code || error.cause?.code || ""; - - // Connection timeout / terminated - if ( - message.includes("connection timeout") || - message.includes("connection terminated") || - message.includes("timeout exceeded when trying to connect") || - causeMessage.includes("connection terminated unexpectedly") || - causeMessage.includes("connection timeout") - ) { - return true; - } - - // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) - if (code === "40P01" || message.includes("deadlock")) { - return true; - } - - // PostgreSQL serialization failure - if (code === "40001") { - return true; - } - - // ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT - if ( - code === "ECONNRESET" || - code === "ECONNREFUSED" || - code === "EPIPE" || - code === "ETIMEDOUT" - ) { - return true; - } - - return false; -} +// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers. // ── Lifecycle ────────────────────────────────────────────────────────── From b53f80d31711f4f35d9519e908675e095df3c20f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 09:59:49 -0400 Subject: [PATCH 027/112] 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 028/112] 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 b399d2a291423f535a3138124575e73eeda535a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 10:23:32 -0400 Subject: [PATCH 029/112] Add some retry and database confict mitigation --- server/lib/rebuildClientAssociations.ts | 257 ++++++++++++---------- server/routers/newt/buildConfiguration.ts | 4 +- server/routers/olm/buildConfiguration.ts | 2 +- 3 files changed, 145 insertions(+), 118 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index b7647a182..abad6b7aa 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -485,6 +485,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) + .onConflictDoNothing() .returning(); logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` @@ -532,121 +533,141 @@ async function rebuildClientAssociationsFromSiteResourceImpl( for (const site of sitesToProcess) { const siteId = site.siteId; - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` - ); + try { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` + ); - const existingClientSites = await trx - .select({ - clientId: clientSitesAssociationsCache.clientId - }) - .from(clientSitesAssociationsCache) - .where(eq(clientSitesAssociationsCache.siteId, siteId)); + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); - const existingClientSiteIds = existingClientSites.map( - (row) => row.clientId - ); + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` - ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` + ); - // Get full client details for existing clients (needed for sending delete messages) - const existingClients = - existingClientSiteIds.length > 0 - ? await trx - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .where(inArray(clients.clientId, existingClientSiteIds)) + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = + existingClientSiteIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where( + inArray(clients.clientId, existingClientSiteIds) + ) + : []; + + const otherResourceClientIds = + clientsFromOtherResourcesBySite.get(siteId) ?? + new Set(); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` + ); + + // Expected clients from this resource are site-scoped: if this site is + // no longer attached to the resource, the expected set is empty. + const expectedClientIdsForSite = currentSiteIdSet.has(siteId) + ? mergedAllClientIds : []; - const otherResourceClientIds = - clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); - - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` - ); - - // Expected clients from this resource are site-scoped: if this site is - // no longer attached to the resource, the expected set is empty. - const expectedClientIdsForSite = currentSiteIdSet.has(siteId) - ? mergedAllClientIds - : []; - - const clientSitesToAdd = expectedClientIdsForSite.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource - ); - - const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ - clientId, - siteId - })); - - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` - ); - - if (clientSitesToInsert.length > 0) { - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` + const clientSitesToAdd = expectedClientIdsForSite.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource ); - await trx - .insert(clientSitesAssociationsCache) - .values(clientSitesToInsert) - .returning(); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` - ); - } else { - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` - ); - } - // Now remove any client-site associations that should no longer exist - const clientSitesToRemove = existingClientSiteIds.filter( - (clientId) => - !expectedClientIdsForSite.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource - ); + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` - ); - - if (clientSitesToRemove.length > 0) { logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` ); - await trx - .delete(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.siteId, siteId), - inArray( - clientSitesAssociationsCache.clientId, - clientSitesToRemove - ) - ) + + if (clientSitesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` ); - } + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .onConflictDoNothing() + .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` + ); + } - // Now handle the messages to add/remove peers on both the newt and olm sides - await handleMessagesForSiteClients( - site, - siteId, - mergedAllClients, - existingClients, - clientSitesToAdd, - clientSitesToRemove, - trx - ); + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !expectedClientIdsForSite.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource + ); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` + ); + + if (clientSitesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) + ) + ); + } + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); + } catch (err) { + // Don't let a failure on one site abort processing of every + // other site queued after it in this run. Since we're not + // re-throwing, the outer wrapper's retry/requeue logic never + // sees this failure, so explicitly queue this resource for a + // follow-up pass to reconcile whatever this site didn't get to. + logger.error( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`, + err + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + } } // Handle subnet proxy target updates for the resource associations @@ -939,7 +960,7 @@ export async function updateClientSiteDestinations( for (const site of sitesData) { if (!site.sites.subnet) { - logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); + logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); continue; } @@ -1866,12 +1887,15 @@ async function rebuildClientAssociationsFromClientImpl( // Insert new associations if (resourcesToAdd.length > 0) { - await trx.insert(clientSiteResourcesAssociationsCache).values( - resourcesToAdd.map((siteResourceId) => ({ - clientId: client.clientId, - siteResourceId - })) - ); + await trx + .insert(clientSiteResourcesAssociationsCache) + .values( + resourcesToAdd.map((siteResourceId) => ({ + clientId: client.clientId, + siteResourceId + })) + ) + .onConflictDoNothing(); } // Remove old associations @@ -1909,12 +1933,15 @@ async function rebuildClientAssociationsFromClientImpl( // Insert new site associations if (sitesToAdd.length > 0) { - await trx.insert(clientSitesAssociationsCache).values( - sitesToAdd.map((siteId) => ({ - clientId: client.clientId, - siteId - })) - ); + await trx + .insert(clientSitesAssociationsCache) + .values( + sitesToAdd.map((siteId) => ({ + clientId: client.clientId, + siteId + })) + ) + .onConflictDoNothing(); } // Remove old site associations diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 5083a6c56..5b7f211ae 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient( clientsRes .filter((client) => { if (!client.clients.pubKey) { - logger.warn( + logger.debug( `Client ${client.clients.clientId} has no public key, skipping` ); return false; } if (!client.clients.subnet) { - logger.warn( + logger.debug( `Client ${client.clients.clientId} has no subnet, skipping` ); return false; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 41bb6d60d..f72731c92 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient( } if (!site.subnet) { - logger.warn(`Site ${site.siteId} has no subnet, skipping`); + logger.debug(`Site ${site.siteId} has no subnet, skipping`); continue; } From 440ebfe08e1ecf2bc0f31f2e48a9bb12ade38e08 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 10:26:13 -0400 Subject: [PATCH 030/112] Clarify error messages --- server/routers/newt/handleNewtDisconnectingMessage.ts | 2 +- server/routers/olm/handleOlmDisconnectingMessage.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index a2b963fc9..afc208ac8 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -43,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( ); }); } catch (error) { - logger.error("Error handling disconnecting message", { error }); + logger.error("Error handling site disconnecting message", error); } }; diff --git a/server/routers/olm/handleOlmDisconnectingMessage.ts b/server/routers/olm/handleOlmDisconnectingMessage.ts index ecd101724..241183497 100644 --- a/server/routers/olm/handleOlmDisconnectingMessage.ts +++ b/server/routers/olm/handleOlmDisconnectingMessage.ts @@ -6,7 +6,9 @@ import logger from "@server/logger"; /** * Handles disconnecting messages from clients to show disconnected in the ui */ -export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { +export const handleOlmDisconnectingMessage: MessageHandler = async ( + context +) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; @@ -29,6 +31,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (context) => }) .where(eq(clients.clientId, olm.clientId)); } catch (error) { - logger.error("Error handling disconnecting message", { error }); + logger.error("Error handling client disconnecting message", error); } }; From 9edb86fd73d95beb869009779f89953567ed987b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 10:30:41 -0400 Subject: [PATCH 031/112] 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 032/112] 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 e4e0da3723542e9ecd3eb32ec460b87b4ca2c57b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:24:53 -0400 Subject: [PATCH 033/112] Add not exists check --- server/routers/newt/handleNewtDisconnectingMessage.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index afc208ac8..811dc68a1 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( } if (!newt.siteId) { - logger.warn("Newt has no client ID!"); + logger.warn("Newt has no site ID!"); return; } @@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( .where(eq(sites.siteId, newt.siteId!)) .returning(); + if (!site) { + throw new Error( + `Could not find site ${newt.siteId} to update disconnection from disconnect message` + ); + } + await fireSiteOfflineAlert( site.orgId, site.siteId, From 600a96c13b0f6cf333cfd7a252a9e71c5f1317eb Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:52:59 -0400 Subject: [PATCH 034/112] Update rate limit to 10 --- server/private/lib/orgRebuildCounter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/orgRebuildCounter.ts b/server/private/lib/orgRebuildCounter.ts index 58d954f66..7107b9b8d 100644 --- a/server/private/lib/orgRebuildCounter.ts +++ b/server/private/lib/orgRebuildCounter.ts @@ -14,7 +14,7 @@ import { redis } from "#private/lib/redis"; import logger from "@server/logger"; -export const ORG_REBUILD_CONCURRENCY_LIMIT = 5; +export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; // Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a // server process dies while holding a rebuild, this ensures the counter key From 5da186b528a4dc8bb0adb12d8b130ec59b9eead3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:58:00 -0400 Subject: [PATCH 035/112] Quiet warning messages --- server/lib/rebuildClientAssociations.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index abad6b7aa..d3fcaea3c 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -700,7 +700,7 @@ async function handleMessagesForSiteClients( trx: Transaction | typeof db = db ): Promise { if (!site.exitNodeId) { - logger.warn( + logger.debug( `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; @@ -714,14 +714,14 @@ async function handleMessagesForSiteClients( .limit(1); if (!exitNode) { - logger.warn( + logger.debug( `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; } if (!site.publicKey) { - logger.warn( + logger.debug( `Site publicKey not set for site ${site.siteId} so cannot add peers to clients` ); return; @@ -735,7 +735,7 @@ async function handleMessagesForSiteClients( .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { - logger.warn( + logger.debug( `Newt not found for site ${siteId} so cannot add peers to clients` ); return; From 2bfc1901a65675c625edc1c6246a01a9925227c7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 14:38:01 -0400 Subject: [PATCH 036/112] Dont check the subnet because we dont use it --- server/routers/site/createSite.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index bddf5b251..b24608609 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -268,7 +268,11 @@ export async function createSite( let newSite: Site | undefined; try { - if (subnet && exitNodeId) { + if (type === "wireguard" && subnet && exitNodeId) { + // Only wireguard sites actually persist the provided subnet/exitNodeId. + // Newt sites have their subnet/exit node chosen (under a lock) when the + // newt connects, so validating them here is both unnecessary and racy, + // since pickSiteDefaults does not lock the subnet it suggests. //make sure the subnet is in the range of the exit node if provided const [exitNode] = await db .select() From 390c822bb4a372eb7dce9b502301940da8d899b7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 16:13:50 -0400 Subject: [PATCH 037/112] Fix issue with overlapping site resources not sending --- server/lib/rebuildClientAssociations.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index d3fcaea3c..efb856825 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -582,10 +582,17 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ? mergedAllClientIds : []; + // Note: we deliberately do NOT exclude clients covered by another + // site resource here (unlike clientSitesToRemove below). Doing so + // previously caused a permanent gap: if resource A saw resource B's + // cache row and skipped adding (assuming B would maintain it), and + // B's own rebuild made the same assumption about A, the site-level + // row could end up never inserted by anyone even though both + // resources' client associations were otherwise correct. + // onConflictDoNothing makes a redundant insert harmless, so there's + // no correctness reason to skip here. const clientSitesToAdd = expectedClientIdsForSite.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource + (clientId) => !existingClientSiteIds.includes(clientId) ); const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ From 40549763884e0998a0e0c4431cc05d9ee865ffe9 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:15:48 -0400 Subject: [PATCH 038/112] Fix #3395 --- messages/en-US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4701d4da1..5d49b0ca9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -864,8 +864,8 @@ "policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthSummary": "Header configured", - "policyAuthHeaderName": "Header name", - "policyAuthHeaderValue": "Expected value", + "policyAuthHeaderName": "Username", + "policyAuthHeaderValue": "Password", "policyAuthSetPasscode": "Set Passcode", "policyAuthSetPincode": "Set PIN Code", "policyAuthSetEmailWhitelist": "Set Email Whitelist", From f60b8795ad034c7c1abd1efaebe0edeca3979849 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:26:49 -0400 Subject: [PATCH 039/112] Fix #3383 --- server/routers/resource/listResources.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f15a3beda..d1deb9d39 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -616,8 +616,8 @@ export async function listResources( and( inArray(resources.mode, browserGatewayModes), or( - eq(effectiveSso, true), - eq(effectiveWhitelist, true), + effectiveSso, + effectiveWhitelist, not(isNull(effectiveHeaderAuthId)), not(isNull(effectivePincodeId)), not(isNull(effectivePasswordId)) @@ -629,8 +629,8 @@ export async function listResources( conditions.push( and( inArray(resources.mode, browserGatewayModes), - not(eq(effectiveSso, true)), - not(eq(effectiveWhitelist, true)), + not(effectiveSso), + not(effectiveWhitelist), isNull(effectiveHeaderAuthId), isNull(effectivePincodeId), isNull(effectivePasswordId) From 2c3151da9bb8d731da8e18ff22790a811f19a725 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:36:06 -0400 Subject: [PATCH 040/112] Fix #3374 --- .../resources/public/[niceId]/http/page.tsx | 17 ++- src/components/HeadersInput.tsx | 129 +++++++++++------- 2 files changed, 92 insertions(+), 54 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index d9e2fefe0..e991d7b97 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -41,7 +41,7 @@ import { import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -137,11 +137,21 @@ function ProxyResourceHttpForm({ }); const [, formAction, saveLoading] = useActionState(onSubmit, null); + const [headersValid, setHeadersValid] = useState(true); async function onSubmit() { const isValid = await form.trigger(); if (!isValid) return; + if (!headersValid) { + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: t("headersValidationError") + }); + return; + } + const data = form.getValues(); const res = await api @@ -318,6 +328,9 @@ function ProxyResourceHttpForm({ onChange={ field.onChange } + onValidityChange={ + setHeadersValid + } rows={4} /> @@ -341,7 +354,7 @@ function ProxyResourceHttpForm({