mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-29 20:22:59 +00:00
Handle deleting client and orphaning resources
This commit is contained in:
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
||||||
import { newts, newtSessions, sites } 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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -77,17 +77,25 @@ export async function deleteSite(
|
|||||||
.where(eq(siteNetworks.siteId, siteId));
|
.where(eq(siteNetworks.siteId, siteId));
|
||||||
|
|
||||||
// loop through them
|
// loop through them
|
||||||
for (const network of await networks) {
|
const updatedSiteResources = await trx
|
||||||
const [siteResource] = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(eq(siteResources.networkId, network.networkId));
|
.where(
|
||||||
if (siteResource) {
|
inArray(
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
siteResources.networkId,
|
||||||
|
networks.map((n) => n.networkId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const siteResource of updatedSiteResources) {
|
||||||
|
rebuildClientAssociationsFromSiteResource(
|
||||||
siteResource,
|
siteResource,
|
||||||
trx
|
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
|
// get the newt on the site by querying the newt table for siteId
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const createSiteResourceSchema = z
|
|||||||
ssl: z.boolean().optional(), // only used for http mode
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
siteIds: z.array(z.int()),
|
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(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
@@ -187,7 +188,8 @@ export async function createSiteResource(
|
|||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId,
|
||||||
siteIds,
|
siteIds: siteIdsInput,
|
||||||
|
siteId,
|
||||||
mode,
|
mode,
|
||||||
scheme,
|
scheme,
|
||||||
// proxyPort,
|
// proxyPort,
|
||||||
@@ -208,6 +210,12 @@ export async function createSiteResource(
|
|||||||
subdomain
|
subdomain
|
||||||
} = parsedBody.data;
|
} = 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") {
|
if (mode == "http") {
|
||||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
orgId,
|
orgId,
|
||||||
|
|||||||
@@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
|||||||
*/
|
*/
|
||||||
function aggCol<T>(column: any) {
|
function aggCol<T>(column: any) {
|
||||||
if (DB_TYPE === "sqlite") {
|
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<T>`json_group_array(${column})`;
|
return sql<T>`json_group_array(${column})`;
|
||||||
}
|
}
|
||||||
return sql<T>`array_agg(${column})`;
|
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) {
|
|||||||
if (DB_TYPE !== "sqlite") {
|
if (DB_TYPE !== "sqlite") {
|
||||||
return row;
|
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 {
|
return {
|
||||||
...row,
|
...row,
|
||||||
siteNames: JSON.parse(row.siteNames) as string[],
|
siteNames,
|
||||||
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
|
siteNiceIds,
|
||||||
siteIds: JSON.parse(row.siteIds) as number[],
|
siteIds,
|
||||||
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
|
siteAddresses,
|
||||||
// SQLite stores booleans as 0/1 integers
|
siteOnlines
|
||||||
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
|
|
||||||
(v) => v === 1
|
|
||||||
) as boolean[]
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +180,11 @@ function querySiteResourcesBase() {
|
|||||||
siteOnlines: aggCol<boolean[]>(sites.online)
|
siteOnlines: aggCol<boolean[]>(sites.online)
|
||||||
})
|
})
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.innerJoin(
|
.leftJoin(
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
)
|
)
|
||||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
.groupBy(siteResources.siteResourceId);
|
.groupBy(siteResources.siteResourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
if (siteId != null) {
|
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
|
const resourcesForSite = db
|
||||||
.select({ id: siteResources.siteResourceId })
|
.select({ id: siteResources.siteResourceId })
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const updateSiteResourceSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
siteIds: z.array(z.int()),
|
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().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
niceId: z
|
niceId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -196,7 +197,8 @@ export async function updateSiteResource(
|
|||||||
const { siteResourceId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteIds, // because it can change
|
siteIds: siteIdsInput, // because it can change
|
||||||
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
scheme,
|
scheme,
|
||||||
@@ -217,6 +219,12 @@ export async function updateSiteResource(
|
|||||||
subdomain
|
subdomain
|
||||||
} = parsedBody.data;
|
} = 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
|
// Check if site resource exists
|
||||||
const [existingSiteResource] = await db
|
const [existingSiteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -37,11 +37,8 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
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 type { PaginationState } from "@tanstack/react-table";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
@@ -206,7 +203,11 @@ export default function ClientResourcesTable({
|
|||||||
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
||||||
|
|
||||||
if (!siteNames || siteNames.length === 0) {
|
if (!siteNames || siteNames.length === 0) {
|
||||||
return <span>-</span>;
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("noSites", { defaultValue: "No sites" })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (siteNames.length === 1) {
|
if (siteNames.length === 1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user