diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 344f6b4e3..32735c639 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Site, siteNetworks, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -77,17 +77,25 @@ export async function deleteSite( .where(eq(siteNetworks.siteId, siteId)); // loop through them - for (const network of await networks) { - const [siteResource] = await trx - .select() - .from(siteResources) - .where(eq(siteResources.networkId, network.networkId)); - if (siteResource) { - await rebuildClientAssociationsFromSiteResource( - siteResource, - trx + const updatedSiteResources = await trx + .select() + .from(siteResources) + .where( + inArray( + siteResources.networkId, + networks.map((n) => n.networkId) + ) + ); + for (const siteResource of updatedSiteResources) { + rebuildClientAssociationsFromSiteResource( + siteResource, + trx + ).catch((error) => { + logger.error( + "Failed to rebuild client associations from site resource:", + error ); - } + }); } // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index cec1c8b35..b3238796d 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -47,6 +47,7 @@ const createSiteResourceSchema = z ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()), + siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), destination: z.string().min(1), @@ -187,7 +188,8 @@ export async function createSiteResource( const { name, niceId, - siteIds, + siteIds: siteIdsInput, + siteId, mode, scheme, // proxyPort, @@ -208,6 +210,12 @@ export async function createSiteResource( subdomain } = parsedBody.data; + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } + if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( orgId, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 0a6166755..c7099de40 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ */ function aggCol(column: any) { if (DB_TYPE === "sqlite") { + // json_group_array will include NULLs for left-joined missing rows; + // we filter them out in transformSiteResourceRow keeping arrays aligned. return sql`json_group_array(${column})`; } - return sql`array_agg(${column})`; + return sql`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`; } /** @@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) { if (DB_TYPE !== "sqlite") { return row; } + const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[]; + const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[]; + const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[]; + const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[]; + const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[]; + + // When a site resource has no associated sites (left join produced no + // matches), the aggregated arrays will contain a single NULL entry. Strip + // those out, keeping the parallel arrays aligned by siteId presence. + const siteIds: number[] = []; + const siteNames: string[] = []; + const siteNiceIds: string[] = []; + const siteAddresses: (string | null)[] = []; + const siteOnlines: boolean[] = []; + for (let i = 0; i < siteIdsRaw.length; i++) { + if (siteIdsRaw[i] == null) continue; + siteIds.push(siteIdsRaw[i] as number); + siteNames.push((siteNamesRaw[i] ?? "") as string); + siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string); + siteAddresses.push(siteAddressesRaw[i] ?? null); + siteOnlines.push(siteOnlinesRaw[i] === 1); + } + return { ...row, - siteNames: JSON.parse(row.siteNames) as string[], - siteNiceIds: JSON.parse(row.siteNiceIds) as string[], - siteIds: JSON.parse(row.siteIds) as number[], - siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[], - // SQLite stores booleans as 0/1 integers - siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map( - (v) => v === 1 - ) as boolean[] + siteNames, + siteNiceIds, + siteIds, + siteAddresses, + siteOnlines }; } @@ -158,11 +180,11 @@ function querySiteResourcesBase() { siteOnlines: aggCol(sites.online) }) .from(siteResources) - .innerJoin( + .leftJoin( siteNetworks, eq(siteResources.networkId, siteNetworks.networkId) ) - .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } @@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg( const conditions = [and(eq(siteResources.orgId, orgId))]; if (siteId != null) { + // Keep inner joins here: filtering by a specific site implies the + // resource must have at least one matching site. const resourcesForSite = db .select({ id: siteResources.siteResourceId }) .from(siteResources) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d2bb44a45..462cb1c2b 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -44,6 +44,7 @@ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), siteIds: z.array(z.int()), + siteId: z.int().positive().optional(), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), niceId: z .string() @@ -196,7 +197,8 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteIds, // because it can change + siteIds: siteIdsInput, // because it can change + siteId, niceId, mode, scheme, @@ -217,6 +219,12 @@ export async function updateSiteResource( subdomain } = parsedBody.data; + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } + // Check if site resource exists const [existingSiteResource] = await db .select() diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index a772fb576..5c5906ad5 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -37,11 +37,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { useEffect, useMemo, useState, useTransition } from "react"; - import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; -import { orgQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; @@ -206,7 +203,11 @@ export default function ClientResourcesTable({ const { siteNames, siteNiceIds, orgId } = resourceRow; if (!siteNames || siteNames.length === 0) { - return -; + return ( + + {t("noSites", { defaultValue: "No sites" })} + + ); } if (siteNames.length === 1) {