Support labels on blueprints

Ref https://github.com/orgs/fosrl/discussions/2849
This commit is contained in:
Owen
2026-08-14 09:16:50 -04:00
parent 0ff5ea4f6a
commit 03118f1ede
4 changed files with 114 additions and 1 deletions
+86
View File
@@ -0,0 +1,86 @@
import {
labels,
resourceLabels,
siteResourceLabels,
Transaction
} from "@server/db";
import logger from "@server/logger";
import { and, eq, sql } from "drizzle-orm";
// Matches the "gray" swatch in the label color palette used by the UI
// (src/components/labels-selector.tsx), used as the default for labels
// auto-created from a blueprint where no color is specified.
const DEFAULT_LABEL_COLOR = "#b4b4b4";
/**
* Looks up labels by name (case-insensitive) within an org, auto-creating
* any that don't already exist. Returns the resolved, de-duplicated labelIds.
*/
export async function getOrCreateLabelIds(
orgId: string,
labelNames: string[],
trx: Transaction
): Promise<number[]> {
const labelIds = new Set<number>();
for (const name of labelNames) {
let [label] = await trx
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (!label) {
[label] = await trx
.insert(labels)
.values({ name, color: DEFAULT_LABEL_COLOR, orgId })
.returning({ labelId: labels.labelId });
logger.info(
`Auto-created label "${name}" in org ${orgId} from blueprint`
);
}
labelIds.add(label.labelId);
}
return Array.from(labelIds);
}
export async function syncResourceLabels(
resourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(resourceLabels)
.where(eq(resourceLabels.resourceId, resourceId));
if (labelIds.length > 0) {
await trx
.insert(resourceLabels)
.values(labelIds.map((labelId) => ({ resourceId, labelId })));
}
}
export async function syncSiteResourceLabels(
siteResourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(siteResourceLabels)
.where(eq(siteResourceLabels.siteResourceId, siteResourceId));
if (labelIds.length > 0) {
await trx
.insert(siteResourceLabels)
.values(
labelIds.map((labelId) => ({ siteResourceId, labelId }))
);
}
}
+15
View File
@@ -19,6 +19,7 @@ import {
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
@@ -443,6 +444,13 @@ export async function updatePrivateResources(
); );
} }
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({ results.push({
newSiteResource: updatedResource, newSiteResource: updatedResource,
oldSiteResource: existingResource, oldSiteResource: existingResource,
@@ -697,6 +705,13 @@ export async function updatePrivateResources(
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx); await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({ results.push({
newSiteResource: newResource, newSiteResource: newResource,
newSites: allSites, newSites: allSites,
+10
View File
@@ -50,6 +50,7 @@ import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import next from "next"; import next from "next";
@@ -1351,6 +1352,15 @@ export async function updatePublicResources(
logger.debug(`Created resource ${newResource.resourceId}`); logger.debug(`Created resource ${newResource.resourceId}`);
} }
if (!isTargetsOnlyResource(resourceData)) {
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels || [],
trx
);
await syncResourceLabels(resource.resourceId, labelIds, trx);
}
results.push({ results.push({
proxyResource: resource, proxyResource: resource,
targetsToUpdate, targetsToUpdate,
+3 -1
View File
@@ -225,7 +225,8 @@ export const PublicResourceSchema = z
maintenance: MaintenanceSchema.optional(), maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(), "proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional() "proxy-protocol-version": z.int().min(1).optional(),
labels: z.array(z.string().min(1)).optional()
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -493,6 +494,7 @@ export const PrivateResourceSchema = z
}), }),
users: z.array(z.string()).optional().default([]), users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]),
labels: z.array(z.string().min(1)).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional() "auth-daemon": AuthDaemonSchema.optional()
}) })
.refine( .refine(