diff --git a/messages/en-US.json b/messages/en-US.json index af2287db7..25564a98b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4276,6 +4276,9 @@ "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.", "resourceLauncherResourceDetails": "Resource Details", + "resourceLauncherSitesDescription": "The resource is accessible via the following sites.", + "resourceLauncherViewSiteAsAdmin": "View Site as Admin", + "resourceLauncherFilterBySite": "Filter by Site", "resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.", "resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.", "resourceLauncherPrivateClientRequiredTitle": "Client Connection Required", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 813b83cc9..ea1dc98da 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -522,6 +522,7 @@ async function fetchLabelsForResources( type SiteGroupRow = { siteId: number; name: string; + niceId: string; type: string; online: boolean; itemCount: number; @@ -556,6 +557,7 @@ async function listSiteGroups( .select({ siteId: sites.siteId, name: sites.name, + niceId: sites.niceId, type: sites.type, online: sites.online, itemCount: countDistinct(resources.resourceId) @@ -576,7 +578,13 @@ async function listSiteGroups( const publicRows = await publicQuery .where(and(...publicConditions)) - .groupBy(sites.siteId, sites.name, sites.type, sites.online); + .groupBy( + sites.siteId, + sites.name, + sites.niceId, + sites.type, + sites.online + ); for (const row of publicRows) { const existing = siteCountMap.get(row.siteId); @@ -586,6 +594,7 @@ async function listSiteGroups( siteCountMap.set(row.siteId, { siteId: row.siteId, name: row.name, + niceId: row.niceId, type: row.type, online: row.online, itemCount: Number(row.itemCount) @@ -612,6 +621,7 @@ async function listSiteGroups( .select({ siteId: sites.siteId, name: sites.name, + niceId: sites.niceId, type: sites.type, online: sites.online, itemCount: countDistinct(siteResources.siteResourceId) @@ -638,7 +648,13 @@ async function listSiteGroups( const siteRows = await siteResourceQuery .where(and(...siteConditions)) - .groupBy(sites.siteId, sites.name, sites.type, sites.online); + .groupBy( + sites.siteId, + sites.name, + sites.niceId, + sites.type, + sites.online + ); for (const row of siteRows) { const existing = siteCountMap.get(row.siteId); @@ -648,6 +664,7 @@ async function listSiteGroups( siteCountMap.set(row.siteId, { siteId: row.siteId, name: row.name, + niceId: row.niceId, type: row.type, online: row.online, itemCount: Number(row.itemCount) @@ -1061,6 +1078,43 @@ export async function listLauncherGroupsForUser( }; } +function toLauncherSiteInfo(row: { + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; + siteType: string | null; + siteOnline: boolean | null; +}): LauncherSiteInfo | null { + if ( + row.siteId == null || + row.siteName == null || + row.siteNiceId == null || + row.siteType == null + ) { + return null; + } + return { + siteId: row.siteId, + name: row.siteName, + niceId: row.siteNiceId, + type: row.siteType, + online: row.siteOnline ?? undefined + }; +} + +function pickPrimarySite( + sites: LauncherSiteInfo[], + siteIdFilter?: number +): LauncherSiteInfo | undefined { + if (sites.length === 0) { + return undefined; + } + if (siteIdFilter != null) { + return sites.find((site) => site.siteId === siteIdFilter) ?? sites[0]; + } + return sites[0]; +} + async function mapPublicResources( orgId: string, resourceIds: number[], @@ -1084,6 +1138,7 @@ async function mapPublicResources( enabled: resources.enabled, siteId: sites.siteId, siteName: sites.name, + siteNiceId: sites.niceId, siteType: sites.type, siteOnline: sites.online, exitNodeEndpoint: exitNodes.endpoint @@ -1097,56 +1152,65 @@ async function mapPublicResources( inArray(resources.resourceId, resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), - eq(resources.status, "approved"), - siteIdFilter != null - ? eq(sites.siteId, siteIdFilter) - : undefined + eq(resources.status, "approved") ) ); - const seen = new Set(); - const result: LauncherResource[] = []; + const byKey = new Map(); + const siteIdsByKey = new Map>(); for (const row of rows) { const key = `public:${row.resourceId}`; - if (seen.has(key)) { + let item = byKey.get(key); + + if (!item) { + const access = formatPublicResourceAccess({ + mode: row.mode, + fullDomain: row.fullDomain, + ssl: row.ssl, + proxyPort: row.proxyPort, + wildcard: row.wildcard, + exitNodeEndpoint: row.exitNodeEndpoint + }); + + item = { + launcherResourceKey: key, + resourceType: "public", + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.byResourceId.get(row.resourceId) ?? [], + sites: [] + }; + byKey.set(key, item); + siteIdsByKey.set(key, new Set()); + } + + const site = toLauncherSiteInfo(row); + if (!site) { continue; } - seen.add(key); - const access = formatPublicResourceAccess({ - mode: row.mode, - fullDomain: row.fullDomain, - ssl: row.ssl, - proxyPort: row.proxyPort, - wildcard: row.wildcard, - exitNodeEndpoint: row.exitNodeEndpoint - }); - - result.push({ - launcherResourceKey: key, - resourceType: "public", - resourceId: row.resourceId, - niceId: row.niceId, - name: row.name, - ...access, - iconUrl: null, - enabled: row.enabled, - mode: row.mode, - labels: labelMaps.byResourceId.get(row.resourceId) ?? [], - site: - row.siteId != null - ? { - siteId: row.siteId, - name: row.siteName!, - type: row.siteType!, - online: row.siteOnline ?? undefined - } - : undefined - }); + const seenSiteIds = siteIdsByKey.get(key)!; + if (seenSiteIds.has(site.siteId)) { + continue; + } + seenSiteIds.add(site.siteId); + item.sites.push(site); } - return result; + for (const item of byKey.values()) { + item.sites.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + item.site = pickPrimarySite(item.sites, siteIdFilter); + } + + return Array.from(byKey.values()); } async function mapSiteResources( @@ -1175,6 +1239,7 @@ async function mapSiteResources( enabled: siteResources.enabled, siteId: sites.siteId, siteName: sites.name, + siteNiceId: sites.niceId, siteType: sites.type, siteOnline: sites.online }) @@ -1189,59 +1254,69 @@ async function mapSiteResources( inArray(siteResources.siteResourceId, siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), - eq(siteResources.status, "approved"), - siteIdFilter != null - ? eq(sites.siteId, siteIdFilter) - : undefined + eq(siteResources.status, "approved") ) ); - const seen = new Set(); - const result: LauncherResource[] = []; + const byKey = new Map(); + const siteIdsByKey = new Map>(); for (const row of rows) { const key = `site:${row.siteResourceId}`; - if (seen.has(key)) { + let item = byKey.get(key); + + if (!item) { + const access = formatSiteResourceAccess({ + mode: row.mode, + destination: row.destination, + destinationPort: row.destinationPort, + scheme: row.scheme, + ssl: row.ssl, + fullDomain: row.fullDomain, + alias: row.alias, + aliasAddress: row.aliasAddress + }); + + item = { + launcherResourceKey: key, + resourceType: "site", + resourceId: row.siteResourceId, + siteResourceId: row.siteResourceId, + niceId: row.niceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: + labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], + sites: [] + }; + byKey.set(key, item); + siteIdsByKey.set(key, new Set()); + } + + const site = toLauncherSiteInfo(row); + if (!site) { continue; } - seen.add(key); - const access = formatSiteResourceAccess({ - mode: row.mode, - destination: row.destination, - destinationPort: row.destinationPort, - scheme: row.scheme, - ssl: row.ssl, - fullDomain: row.fullDomain, - alias: row.alias, - aliasAddress: row.aliasAddress - }); - - result.push({ - launcherResourceKey: key, - resourceType: "site", - resourceId: row.siteResourceId, - siteResourceId: row.siteResourceId, - niceId: row.niceId, - name: row.name, - ...access, - iconUrl: null, - enabled: row.enabled, - mode: row.mode, - labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], - site: - row.siteId != null - ? { - siteId: row.siteId, - name: row.siteName!, - type: row.siteType!, - online: row.siteOnline ?? undefined - } - : undefined - }); + const seenSiteIds = siteIdsByKey.get(key)!; + if (seenSiteIds.has(site.siteId)) { + continue; + } + seenSiteIds.add(site.siteId); + item.sites.push(site); } - return result; + for (const item of byKey.values()) { + item.sites.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + item.site = pickPrimarySite(item.sites, siteIdFilter); + } + + return Array.from(byKey.values()); } function filterResourcesBySite( @@ -1252,13 +1327,17 @@ function filterResourcesBySite( return items.filter((item) => item.mode === "inference"); } if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) { - return items.filter((item) => !item.site && item.mode !== "inference"); + return items.filter( + (item) => item.sites.length === 0 && item.mode !== "inference" + ); } const siteId = Number.parseInt(groupKey, 10); if (!Number.isFinite(siteId)) { return items; } - return items.filter((item) => item.site?.siteId === siteId); + return items.filter((item) => + item.sites.some((site) => site.siteId === siteId) + ); } function filterResourcesByLabel( @@ -1499,6 +1578,7 @@ async function collectAccessibleSites( .select({ siteId: sites.siteId, name: sites.name, + niceId: sites.niceId, type: sites.type, online: sites.online, itemCount: countDistinct(resources.resourceId) @@ -1507,7 +1587,13 @@ async function collectAccessibleSites( .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .innerJoin(sites, eq(targets.siteId, sites.siteId)) .where(and(...publicConditions)) - .groupBy(sites.siteId, sites.name, sites.type, sites.online); + .groupBy( + sites.siteId, + sites.name, + sites.niceId, + sites.type, + sites.online + ); for (const row of publicRows) { const existing = siteCountMap.get(row.siteId); @@ -1517,6 +1603,7 @@ async function collectAccessibleSites( siteCountMap.set(row.siteId, { siteId: row.siteId, name: row.name, + niceId: row.niceId, type: row.type, online: row.online, itemCount: Number(row.itemCount) @@ -1540,6 +1627,7 @@ async function collectAccessibleSites( .select({ siteId: sites.siteId, name: sites.name, + niceId: sites.niceId, type: sites.type, online: sites.online, itemCount: countDistinct(siteResources.siteResourceId) @@ -1551,7 +1639,13 @@ async function collectAccessibleSites( ) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .where(and(...siteConditions)) - .groupBy(sites.siteId, sites.name, sites.type, sites.online); + .groupBy( + sites.siteId, + sites.name, + sites.niceId, + sites.type, + sites.online + ); for (const row of siteRows) { const existing = siteCountMap.get(row.siteId); @@ -1561,6 +1655,7 @@ async function collectAccessibleSites( siteCountMap.set(row.siteId, { siteId: row.siteId, name: row.name, + niceId: row.niceId, type: row.type, online: row.online, itemCount: Number(row.itemCount) @@ -1675,6 +1770,7 @@ export async function listAccessibleLauncherSitesForUser( .map((row) => ({ siteId: row.siteId, name: row.name, + niceId: row.niceId, type: row.type, online: row.online })) diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 7084781e9..ffe9432bc 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -32,6 +32,7 @@ export type LauncherLabel = { export type LauncherSiteInfo = { siteId: number; name: string; + niceId: string; type: string; online?: boolean; }; @@ -51,6 +52,7 @@ export type LauncherResource = { mode: string; labels: LauncherLabel[]; site?: LauncherSiteInfo; + sites: LauncherSiteInfo[]; }; export type LauncherGroup = { @@ -184,8 +186,7 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; export type LauncherViewSelection = - | { type: "default" } - | { type: "saved"; viewId: number }; + { type: "default" } | { type: "saved"; viewId: number }; export type LauncherScaleCapabilities = { allowSiteGrouping: boolean; diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx index e35d9dce6..e2ff2af9f 100644 --- a/src/components/resource-launcher/LauncherResourcePanel.tsx +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -28,7 +28,22 @@ import { } from "@app/components/SidePanel"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret"; +import { cn } from "@app/lib/cn"; import { derivePublicAuthState, formatPublicResourceType @@ -36,7 +51,10 @@ import { import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; import { launcherQueries } from "@app/lib/queries"; -import type { LauncherResource } from "@server/routers/launcher/types"; +import type { + LauncherResource, + LauncherSiteInfo +} from "@server/routers/launcher/types"; import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; import type { GetResourceResponse } from "@server/routers/resource/getResource"; import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; @@ -44,15 +62,18 @@ import { useQuery } from "@tanstack/react-query"; import { AlertCircle, CheckCircle2, + ChevronsUpDown, Clock, ExternalLink, Loader2, + MoreHorizontal, ShieldCheck, ShieldOff, XCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { useState } from "react"; type LauncherResourcePanelProps = { open: boolean; @@ -60,6 +81,7 @@ type LauncherResourcePanelProps = { resource: LauncherResource | null; orgId: string; isAdmin: boolean; + onFilterBySite: (site: LauncherSiteInfo) => void; }; type LauncherResourceDetailResult = @@ -153,6 +175,171 @@ function HealthStatusDisplay({ const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"]; const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"]; +type SitesSortKey = "name" | "status"; +type SitesSortOrder = "asc" | "desc"; + +function siteStatusSortValue(site: LauncherSiteInfo): number { + if (site.type !== "newt" && site.type !== "wireguard") { + return -1; + } + if (typeof site.online !== "boolean") { + return -1; + } + return site.online ? 1 : 0; +} + +function SiteStatusCell({ site }: { site: LauncherSiteInfo }) { + const t = useTranslations(); + + if (site.type !== "newt" && site.type !== "wireguard") { + return -; + } + + if (typeof site.online !== "boolean") { + return -; + } + + return ( + + + {site.online ? t("online") : t("offline")} + + ); +} + +function LauncherResourceSitesSection({ + orgId, + sites, + isAdmin, + onFilterBySite +}: { + orgId: string; + sites: LauncherSiteInfo[]; + isAdmin: boolean; + onFilterBySite: (site: LauncherSiteInfo) => void; +}) { + const t = useTranslations(); + const [sortKey, setSortKey] = useState("name"); + const [sortOrder, setSortOrder] = useState("asc"); + + if (sites.length === 0) { + return null; + } + + function toggleSort(key: SitesSortKey) { + if (sortKey === key) { + setSortOrder((order) => (order === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortOrder("asc"); + } + + const sortedSites = [...sites].sort((a, b) => { + const cmp = + sortKey === "name" + ? a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }) + : siteStatusSortValue(a) - siteStatusSortValue(b); + return sortOrder === "desc" ? -cmp : cmp; + }); + + return ( + + + {t("sites")} + + {t("resourceLauncherSitesDescription")} + + + + + + + + + + + + + + {t("actions")} + + + + + {sortedSites.map((site) => ( + + {site.name} + + + + + + + + + + {isAdmin ? ( + + + {t( + "resourceLauncherViewSiteAsAdmin" + )} + + + ) : null} + + onFilterBySite(site) + } + > + {t( + "resourceLauncherFilterBySite" + )} + + + + + + ))} + +
+
+
+ ); +} + function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) { const t = useTranslations(); @@ -235,12 +422,16 @@ function PublicResourceDetails({ orgId, launcherResource, resource, - authInfo + authInfo, + isAdmin, + onFilterBySite }: { orgId: string; launcherResource: LauncherResource; resource: GetResourceResponse; authInfo: GetResourceAuthInfoResponse; + isAdmin: boolean; + onFilterBySite: (site: LauncherSiteInfo) => void; }) { const t = useTranslations(); const mode = resource.mode || ""; @@ -328,6 +519,12 @@ function PublicResourceDetails({ + {showAuthMethods ? ( ) : null} @@ -363,11 +560,15 @@ function PublicResourceDetails({ function PrivateResourceDetails({ orgId, launcherResource, - resource + resource, + isAdmin, + onFilterBySite }: { orgId: string; launcherResource: LauncherResource; resource: GetSiteResourceResponse; + isAdmin: boolean; + onFilterBySite: (site: LauncherSiteInfo) => void; }) { const t = useTranslations(); const isInference = resource.mode === "inference"; @@ -417,6 +618,12 @@ function PrivateResourceDetails({ /> + {isInference ? ( <> void; }) { const t = useTranslations(); const { data, isPending, isError } = useQuery({ @@ -477,6 +688,8 @@ function LauncherResourcePanelBody({ launcherResource={resource} resource={detail.data} authInfo={detail.authInfo} + isAdmin={isAdmin} + onFilterBySite={onFilterBySite} /> ); } @@ -486,6 +699,8 @@ function LauncherResourcePanelBody({ orgId={orgId} launcherResource={resource} resource={detail.data} + isAdmin={isAdmin} + onFilterBySite={onFilterBySite} /> ); } @@ -495,7 +710,8 @@ export function LauncherResourcePanel({ onOpenChange, resource, orgId, - isAdmin + isAdmin, + onFilterBySite }: LauncherResourcePanelProps) { const t = useTranslations(); @@ -511,6 +727,8 @@ export function LauncherResourcePanel({ orgId={orgId} resource={resource} open={open} + isAdmin={isAdmin} + onFilterBySite={onFilterBySite} /> ) : null} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index bf65779e3..33afedca8 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -44,6 +44,7 @@ import { type LauncherGroup, type LauncherResource, type LauncherScaleInfo, + type LauncherSiteInfo, type LauncherViewConfig, type LauncherViewRecord, type ListLauncherResourcesResponse @@ -584,6 +585,14 @@ export default function ResourceLauncher({ } }, []); + const handleFilterBySite = useCallback( + (site: LauncherSiteInfo) => { + applyConfigPatch({ siteIds: [site.siteId] }); + handlePanelOpenChange(false); + }, + [applyConfigPatch, handlePanelOpenChange] + ); + const hasHandledAutoOpen = useRef(false); useEffect(() => { @@ -795,6 +804,7 @@ export default function ResourceLauncher({ resource={selectedResource} orgId={orgId} isAdmin={isAdmin} + onFilterBySite={handleFilterBySite} /> {activeSavedView ? (