add sites to resource launcher panel

This commit is contained in:
miloschwartz
2026-09-11 14:57:36 -04:00
parent d776478350
commit 110b6d19a5
5 changed files with 422 additions and 94 deletions
+3
View File
@@ -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",
+183 -87
View File
@@ -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<string>();
const result: LauncherResource[] = [];
const byKey = new Map<string, LauncherResource>();
const siteIdsByKey = new Map<string, Set<number>>();
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<string>();
const result: LauncherResource[] = [];
const byKey = new Map<string, LauncherResource>();
const siteIdsByKey = new Map<string, Set<number>>();
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
}))
+3 -2
View File
@@ -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;
@@ -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 <span>-</span>;
}
if (typeof site.online !== "boolean") {
return <span>-</span>;
}
return (
<span className="flex items-center gap-2">
<span
className={cn(
"size-2 shrink-0 rounded-full",
site.online ? "bg-green-500" : "bg-neutral-500"
)}
/>
<span>{site.online ? t("online") : t("offline")}</span>
</span>
);
}
function LauncherResourceSitesSection({
orgId,
sites,
isAdmin,
onFilterBySite
}: {
orgId: string;
sites: LauncherSiteInfo[];
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const [sortKey, setSortKey] = useState<SitesSortKey>("name");
const [sortOrder, setSortOrder] = useState<SitesSortOrder>("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 (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("sites")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherSitesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("status")}
>
{t("status")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead className="w-12">
<span className="sr-only">{t("actions")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSites.map((site) => (
<TableRow key={site.siteId}>
<TableCell>{site.name}</TableCell>
<TableCell>
<SiteStatusCell site={site} />
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isAdmin ? (
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/sites/${site.niceId}`}
>
{t(
"resourceLauncherViewSiteAsAdmin"
)}
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() =>
onFilterBySite(site)
}
>
{t(
"resourceLauncherFilterBySite"
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SettingsSectionBody>
</SettingsSection>
);
}
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({
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{showAuthMethods ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : 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({
/>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{isInference ? (
<>
<LauncherInferenceModelsSection
@@ -440,11 +647,15 @@ function PrivateResourceDetails({
function LauncherResourcePanelBody({
orgId,
resource,
open
open,
isAdmin,
onFilterBySite
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => 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}
</SidePanelBody>
@@ -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 ? (