From 3253d60900dcd86dce4529cf4dc1204f483e088e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 5 May 2026 20:53:16 +0200 Subject: [PATCH 01/44] =?UTF-8?q?=F0=9F=9A=A7=20Add=20CRUD=20endpoints=20a?= =?UTF-8?q?nd=20tables=20for=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 3 + server/db/pg/schema/schema.ts | 85 ++++++++-- server/private/routers/external.ts | 25 +++ .../private/routers/labels/createOrgLabel.ts | 144 ++++++++++++++++ server/private/routers/labels/index.ts | 16 ++ .../private/routers/labels/listOrgLabels.ts | 155 ++++++++++++++++++ .../private/routers/labels/updateOrgLabel.ts | 109 ++++++++++++ server/routers/labels/types.ts | 10 ++ 8 files changed, 531 insertions(+), 16 deletions(-) create mode 100644 server/private/routers/labels/createOrgLabel.ts create mode 100644 server/private/routers/labels/index.ts create mode 100644 server/private/routers/labels/listOrgLabels.ts create mode 100644 server/private/routers/labels/updateOrgLabel.ts create mode 100644 server/routers/labels/types.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 9ba1b5bce..f6bfa2549 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -148,6 +148,9 @@ export enum ActionsEnum { updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", listAlertRules = "listAlertRules", + listOrgLabels = "listOrgLabels", + createOrgLabel = "createOrgLabel", + updateOrgLabel = "updateOrgLabel", getAlertRule = "getAlertRule", createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7fbcef621..0a4066db3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -162,6 +162,45 @@ export const resources = pgTable("resources", { wildcard: boolean("wildcard").notNull().default(false) }); +export const labels = pgTable("labels", { + labelId: serial("labelId").primaryKey(), + name: varchar("name").notNull(), + color: varchar("color").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const siteLabels = pgTable("siteLabels", { + siteLabelId: serial("siteLabelId").primaryKey(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const resourceLabels = pgTable("resourceLabels", { + resourceLabelId: serial("resourceLabelId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + export const targets = pgTable("targets", { targetId: serial("targetId").primaryKey(), resourceId: integer("resourceId") @@ -196,9 +235,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }).notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), @@ -1097,19 +1138,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { complete: boolean("complete").notNull().default(false) }); -export const statusHistory = pgTable("statusHistory", { - id: serial("id").primaryKey(), - entityType: varchar("entityType").notNull(), - entityId: integer("entityId").notNull(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: varchar("status").notNull(), - timestamp: integer("timestamp").notNull(), -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); +export const statusHistory = pgTable( + "statusHistory", + { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull() + }, + (table) => [ + index("idx_statusHistory_entity").on( + table.entityType, + table.entityId, + table.timestamp + ), + index("idx_statusHistory_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -1179,3 +1231,4 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; +export type Label = InferSelectModel; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a2667daa1..1b8c1ef4c 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; +import * as labels from "#private/routers/labels"; import { verifyOrgAccess, @@ -732,6 +733,30 @@ authenticated.get( alertRule.getAlertRule ); +authenticated.get( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listOrgLabels), + labels.listOrgLabels +); + +authenticated.post( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgLabel), + labels.createOrgLabel +); + +authenticated.patch( + "/org/:orgId/label/:labelId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgLabel), + labels.updateOrgLabel +); + authenticated.get( "/org/:orgId/health-checks", verifyValidLicense, diff --git a/server/private/routers/labels/createOrgLabel.ts b/server/private/routers/labels/createOrgLabel.ts new file mode 100644 index 000000000..1439018d2 --- /dev/null +++ b/server/private/routers/labels/createOrgLabel.ts @@ -0,0 +1,144 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites, + type Label +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + color: z + .string() + .regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i) + .nonempty(), + siteId: z.number().int().optional(), + resourceId: z.number().int().optional() +}); + +export async function createOrgLabel( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, color, siteId, resourceId } = parsedBody.data; + + if (siteId) { + const siteCount = await db.$count(sites, eq(sites.siteId, siteId)); + + if (siteCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with Id ${siteId} doesn't exist.` + ) + ); + } + } + + if (resourceId) { + const resourceCount = await db.$count( + sites, + eq(resources.resourceId, resourceId) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Resource with Id ${resourceId} doesn't exist.` + ) + ); + } + } + + const label = await db.transaction(async (tx) => { + const [label] = await tx + .insert(labels) + .values({ + name, + color, + orgId + }) + .returning(); + + if (siteId) { + await tx.insert(siteLabels).values({ + siteId, + labelId: label.labelId + }); + } + + if (resourceId) { + await tx.insert(resourceLabels).values({ + resourceId, + labelId: label.labelId + }); + } + return label; + }); + + return response(res, { + data: { label }, + success: true, + error: false, + message: "Org Label created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts new file mode 100644 index 000000000..4c7e5f432 --- /dev/null +++ b/server/private/routers/labels/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./listOrgLabels"; +export * from "./createOrgLabel"; +export * from "./updateOrgLabel"; diff --git a/server/private/routers/labels/listOrgLabels.ts b/server/private/routers/labels/listOrgLabels.ts new file mode 100644 index 000000000..dc2b50017 --- /dev/null +++ b/server/private/routers/labels/listOrgLabels.ts @@ -0,0 +1,155 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, labels } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listLabelsSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +function queryLabelsBase() { + return db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(labels); +} + +export async function listOrgLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listLabelsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + + const conditions = [and(eq(labels.orgId, orgId))]; + + if (query) { + conditions.push( + like( + sql`LOWER(${labels.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = queryLabelsBase().where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count( + queryLabelsBase() + .where(and(...conditions)) + .as("filtered_labels") + ); + + const labelListQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(labels.name)); + + const [totalCount, rows] = await Promise.all([ + countQuery, + labelListQuery + ]); + + return response(res, { + data: { + labels: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/updateOrgLabel.ts b/server/private/routers/labels/updateOrgLabel.ts new file mode 100644 index 000000000..d6478678a --- /dev/null +++ b/server/private/routers/labels/updateOrgLabel.ts @@ -0,0 +1,109 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites, + type Label +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + labelId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const updateLabelBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + color: z + .string() + .regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i) + .nonempty() +}); + +export async function updateOrgLabel( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, labelId } = parsedParams.data; + + const parsedBody = updateLabelBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + if (!existing) { + return next(createHttpError(HttpCode.NOT_FOUND, "Label not found")); + } + + const { name, color } = parsedBody.data; + + const [label] = await db + .update(labels) + .set({ + name, + color + }) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))) + .returning(); + + return response(res, { + data: { + label + }, + success: true, + error: false, + message: "Label updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/labels/types.ts b/server/routers/labels/types.ts new file mode 100644 index 000000000..ad182ad11 --- /dev/null +++ b/server/routers/labels/types.ts @@ -0,0 +1,10 @@ +import type { Label } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListOrgLabelsResponse = PaginatedResponse<{ + labels: Omit[]; +}>; + +export type CreateOrEditLabelResponse = { + label: Label; +}; From 09baf2f32ee23836f47e1e5b4341c9ec391ef1eb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 5 May 2026 21:08:22 +0200 Subject: [PATCH 02/44] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20add=20sqlite=20ta?= =?UTF-8?q?ble=20for=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 87 +++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 423190420..3695e29a0 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -183,6 +183,47 @@ export const resources = sqliteTable("resources", { wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) }); +export const labels = sqliteTable("labels", { + labelId: integer("labelId").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + color: text("color").notNull(), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const siteLabels = sqliteTable("siteLabels", { + siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const resourceLabels = sqliteTable("resourceLabels", { + resourceLabelId: integer("resourceLabelId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") @@ -219,9 +260,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }).notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() @@ -1196,19 +1239,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { complete: integer("complete", { mode: "boolean" }).notNull().default(false) }); -export const statusHistory = sqliteTable("statusHistory", { - id: integer("id").primaryKey({ autoIncrement: true }), - entityType: text("entityType").notNull(), // "site" | "healthCheck" - entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId - orgId: text("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks - timestamp: integer("timestamp").notNull(), // unix epoch seconds -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); +export const statusHistory = sqliteTable( + "statusHistory", + { + id: integer("id").primaryKey({ autoIncrement: true }), + entityType: text("entityType").notNull(), // "site" | "healthCheck" + entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks + timestamp: integer("timestamp").notNull() // unix epoch seconds + }, + (table) => [ + index("idx_statusHistory_entity").on( + table.entityType, + table.entityId, + table.timestamp + ), + index("idx_statusHistory_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -1278,3 +1332,4 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type StatusHistory = InferSelectModel; +export type Label = InferSelectModel; From 0d04cc365f0215eef36fe2ed42d45d86c7fa25e6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 5 May 2026 21:35:10 +0200 Subject: [PATCH 03/44] =?UTF-8?q?=E2=9C=A8=20attach=20label=20to=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 1 + server/private/routers/external.ts | 8 + .../routers/labels/attachLabelToItem.ts | 155 ++++++++++++++++++ .../private/routers/labels/createOrgLabel.ts | 17 +- server/private/routers/labels/index.ts | 1 + .../private/routers/labels/updateOrgLabel.ts | 10 +- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 server/private/routers/labels/attachLabelToItem.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f6bfa2549..5e2f58287 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -151,6 +151,7 @@ export enum ActionsEnum { listOrgLabels = "listOrgLabels", createOrgLabel = "createOrgLabel", updateOrgLabel = "updateOrgLabel", + attachLabelToItem = "attachLabelToItem", getAlertRule = "getAlertRule", createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 1b8c1ef4c..7941cd1fe 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -757,6 +757,14 @@ authenticated.patch( labels.updateOrgLabel ); +authenticated.put( + "/org/:orgId/label/:labelId/attach", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.attachLabelToItem), + labels.attachLabelToItem +); + authenticated.get( "/org/:orgId/health-checks", verifyValidLicense, diff --git a/server/private/routers/labels/attachLabelToItem.ts b/server/private/routers/labels/attachLabelToItem.ts new file mode 100644 index 000000000..4655c3f3b --- /dev/null +++ b/server/private/routers/labels/attachLabelToItem.ts @@ -0,0 +1,155 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + labelId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const attachLabelBodySchema = z.strictObject({ + siteId: z.number().int().optional(), + resourceId: z.number().int().optional() +}); + +export async function attachLabelToItem( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, labelId } = parsedParams.data; + + const parsedBody = attachLabelBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteId, resourceId } = parsedBody.data; + + if (!siteId && !resourceId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one of `siteId` or `resourceId` should be provided." + ) + ); + } + + const [existing] = await db + .select() + .from(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Label with Id ${labelId} not found` + ) + ); + } + + if (siteId) { + const siteCount = await db.$count( + sites, + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ); + + if (siteCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with Id ${siteId} doesn't exist.` + ) + ); + } + + await db + .insert(siteLabels) + .values({ + labelId, + siteId + }) + .returning(); + } + + if (resourceId) { + const resourceCount = await db.$count( + resources, + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Resource with Id ${resourceId} doesn't exist.` + ) + ); + } + + await db.insert(resourceLabels).values({ + labelId, + resourceId + }); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Site Label object created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/createOrgLabel.ts b/server/private/routers/labels/createOrgLabel.ts index 1439018d2..074a96207 100644 --- a/server/private/routers/labels/createOrgLabel.ts +++ b/server/private/routers/labels/createOrgLabel.ts @@ -16,14 +16,13 @@ import { resourceLabels, resources, siteLabels, - sites, - type Label + sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import HttpCode from "@server/types/HttpCode"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -74,7 +73,10 @@ export async function createOrgLabel( const { name, color, siteId, resourceId } = parsedBody.data; if (siteId) { - const siteCount = await db.$count(sites, eq(sites.siteId, siteId)); + const siteCount = await db.$count( + sites, + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ); if (siteCount === 0) { return next( @@ -88,8 +90,11 @@ export async function createOrgLabel( if (resourceId) { const resourceCount = await db.$count( - sites, - eq(resources.resourceId, resourceId) + resources, + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) ); if (resourceCount === 0) { diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts index 4c7e5f432..cc15ebffe 100644 --- a/server/private/routers/labels/index.ts +++ b/server/private/routers/labels/index.ts @@ -14,3 +14,4 @@ export * from "./listOrgLabels"; export * from "./createOrgLabel"; export * from "./updateOrgLabel"; +export * from "./attachLabelToItem"; diff --git a/server/private/routers/labels/updateOrgLabel.ts b/server/private/routers/labels/updateOrgLabel.ts index d6478678a..eb5f5177a 100644 --- a/server/private/routers/labels/updateOrgLabel.ts +++ b/server/private/routers/labels/updateOrgLabel.ts @@ -11,15 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { - db, - labels, - resourceLabels, - resources, - siteLabels, - sites, - type Label -} from "@server/db"; +import { db, labels } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; From 1831ca4e751e813f445933f8750a8bb6fdbff4b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 00:33:47 +0200 Subject: [PATCH 04/44] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20detach=20label=20fro?= =?UTF-8?q?m=20site/resoirce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 1 + server/private/routers/external.ts | 8 + .../routers/labels/attachLabelToItem.ts | 2 +- .../routers/labels/detachLabelFromItem.ts | 160 ++++++++++++++++++ server/private/routers/labels/index.ts | 1 + 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/labels/detachLabelFromItem.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 5e2f58287..969f9e4ae 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -152,6 +152,7 @@ export enum ActionsEnum { createOrgLabel = "createOrgLabel", updateOrgLabel = "updateOrgLabel", attachLabelToItem = "attachLabelToItem", + detachLabelFromItem = "detachLabelFromItem", getAlertRule = "getAlertRule", createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 7941cd1fe..5b146da18 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -765,6 +765,14 @@ authenticated.put( labels.attachLabelToItem ); +authenticated.delete( + "/org/:orgId/label/:labelId/detach", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.detachLabelFromItem), + labels.detachLabelFromItem +); + authenticated.get( "/org/:orgId/health-checks", verifyValidLicense, diff --git a/server/private/routers/labels/attachLabelToItem.ts b/server/private/routers/labels/attachLabelToItem.ts index 4655c3f3b..392332776 100644 --- a/server/private/routers/labels/attachLabelToItem.ts +++ b/server/private/routers/labels/attachLabelToItem.ts @@ -127,7 +127,7 @@ export async function attachLabelToItem( if (resourceCount === 0) { return next( createHttpError( - HttpCode.BAD_REQUEST, + HttpCode.NOT_FOUND, `Resource with Id ${resourceId} doesn't exist.` ) ); diff --git a/server/private/routers/labels/detachLabelFromItem.ts b/server/private/routers/labels/detachLabelFromItem.ts new file mode 100644 index 000000000..1e09234a0 --- /dev/null +++ b/server/private/routers/labels/detachLabelFromItem.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + labelId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const detachLabelBodySchema = z.strictObject({ + siteId: z.number().int().optional(), + resourceId: z.number().int().optional() +}); + +export async function detachLabelFromItem( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, labelId } = parsedParams.data; + + const parsedBody = detachLabelBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteId, resourceId } = parsedBody.data; + + if (!siteId && !resourceId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one of `siteId` or `resourceId` should be provided." + ) + ); + } + + const [existing] = await db + .select() + .from(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Label with Id ${labelId} not found` + ) + ); + } + + if (siteId) { + const siteCount = await db.$count( + sites, + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ); + + if (siteCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with Id ${siteId} doesn't exist.` + ) + ); + } + + await db + .delete(siteLabels) + .where( + and( + eq(siteLabels.labelId, labelId), + eq(siteLabels.siteId, siteId) + ) + ); + } + + if (resourceId) { + const resourceCount = await db.$count( + resources, + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with Id ${resourceId} doesn't exist.` + ) + ); + } + + await db + .delete(resourceLabels) + .where( + and( + eq(resourceLabels.labelId, labelId), + eq(resourceLabels.resourceId, resourceId) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Label detached successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts index cc15ebffe..ffec1229b 100644 --- a/server/private/routers/labels/index.ts +++ b/server/private/routers/labels/index.ts @@ -15,3 +15,4 @@ export * from "./listOrgLabels"; export * from "./createOrgLabel"; export * from "./updateOrgLabel"; export * from "./attachLabelToItem"; +export * from "./detachLabelFromItem"; From ab8fc11ab3dc878b5ca0ef902906893a48bf63ca Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 02:46:16 +0200 Subject: [PATCH 05/44] =?UTF-8?q?=F0=9F=9A=A7=20add=20labels=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ src/components/SitesTable.tsx | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ee4ef143d..148b593c3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1124,6 +1124,8 @@ "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", "inviteInvalid": "Invalid Invite", + "labels": "Labels", + "addLabelsButtonText": "Add labels", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index c29314874..5b80a097f 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -26,7 +26,8 @@ import { ArrowUpRight, ChevronDown, ChevronsUpDownIcon, - MoreHorizontal + MoreHorizontal, + PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -437,7 +438,7 @@ export default function SitesTable({ header: () => { return {t("address")}; }, - cell: ({ row }: { row: any }) => { + cell: ({ row }) => { const originalRow = row.original; return originalRow.address ? (
@@ -448,6 +449,22 @@ export default function SitesTable({ ); } }, + { + accessorKey: "labels", + header: () => {t("labels")}, + cell: ({ row }) => { + return ( + + ); + } + }, { id: "actions", enableHiding: false, @@ -622,7 +639,8 @@ export default function SitesTable({ niceId: false, nice: false, exitNode: false, - address: false + address: false, + labels: false }} enableColumnVisibility stickyLeftColumn="name" From 72524db52d3ae45fc5f1ae2b71f4a0a8fa7c3c9a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 02:48:47 +0200 Subject: [PATCH 06/44] =?UTF-8?q?=F0=9F=92=84=20shrink=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5b80a097f..b3a22db0d 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -459,8 +459,10 @@ export default function SitesTable({ size="sm" variant="outline" > - {" "} - {t("addLabelsButtonText")} + {" "} + + {t("addLabelsButtonText")} + ); } From 840cc214e3ef2def855329c042062d130fcf416b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 18:21:09 +0200 Subject: [PATCH 07/44] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 +- src/components/SitesTable.tsx | 102 +++++++++++++++++++++++----------- 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 148b593c3..06dc1ac6c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1125,7 +1125,9 @@ "idpErrorNotFound": "IdP not found", "inviteInvalid": "Invalid Invite", "labels": "Labels", - "addLabelsButtonText": "Add labels", + "addLabels": "Add labels", + "siteLabelsTab": "Labels", + "siteLabelsDescription": "Manage labels associated with this site.", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index b3a22db0d..d53a6fdc0 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -94,6 +94,7 @@ export default function SitesTable({ const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); + const [isLabelsDialogOpen, setIsLabelsDialogOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -453,18 +454,7 @@ export default function SitesTable({ accessorKey: "labels", header: () => {t("labels")}, cell: ({ row }) => { - return ( - - ); + return <>; } }, { @@ -507,6 +497,14 @@ export default function SitesTable({ {t("sitesTableViewPrivateResources")} + { + setSelectedSite(siteRow); + setIsLabelsDialogOpen(true); + }} + > + {t("addLabels")} + { setSelectedSite(siteRow); @@ -597,25 +595,33 @@ export default function SitesTable({ {selectedSite && ( - { - setIsDeleteModalOpen(val); - setSelectedSite(null); - }} - dialog={ -
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

-
- } - buttonText={t("siteConfirmDelete")} - onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) - } - string={selectedSite.name} - title={t("siteDelete")} - /> + <> + { + setIsDeleteModalOpen(val); + setSelectedSite(null); + }} + dialog={ +
+

{t("siteQuestionRemove")}

+

{t("siteMessageRemove")}

+
+ } + buttonText={t("siteConfirmDelete")} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } + string={selectedSite.name} + title={t("siteDelete")} + /> + + + )} ); } + +type SiteLabelsDialogProps = { + site: SiteRow; + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +function SiteLabelsDialog({ site, isOpen, setIsOpen }: SiteLabelsDialogProps) { + const t = useTranslations(); + return ( + + + + {t("siteLabelsTab")} + + {t("siteLabelsDescription")} + + + + <> + + + + + + + ); +} From e61ef2ca2a03a80d8e426a2db85cee1746a3cb19 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 20:06:42 +0200 Subject: [PATCH 08/44] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20label=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 + server/routers/site/listSites.ts | 45 ++++++++++- src/components/SitesTable.tsx | 46 ++++++++++- src/components/labels-selector.tsx | 126 +++++++++++++++++++++++++++++ src/lib/queries.ts | 28 +++++++ 5 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/components/labels-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 871906841..2a6d1ee40 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1128,6 +1128,9 @@ "addLabels": "Add labels", "siteLabelsTab": "Labels", "siteLabelsDescription": "Manage labels associated with this site.", + "labelsNotFound": "Labels not found", + "labelSearch": "Search labels", + "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index fc4ea5be1..8c35a2521 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -9,7 +9,10 @@ import { siteResources, targets, sites, - userSites + userSites, + labels, + siteLabels, + type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; import response from "@server/lib/response"; @@ -23,6 +26,8 @@ import createHttpError from "http-errors"; import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; // Stale-while-revalidate: keeps the last successfully fetched version so that // a transient network failure / timeout does not flip every site back to @@ -233,6 +238,7 @@ type SiteRowBase = Awaited>[0]; type SiteWithUpdateAvailable = Omit & { online?: SiteRowBase["online"]; // undefined for local sites newtUpdateAvailable?: boolean; + labels?: Array>; }; export type ListSitesResponse = PaginatedResponse<{ @@ -367,11 +373,46 @@ export async function listSites( // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); + const siteIds = rows.map((site) => site.siteId); + + let labelsForSites: Array<{ + labelId: number; + name: string; + color: string; + siteId: number; + }> = []; + + // The label feature should be added in the tiers + // if (await isLicensedOrSubscribed(orgId, tierMatrix.fullRbac)) { + // } + labelsForSites = + siteIds.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.name, + siteId: siteLabels.siteId + }) + .from(labels) + .innerJoin( + siteLabels, + eq(siteLabels.labelId, labels.labelId) + ) + .where(inArray(siteLabels.siteId, siteIds)); + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; // Initially set to false, will be updated if version check succeeds siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; + + // associate labels + const labelsForSite = labelsForSites.filter( + (label) => label.siteId === site.siteId + ); + + return { ...siteWithUpdate, labels: labelsForSite }; }); // Try to get the latest version, but don't block if it fails diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d53a6fdc0..a50bc8b20 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -50,6 +50,9 @@ import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { LabelsSelector } from "./labels-selector"; export type SiteRow = { id: number; @@ -67,6 +70,11 @@ export type SiteRow = { exitNodeEndpoint?: string; remoteExitNodeId?: string; resourceCount: number; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; type SitesTableProps = { @@ -368,7 +376,7 @@ export default function SitesTable({ variant="ghost" size="sm" onClick={() => setResourcesDialogSite(siteRow)} - className="flex h-8 items-center gap-2 px-0 font-normal" + className="flex h-8 items-center gap-2 px-2 font-normal" > {siteRow.resourceCount} {t("resources")} @@ -450,11 +458,41 @@ export default function SitesTable({ ); } }, + // The label feature should be added to the tiers { accessorKey: "labels", header: () => {t("labels")}, cell: ({ row }) => { - return <>; + const labels = row.original.labels ?? []; + return ( +
+ + + + + + {}} + /> + + +
+ ); } }, { @@ -616,11 +654,11 @@ export default function SitesTable({ title={t("siteDelete")} /> - + /> */} )} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx new file mode 100644 index 000000000..988a9e02b --- /dev/null +++ b/src/components/labels-selector.tsx @@ -0,0 +1,126 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { Checkbox } from "./ui/checkbox"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; +import { type Selectedsite, SiteOnlineStatus } from "./site-selector"; + +type SelectedLabel = { + name: string; + color: string; + labelId: number; +}; + +export type LabelsSelectorProps = { + orgId: string; + selectedLabels: SelectedLabel[]; + onSelectionChange: (sites: SelectedLabel[]) => void; +}; + +export function LabelsSelector({ + orgId, + selectedLabels, + onSelectionChange +}: LabelsSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 10 + }) + ); + + const labelsShown = useMemo(() => { + const base = [...labels]; + if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) { + const selectedNotInBase = selectedLabels.filter( + (sel) => !base.some((s) => s.labelId === sel.labelId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, labels, selectedLabels]); + + const selectedIds = useMemo( + () => new Set(selectedLabels.map((s) => s.labelId)), + [selectedLabels] + ); + + return ( + + + + + {labelSearchQuery.trim().length > 0 ? ( + <> + {t("createNewLabel", { + label: labelSearchQuery.trim() + })} + + ) : ( + t("labelsNotFound") + )} + + + {labelsShown.map((label) => ( + { + if (selectedIds.has(label.labelId)) { + onSelectionChange( + selectedLabels.filter( + (l) => l.labelId !== label.labelId + ) + ); + } else { + onSelectionChange([ + ...selectedLabels, + label + ]); + } + }} + > + {}} + aria-hidden + tabIndex={-1} + /> +
+ + + {label.name} + +
+
+ ))} +
+
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e58a5d471..d317323c3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -33,6 +33,7 @@ import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; export type ProductUpdate = { link: string | null; @@ -208,6 +209,33 @@ export const orgQueries = { } }), + labels: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "LABELS", { query, perPage }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/labels?${sp.toString()}`, { signal }); + return res.data.data.labels; + } + }), + domains: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAINS"] as const, From a63c1ec364640c3d8b81e4284f37fc495d4d2e27 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 21:49:20 +0200 Subject: [PATCH 09/44] =?UTF-8?q?=F0=9F=92=84=20label=20selector=20(with?= =?UTF-8?q?=20create=20label)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/labels-selector.tsx | 117 +++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2a6d1ee40..563569803 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1130,6 +1130,7 @@ "siteLabelsDescription": "Manage labels associated with this site.", "labelsNotFound": "Labels not found", "labelSearch": "Search labels", + "selectColor": "Select color", "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 988a9e02b..ec8d7f270 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -1,6 +1,6 @@ import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useActionState, useMemo, useState, useTransition } from "react"; import { Command, CommandEmpty, @@ -13,6 +13,18 @@ import { Checkbox } from "./ui/checkbox"; import { useTranslations } from "next-intl"; import { useDebounce } from "use-debounce"; import { type Selectedsite, SiteOnlineStatus } from "./site-selector"; +import { Button } from "./ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import type { AxiosResponse } from "axios"; type SelectedLabel = { name: string; @@ -24,17 +36,31 @@ export type LabelsSelectorProps = { orgId: string; selectedLabels: SelectedLabel[]; onSelectionChange: (sites: SelectedLabel[]) => void; + onCreateLabel: (newlabel: SelectedLabel) => Promise; +}; + +const LABEL_COLORS = { + red: "#ff6467", + green: "#05df72", + blue: "#51a2ff", + yellow: "#fdc744", + orange: "#ff8905", + purple: "#a684ff", + gray: "#b4b4b4" }; export function LabelsSelector({ orgId, selectedLabels, - onSelectionChange + onSelectionChange, + onCreateLabel }: LabelsSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + const api = createApiClient(useEnvContext()); + const { data: labels = [] } = useQuery( orgQueries.labels({ orgId, @@ -59,6 +85,29 @@ export function LabelsSelector({ [selectedLabels] ); + const colorValues = Object.values(LABEL_COLORS); + const randomColor = + colorValues[Math.floor(Math.random() * colorValues.length)]; + + const [, action, isPending] = useActionState(createLabel, null); + + async function createLabel(_: any, formData: FormData) { + const name = formData.get("name")?.toString(); + const color = formData.get("color")?.toString(); + const res = await api.post>( + `/org/${orgId}/labels`, + { name, color } + ); + + const { label } = res.data.data; + await onCreateLabel({ + labelId: label.labelId, + name: label.name, + color: label.color + }); + setlabelsSearchQuery(""); + } + return ( - + {labelSearchQuery.trim().length > 0 ? ( - <> - {t("createNewLabel", { - label: labelSearchQuery.trim() - })} - +
+ + {t("createNewLabel", { + label: labelSearchQuery.trim() + })} + + +
+ + + + + +
+
) : ( t("labelsNotFound") )} From 2fd519e102d8fba6d7414db910ef4baed0ddcc2f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 8 May 2026 22:31:36 +0200 Subject: [PATCH 10/44] =?UTF-8?q?=E2=9C=A8=20add=20and=20toggle=20site=20l?= =?UTF-8?q?abels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/external.ts | 2 +- server/routers/site/listSites.ts | 2 +- src/app/[orgId]/settings/sites/page.tsx | 1 + src/components/SitesTable.tsx | 187 +++++++++++++++--------- src/components/labels-selector.tsx | 68 +++++---- 5 files changed, 156 insertions(+), 104 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 5b146da18..5e20f6db6 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -765,7 +765,7 @@ authenticated.put( labels.attachLabelToItem ); -authenticated.delete( +authenticated.put( "/org/:orgId/label/:labelId/detach", verifyValidLicense, verifyOrgAccess, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 8c35a2521..ac1942574 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -392,7 +392,7 @@ export async function listSites( .select({ labelId: labels.labelId, name: labels.name, - color: labels.name, + color: labels.color, siteId: siteLabels.siteId }) .from(labels) diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 631baee41..6542959a3 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -60,6 +60,7 @@ export default async function SitesPage(props: SitesPageProps) { return { name: site.name, id: site.siteId, + labels: site.labels, nice: site.niceId.toString(), address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index a50bc8b20..57e9ea8a9 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -3,6 +3,16 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { @@ -14,9 +24,9 @@ import { import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; -import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { type PaginationState } from "@tanstack/react-table"; import { @@ -27,32 +37,30 @@ import { ChevronDown, ChevronsUpDownIcon, MoreHorizontal, - PlusIcon + PlusIcon, + XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useState, useTransition, useEffect } from "react"; +import { + startTransition, + useEffect, + useOptimistic, + useState, + useTransition +} from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; -import { - Credenza, - CredenzaBody, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; -import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; + +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { LabelsSelector } from "./labels-selector"; +import { cn } from "@app/lib/cn"; export type SiteRow = { id: number; @@ -463,36 +471,7 @@ export default function SitesTable({ accessorKey: "labels", header: () => {t("labels")}, cell: ({ row }) => { - const labels = row.original.labels ?? []; - return ( -
- - - - - - {}} - /> - - -
- ); + return ; } }, { @@ -653,12 +632,6 @@ export default function SitesTable({ string={selectedSite.name} title={t("siteDelete")} /> - - {/* */} )} @@ -696,36 +669,104 @@ export default function SitesTable({ ); } -type SiteLabelsDialogProps = { +type SiteLabelCellProps = { site: SiteRow; - isOpen: boolean; - setIsOpen: (open: boolean) => void; + orgId: string; }; -function SiteLabelsDialog({ site, isOpen, setIsOpen }: SiteLabelsDialogProps) { +function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const router = useRouter(); + + const labels = site.labels ?? []; + const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); + + function toggleSiteLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + startTransition(async () => { + try { + if (action === "attach") { + setOptimisticLabels([...optimisticLabels, label]); + + await api.put( + `/org/${orgId}/label/${label.labelId}/attach`, + { siteId: site.id } + ); + } else { + setOptimisticLabels( + optimisticLabels.filter( + (lb) => lb.labelId !== label.labelId + ) + ); + await api.put( + `/org/${orgId}/label/${label.labelId}/detach`, + { siteId: site.id } + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } finally { + router.refresh(); + } + }); + } + return ( - - - - {t("siteLabelsTab")} - - {t("siteLabelsDescription")} - - - - <> - - +
+ {optimisticLabels.map((label) => ( + + ))} + + - - - + + + + + +
); } diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index ec8d7f270..64a80b26a 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -21,12 +21,13 @@ import { SelectTrigger, SelectValue } from "./ui/select"; -import { createApiClient } from "@app/lib/api"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { AxiosResponse } from "axios"; +import { toast } from "@app/hooks/useToast"; -type SelectedLabel = { +export type SelectedLabel = { name: string; color: string; labelId: number; @@ -35,8 +36,7 @@ type SelectedLabel = { export type LabelsSelectorProps = { orgId: string; selectedLabels: SelectedLabel[]; - onSelectionChange: (sites: SelectedLabel[]) => void; - onCreateLabel: (newlabel: SelectedLabel) => Promise; + toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; }; const LABEL_COLORS = { @@ -52,8 +52,7 @@ const LABEL_COLORS = { export function LabelsSelector({ orgId, selectedLabels, - onSelectionChange, - onCreateLabel + toggleLabel }: LabelsSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); @@ -94,17 +93,28 @@ export function LabelsSelector({ async function createLabel(_: any, formData: FormData) { const name = formData.get("name")?.toString(); const color = formData.get("color")?.toString(); - const res = await api.post>( - `/org/${orgId}/labels`, - { name, color } - ); + try { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/labels`, { name, color }); - const { label } = res.data.data; - await onCreateLabel({ - labelId: label.labelId, - name: label.name, - color: label.color - }); + const { label } = res.data.data; + + toggleLabel( + { + labelId: label.labelId, + name: label.name, + color: label.color + }, + "attach" + ); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } setlabelsSearchQuery(""); } @@ -185,18 +195,18 @@ export function LabelsSelector({ key={label.labelId} value={`${label.labelId}`} onSelect={() => { - if (selectedIds.has(label.labelId)) { - onSelectionChange( - selectedLabels.filter( - (l) => l.labelId !== label.labelId - ) - ); - } else { - onSelectionChange([ - ...selectedLabels, - label - ]); - } + toggleLabel( + label, + selectedIds.has(label.labelId) + ? "detach" + : "attach" + ); + // } else { + // onSelectionChange([ + // ...selectedLabels, + // label + // ]); + // } }} >
Date: Mon, 11 May 2026 16:57:53 +0200 Subject: [PATCH 11/44] =?UTF-8?q?=F0=9F=90=9B=20handle=20idempotency=20whe?= =?UTF-8?q?n=20adding/removing=20labels=20from=20sites/resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 60 +++++++++++-------- .../routers/labels/attachLabelToItem.ts | 15 +++-- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 0a4066db3..a797e3ddc 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -173,33 +173,41 @@ export const labels = pgTable("labels", { .notNull() }); -export const siteLabels = pgTable("siteLabels", { - siteLabelId: serial("siteLabelId").primaryKey(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - labelId: integer("labelId") - .references(() => labels.labelId, { - onDelete: "cascade" - }) - .notNull() -}); +export const siteLabels = pgTable( + "siteLabels", + { + siteLabelId: serial("siteLabelId").primaryKey(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("site_label_uniq").on(t.siteId, t.labelId)] +); -export const resourceLabels = pgTable("resourceLabels", { - resourceLabelId: serial("resourceLabelId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - labelId: integer("labelId") - .references(() => labels.labelId, { - onDelete: "cascade" - }) - .notNull() -}); +export const resourceLabels = pgTable( + "resourceLabels", + { + resourceLabelId: serial("resourceLabelId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)] +); export const targets = pgTable("targets", { targetId: serial("targetId").primaryKey(), diff --git a/server/private/routers/labels/attachLabelToItem.ts b/server/private/routers/labels/attachLabelToItem.ts index 392332776..79ea360de 100644 --- a/server/private/routers/labels/attachLabelToItem.ts +++ b/server/private/routers/labels/attachLabelToItem.ts @@ -106,13 +106,14 @@ export async function attachLabelToItem( ); } + // idempotent, calling this endpoint multiple times should attach the label only once await db .insert(siteLabels) .values({ labelId, siteId }) - .returning(); + .onConflictDoNothing(); } if (resourceId) { @@ -133,10 +134,14 @@ export async function attachLabelToItem( ); } - await db.insert(resourceLabels).values({ - labelId, - resourceId - }); + // idempotent, calling this endpoint multiple times should attach the label only once + await db + .insert(resourceLabels) + .values({ + labelId, + resourceId + }) + .onConflictDoNothing(); } return response(res, { From c44c02b8ba1e2f9f1764ba871beb720b0b439150 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 17:04:44 +0200 Subject: [PATCH 12/44] =?UTF-8?q?=F0=9F=92=84=20make=20site=20labels=20col?= =?UTF-8?q?umn=20design=20nicer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 57e9ea8a9..f75184a00 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -469,7 +469,11 @@ export default function SitesTable({ // The label feature should be added to the tiers { accessorKey: "labels", - header: () => {t("labels")}, + header: () => ( + + {t("labels")} + + ), cell: ({ row }) => { return ; } @@ -679,6 +683,8 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { const api = createApiClient(useEnvContext()); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const router = useRouter(); const labels = site.labels ?? []; @@ -722,7 +728,7 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { return (
- {optimisticLabels.map((label) => ( + {optimisticLabels.slice(0, 3).map((label) => ( ))} - + {optimisticLabels.length > 3 && ( + + )} +
From 8a0c2031d4af408ea8bfa19fa44d0569cb645710 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:02:59 +0200 Subject: [PATCH 15/44] =?UTF-8?q?=E2=9C=A8=20search=20list=20by=20labels?= =?UTF-8?q?=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/listSites.ts | 100 +++++++++++++++++++------------ 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index ac1942574..829379412 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -190,9 +190,9 @@ const listSitesSchema = z.object({ }) }); -function querySitesBase() { - return db - .select({ +function querySitesBase(isLabelFeatureEnabled: boolean) { + let query = db + .selectDistinct({ siteId: sites.siteId, niceId: sites.niceId, name: sites.name, @@ -231,6 +231,14 @@ function querySitesBase() { remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ); + + if (isLabelFeatureEnabled) { + query = query + .leftJoin(siteLabels, eq(siteLabels.siteId, sites.siteId)) + .leftJoin(labels, eq(labels.labelId, siteLabels.labelId)); + } + + return query; } type SiteRowBase = Awaited>[0]; @@ -314,6 +322,11 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + const { pageSize, page, query, sort_by, order, online, status } = parsedQuery.data; @@ -325,31 +338,43 @@ export async function listSites( eq(sites.orgId, orgId) ) ]; - if (query) { - conditions.push( - or( - like( - sql`LOWER(${sites.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${sites.niceId})`, - "%" + query.toLowerCase() + "%" - ) - ) - ); - } + if (typeof online !== "undefined") { conditions.push(eq(sites.online, online)); } if (typeof status !== "undefined") { conditions.push(eq(sites.status, status)); } - const baseQuery = querySitesBase().where(and(...conditions)); + if (query) { + const queryList = [ + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.niceId})`, + "%" + query.toLowerCase() + "%" + ) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( + like( + sql`LOWER(${labels.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + conditions.push(or(...queryList)); + } + + const baseQuery = querySitesBase(isLabelFeatureEnabled).where( + and(...conditions) + ); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase() + querySitesBase(isLabelFeatureEnabled) .where(and(...conditions)) .as("filtered_sites") ); @@ -382,25 +407,24 @@ export async function listSites( siteId: number; }> = []; - // The label feature should be added in the tiers - // if (await isLicensedOrSubscribed(orgId, tierMatrix.fullRbac)) { - // } - labelsForSites = - siteIds.length === 0 - ? [] - : await db - .select({ - labelId: labels.labelId, - name: labels.name, - color: labels.color, - siteId: siteLabels.siteId - }) - .from(labels) - .innerJoin( - siteLabels, - eq(siteLabels.labelId, labels.labelId) - ) - .where(inArray(siteLabels.siteId, siteIds)); + if (isLabelFeatureEnabled) { + labelsForSites = + siteIds.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + siteId: siteLabels.siteId + }) + .from(labels) + .innerJoin( + siteLabels, + eq(siteLabels.labelId, labels.labelId) + ) + .where(inArray(siteLabels.siteId, siteIds)); + } const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; From 21f72639b69325afab8c6440f771d02f7ad14cd0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:13:19 +0200 Subject: [PATCH 16/44] =?UTF-8?q?=F0=9F=9A=A7=20make=20labels=20column=20p?= =?UTF-8?q?aid,=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 109 +++++++++++++++------------------- 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index f75184a00..2dd793841 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -37,8 +37,7 @@ import { ChevronDown, ChevronsUpDownIcon, MoreHorizontal, - PlusIcon, - XIcon + PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -58,9 +57,11 @@ import { type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { cn } from "@app/lib/cn"; import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { cn } from "@app/lib/cn"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export type SiteRow = { id: number; @@ -110,10 +111,12 @@ export default function SitesTable({ const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); - const [isLabelsDialogOpen, setIsLabelsDialogOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -466,18 +469,26 @@ export default function SitesTable({ ); } }, - // The label feature should be added to the tiers - { - accessorKey: "labels", - header: () => ( - - {t("labels")} - - ), - cell: ({ row }) => { - return ; - } - }, + ...(isLabelFeatureEnabled + ? [ + { + accessorKey: "labels", + header: () => ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: SiteRow } }) => { + return ( + + ); + } + } + ] + : []), { id: "actions", enableHiding: false, @@ -518,24 +529,6 @@ export default function SitesTable({ {t("sitesTableViewPrivateResources")} - { - setSelectedSite(siteRow); - setIsLabelsDialogOpen(true); - }} - > - {t("addLabels")} - - { - setSelectedSite(siteRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - {selectedSite && ( - <> - { - setIsDeleteModalOpen(val); - setSelectedSite(null); - }} - dialog={ -
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

-
- } - buttonText={t("siteConfirmDelete")} - onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) - } - string={selectedSite.name} - title={t("siteDelete")} - /> - + { + setIsDeleteModalOpen(val); + setSelectedSite(null); + }} + dialog={ +
+

{t("siteQuestionRemove")}

+

{t("siteMessageRemove")}

+
+ } + buttonText={t("siteConfirmDelete")} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } + string={selectedSite.name} + title={t("siteDelete")} + /> )} toggleSiteLabel(label, "detach")} + onClick={() => setIsPopoverOpen(true)} className={cn( "inline-flex gap-1 items-center", "rounded-full text-sm cursor-pointer", - "px-1.5 py-0 h-auto" + "pl-1.5 pr-2 py-0 h-auto" )} >
- + {label.name} - - ))} {optimisticLabels.length > 3 && ( From 6e066d38b096236ebcab0a0631730fa6df147fe8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:17:29 +0200 Subject: [PATCH 17/44] =?UTF-8?q?=F0=9F=9A=9A=20Make=20label=20badge=20its?= =?UTF-8?q?=20own=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 27 +++++------------------ src/components/label-badge.tsx | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 src/components/label-badge.tsx diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 2dd793841..b8064c933 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -57,11 +57,12 @@ import { type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { cn } from "@app/lib/cn"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { LabelBadge } from "./label-badge"; import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; export type SiteRow = { id: number; @@ -720,27 +721,11 @@ function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { return (
{optimisticLabels.slice(0, 3).map((label) => ( - + {...label} + /> ))} {optimisticLabels.length > 3 && ( + ); +} From 14e1a119d3c313158a19d65981e96fc01cce8565 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:24:47 +0200 Subject: [PATCH 18/44] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20showing=20labels=20?= =?UTF-8?q?in=20proxy=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 36 +-- src/components/ProxyResourcesTable.tsx | 320 +++++++++++++---------- 2 files changed, 195 insertions(+), 161 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 16a82e400..ab2e41204 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -325,24 +325,6 @@ export async function listResources( ) ]; - if (query) { - conditions.push( - or( - like( - sql`LOWER(${resources.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.niceId})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.fullDomain})`, - "%" + query.toLowerCase() + "%" - ) - ) - ); - } if (typeof enabled !== "undefined") { conditions.push(eq(resources.enabled, enabled)); } @@ -386,6 +368,24 @@ export async function listResources( .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); conditions.push(inArray(resources.resourceId, resourcesWithSite)); } + if (query) { + conditions.push( + or( + like( + sql`LOWER(${resources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.fullDomain})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } const baseQuery = queryResourcesBase().where(and(...conditions)); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 98ddd8eb7..21a770a68 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -2,10 +2,12 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; @@ -24,12 +26,14 @@ import { import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; @@ -64,8 +68,6 @@ import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import UptimeMiniBar from "./UptimeMiniBar"; -import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; -import { build } from "@server/build"; export type TargetHealth = { targetId: number; @@ -97,31 +99,13 @@ export type ResourceRow = { health?: "healthy" | "degraded" | "unhealthy" | "unknown"; sites: ResourceSiteRow[]; wildcard?: boolean; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; -function StatusIcon({ - status, - className = "" -}: { - status: string | undefined | null; - className?: string; -}) { - const iconClass = `h-4 w-4 ${className}`; - - switch (status) { - case "healthy": - return ; - case "degraded": - return ; - case "unhealthy": - return ; - case "unknown": - return ; - default: - return null; - } -} - type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; @@ -153,6 +137,9 @@ export default function ProxyResourcesTable({ const [selectedResource, setSelectedResource] = useState(); + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); const [siteFilterOpen, setSiteFilterOpen] = useState(false); @@ -233,120 +220,6 @@ export default function ProxyResourcesTable({ } } - function TargetStatusCell({ - targets, - healthStatus - }: { - targets?: TargetHealth[]; - healthStatus?: string; - }) { - const overallStatus = healthStatus; - - if (!targets || targets.length === 0) { - return ( -
- - - {t("resourcesTableNoTargets")} - -
- ); - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - const unknownTargets = targets.filter( - (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" - ); - - return ( - - - - - - {monitoredTargets.length > 0 && ( - <> - {monitoredTargets.map((target) => ( - -
- - {target.siteName - ? `${target.siteName} (${target.ip}:${target.port})` - : `${target.ip}:${target.port}`} -
- - {target.healthStatus} - -
- ))} - - )} - {unknownTargets.length > 0 && ( - <> - {unknownTargets.map((target) => ( - -
- - {target.siteName - ? `${target.siteName} (${target.ip}:${target.port})` - : `${target.ip}:${target.port}`} -
- - {!target.enabled - ? t("disabled") - : t("resourcesTableNotMonitored")} - -
- ))} - - )} -
-
- ); - } - const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -653,6 +526,28 @@ export default function ProxyResourcesTable({ /> ) }, + ...(isLabelFeatureEnabled + ? [ + { + id: "labels", + accessorKey: "labels", + header: () => ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: ResourceRow } }) => { + return ( + // + <> + ); + } + } + ] + : []), { id: "actions", enableHiding: false, @@ -800,7 +695,11 @@ export default function ProxyResourcesTable({ isRefreshing={isRefreshing || isFiltering} isNavigatingToAddPage={isNavigatingToAddPage} enableColumnVisibility - columnVisibility={{ niceId: false, protocol: false }} + columnVisibility={{ + niceId: false, + protocol: false, + labels: false + }} stickyLeftColumn="name" stickyRightColumn="actions" /> @@ -808,6 +707,118 @@ export default function ProxyResourcesTable({ ); } +function TargetStatusCell({ + targets, + healthStatus +}: { + targets?: TargetHealth[]; + healthStatus?: string; +}) { + const overallStatus = healthStatus; + const t = useTranslations(); + + if (!targets || targets.length === 0) { + return ( +
+ + {t("resourcesTableNoTargets")} +
+ ); + } + + const monitoredTargets = targets.filter( + (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" + ); + const unknownTargets = targets.filter( + (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" + ); + + return ( + + + + + + {monitoredTargets.length > 0 && ( + <> + {monitoredTargets.map((target) => ( + +
+ + {target.siteName + ? `${target.siteName} (${target.ip}:${target.port})` + : `${target.ip}:${target.port}`} +
+ + {target.healthStatus} + +
+ ))} + + )} + {unknownTargets.length > 0 && ( + <> + {unknownTargets.map((target) => ( + +
+ + {target.siteName + ? `${target.siteName} (${target.ip}:${target.port})` + : `${target.ip}:${target.port}`} +
+ + {!target.enabled + ? t("disabled") + : t("resourcesTableNotMonitored")} + +
+ ))} + + )} +
+
+ ); +} + type ResourceEnabledFormProps = { resource: ResourceRow; onToggleResourceEnabled: ( @@ -847,3 +858,26 @@ function ResourceEnabledForm({ ); } + +function StatusIcon({ + status, + className = "" +}: { + status: string | undefined | null; + className?: string; +}) { + const iconClass = `h-4 w-4 ${className}`; + + switch (status) { + case "healthy": + return ; + case "degraded": + return ; + case "unhealthy": + return ; + case "unknown": + return ; + default: + return null; + } +} From a0759a79a1616c8b5959e03a14c8f543882cd291 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:28:40 +0200 Subject: [PATCH 19/44] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20add=20unique=20in?= =?UTF-8?q?dexes=20to=20site=20&=20resource=20labels=20in=20sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/sqlite/schema/schema.ts | 64 +++++++++++++++++-------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3695e29a0..924581120 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -194,35 +194,43 @@ export const labels = sqliteTable("labels", { .notNull() }); -export const siteLabels = sqliteTable("siteLabels", { - siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - labelId: integer("labelId") - .references(() => labels.labelId, { - onDelete: "cascade" - }) - .notNull() -}); +export const siteLabels = sqliteTable( + "siteLabels", + { + siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("site_label_uniq").on(t.siteId, t.labelId)] +); -export const resourceLabels = sqliteTable("resourceLabels", { - resourceLabelId: integer("resourceLabelId").primaryKey({ - autoIncrement: true - }), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - labelId: integer("labelId") - .references(() => labels.labelId, { - onDelete: "cascade" - }) - .notNull() -}); +export const resourceLabels = sqliteTable( + "resourceLabels", + { + resourceLabelId: integer("resourceLabelId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)] +); export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), From 549e1ead1dd3a6e86e3346fab1bcb110b319ecc5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:30:23 +0200 Subject: [PATCH 20/44] =?UTF-8?q?=E2=9C=A8=20handle=20labels=20in=20resour?= =?UTF-8?q?ces=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 112 ++++++++++++++---- .../[orgId]/settings/resources/proxy/page.tsx | 1 + 2 files changed, 88 insertions(+), 25 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index ab2e41204..6756d8657 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,7 +1,9 @@ import { db, + labels, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceLabels, resourcePassword, resourcePincode, resources, @@ -9,8 +11,11 @@ import { sites, targetHealthCheck, targets, - userResources + userResources, + type Label } from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -154,10 +159,11 @@ export type ResourceWithTargets = { siteNiceId: string; online?: boolean; // undefined for local sites }>; + labels?: Array>; }; -function queryResourcesBase() { - return db +function queryResourcesBase(isLabelFeatureEnabled: boolean) { + let query = db .select({ resourceId: resources.resourceId, name: resources.name, @@ -203,14 +209,24 @@ function queryResourcesBase() { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) - ) - .groupBy( - resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); + + if (isLabelFeatureEnabled) { + query = query + .leftJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(labels, eq(labels.labelId, resourceLabels.labelId)); + } + + return query.groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + ); } export type ListResourcesResponse = PaginatedResponse<{ @@ -288,6 +304,11 @@ export async function listResources( ); } + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db @@ -369,25 +390,34 @@ export async function listResources( conditions.push(inArray(resources.resourceId, resourcesWithSite)); } if (query) { - conditions.push( - or( + const queryList = [ + like( + sql`LOWER(${resources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.fullDomain})`, + "%" + query.toLowerCase() + "%" + ) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( like( - sql`LOWER(${resources.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.niceId})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.fullDomain})`, + sql`LOWER(${labels.name})`, "%" + query.toLowerCase() + "%" ) - ) - ); + ); + } + + conditions.push(or(...queryList)); } - const baseQuery = queryResourcesBase().where(and(...conditions)); + const baseQuery = queryResourcesBase(isLabelFeatureEnabled).where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); @@ -407,6 +437,35 @@ export async function listResources( ]); const resourceIdList = rows.map((row) => row.resourceId); + + let labelsForResources: Array<{ + labelId: number; + name: string; + color: string; + resourceId: number; + }> = []; + + if (isLabelFeatureEnabled) { + labelsForResources = + resourceIdList.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + resourceId: resourceLabels.resourceId + }) + .from(labels) + .innerJoin( + resourceLabels, + eq(resourceLabels.labelId, labels.labelId) + ) + .where( + inArray(resourceLabels.resourceId, resourceIdList) + ); + } + const allResourceTargets = resourceIdList.length === 0 ? [] @@ -458,7 +517,10 @@ export async function listResources( headerAuthId: row.headerAuthId, health: row.health ?? null, targets: [], - sites: [] + sites: [], + labels: labelsForResources.filter( + (l) => l.resourceId === row.resourceId + ) }; map.set(row.resourceId, entry); } diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index b94c4daf5..8d79947b7 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -111,6 +111,7 @@ export default async function ProxyResourcesPage( protocol: resource.protocol, proxyPort: resource.proxyPort, http: resource.http, + labels: resource.labels, authState: !resource.http ? "none" : resource.sso || From ab494521b1a2bf05b3c6b2b1acdab8535cbbae8d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 18:37:16 +0200 Subject: [PATCH 21/44] =?UTF-8?q?=E2=9C=A8=20labels=20on=20proxy=20resourc?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 7 +- server/routers/site/listSites.ts | 3 +- src/components/ProxyResourcesTable.tsx | 112 ++++++++++++++++++++++- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 6756d8657..c6d7d036d 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -417,7 +417,9 @@ export async function listResources( conditions.push(or(...queryList)); } - const baseQuery = queryResourcesBase(isLabelFeatureEnabled).where(and(...conditions)); + const baseQuery = queryResourcesBase(isLabelFeatureEnabled).where( + and(...conditions) + ); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); @@ -463,7 +465,8 @@ export async function listResources( ) .where( inArray(resourceLabels.resourceId, resourceIdList) - ); + ) + .orderBy(asc(resourceLabels.resourceLabelId)); } const allResourceTargets = diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 829379412..99f931bb9 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -423,7 +423,8 @@ export async function listSites( siteLabels, eq(siteLabels.labelId, labels.labelId) ) - .where(inArray(siteLabels.siteId, siteIds)); + .where(inArray(siteLabels.siteId, siteIds)) + .orderBy(asc(siteLabels.siteLabelId)); } const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 21a770a68..164171a70 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -47,6 +47,7 @@ import { Clock, Funnel, MoreHorizontal, + PlusIcon, ShieldCheck, ShieldOff, XCircle @@ -55,6 +56,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { + startTransition, useEffect, useMemo, useOptimistic, @@ -68,6 +70,8 @@ import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import UptimeMiniBar from "./UptimeMiniBar"; +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; +import { LabelBadge } from "./label-badge"; export type TargetHealth = { targetId: number; @@ -538,11 +542,10 @@ export default function ProxyResourcesTable({ ), cell: ({ row }: { row: { original: ResourceRow } }) => { return ( - // - <> + ); } } @@ -707,6 +710,105 @@ export default function ProxyResourcesTable({ ); } +type ResourceLabelCellProps = { + resource: ResourceRow; + orgId: string; +}; + +function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) { + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const router = useRouter(); + + const labels = resource.labels ?? []; + const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); + + function toggleSiteLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + startTransition(async () => { + try { + if (action === "attach") { + setOptimisticLabels([...optimisticLabels, label]); + + await api.put( + `/org/${orgId}/label/${label.labelId}/attach`, + { resourceId: resource.id } + ); + } else { + setOptimisticLabels( + optimisticLabels.filter( + (lb) => lb.labelId !== label.labelId + ) + ); + await api.put( + `/org/${orgId}/label/${label.labelId}/detach`, + { resourceId: resource.id } + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } finally { + router.refresh(); + } + }); + } + + return ( +
+ {optimisticLabels.slice(0, 3).map((label) => ( + setIsPopoverOpen(true)} + {...label} + /> + ))} + {optimisticLabels.length > 3 && ( + + )} + + + + + + + + +
+ ); +} + function TargetStatusCell({ targets, healthStatus From 3855486a00fdf61966a0f7a232a7e49945681777 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 19:27:00 +0200 Subject: [PATCH 22/44] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20prevent=20`Sitetable?= =?UTF-8?q?Cell`=20from=20rerendering=20unnecessarily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 43 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index b8064c933..dfef6d6a1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -45,6 +45,7 @@ import { usePathname, useRouter } from "next/navigation"; import { startTransition, useEffect, + useMemo, useOptimistic, useState, useTransition @@ -180,7 +181,8 @@ export default function SitesTable({ }); } - const columns: ExtendedColumnDef[] = [ + const columns = useMemo[]>(() => { + const cols: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, @@ -470,26 +472,6 @@ export default function SitesTable({ ); } }, - ...(isLabelFeatureEnabled - ? [ - { - accessorKey: "labels", - header: () => ( - - {t("labels")} - - ), - cell: ({ row }: { row: { original: SiteRow } }) => { - return ( - - ); - } - } - ] - : []), { id: "actions", enableHiding: false, @@ -544,7 +526,24 @@ export default function SitesTable({ ); } } - ]; + ]; + + if (isLabelFeatureEnabled) { + cols.splice(cols.length - 1, 0, { + accessorKey: "labels", + header: () => ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: SiteRow } }) => ( + + ) + }); + } + + return cols; + }, [isLabelFeatureEnabled, orgId, t, searchParams]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From d321d7275c426ebecd9b72c297c87d32c4b41656 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 11 May 2026 21:06:20 +0200 Subject: [PATCH 23/44] =?UTF-8?q?=F0=9F=9A=A7=20=20tried=20to=20memo=20pro?= =?UTF-8?q?xy=20resource=20table,=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProxyResourcesTable.tsx | 763 +++++++++++++------------ 1 file changed, 399 insertions(+), 364 deletions(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 164171a70..a8934b041 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -118,6 +118,11 @@ type ProxyResourcesTableProps = { initialFilterSite?: Selectedsite | null; }; +const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + export default function ProxyResourcesTable({ resources, orgId, @@ -224,389 +229,429 @@ export default function ProxyResourcesTable({ } } - const proxyColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: t("name"), - header: () => { - const nameOrder = getSortDirection("name", searchParams); - const Icon = - nameOrder === "asc" - ? ArrowDown01Icon - : nameOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; - return ( - - ); - } - }, - { - id: "niceId", - accessorKey: "nice", - friendlyName: t("identifier"), - enableHiding: true, - header: () => {t("identifier")}, - cell: ({ row }) => { - return {row.original.nice || "-"}; - } - }, - { - id: "sites", - accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), - friendlyName: t("sites"), - header: () => ( - - + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + + const siteFilterOpenRef = useRef(siteFilterOpen); + siteFilterOpenRef.current = siteFilterOpen; + + const selectedSiteRef = useRef(selectedSite); + selectedSiteRef.current = selectedSite; + + const clearSiteFilterRef = useRef(clearSiteFilter); + clearSiteFilterRef.current = clearSiteFilter; + + const onPickSiteRef = useRef(onPickSite); + onPickSiteRef.current = onPickSite; + + const proxyColumns = useMemo[]>(() => { + const cols: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( - - {t("identifier")}, + cell: ({ row }) => { + return {row.original.nice || "-"}; + } + }, + { + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", "), + friendlyName: t("sites"), + header: () => ( + -
+ -
- -
-
- ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "protocol", - friendlyName: t("protocol"), - enableHiding: true, - header: () => {t("protocol")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - {resourceRow.http - ? resourceRow.ssl - ? "HTTPS" - : "HTTP" - : resourceRow.protocol.toUpperCase()} - - ); - } - }, - { - id: "status", - accessorKey: "status", - friendlyName: t("health"), - header: () => ( - - handleFilterChange("healthStatus", value) - } - searchPlaceholder={t("searchPlaceholder")} - emptyMessage={t("emptySearchOptions")} - label={t("health")} - className="p-3" - /> - ), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - + +
+ +
+ + onPickSiteRef.current(site) + } + /> +
+ + ), + cell: ({ row }) => ( + - ); + ) }, - sortingFn: (rowA, rowB) => { - const statusA = rowA.original.health; - const statusB = rowB.original.health; - if (!statusA && !statusB) return 0; - if (!statusA) return 1; - if (!statusB) return -1; - const statusOrder = { - healthy: 3, - degraded: 2, - unhealthy: 1, - unknown: 0 - }; - return statusOrder[statusA] - statusOrder[statusB]; - } - }, - { - id: "statusHistory", - friendlyName: t("uptime30d"), - header: () => {t("uptime30d")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ; - } - }, - { - accessorKey: "domain", - friendlyName: t("access"), - header: () => {t("access")}, - cell: ({ row }) => { - const resourceRow = row.original; - - if (!resourceRow.http) { + { + accessorKey: "protocol", + friendlyName: t("protocol"), + enableHiding: true, + header: () => {t("protocol")}, + cell: ({ row }) => { + const resourceRow = row.original; return ( -
- -
+ + {resourceRow.http + ? resourceRow.ssl + ? "HTTPS" + : "HTTP" + : resourceRow.protocol.toUpperCase()} + ); } - - if (!resourceRow.domainId) { + }, + { + id: "status", + accessorKey: "status", + friendlyName: t("health"), + header: () => ( + + handleFilterChange("healthStatus", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("health")} + className="p-3" + /> + ), + cell: ({ row }) => { + const resourceRow = row.original; return ( -
- -
+ + ); + }, + sortingFn: (rowA, rowB) => { + const statusA = rowA.original.health; + const statusB = rowB.original.health; + if (!statusA && !statusB) return 0; + if (!statusA) return 1; + if (!statusB) return -1; + const statusOrder = { + healthy: 3, + degraded: 2, + unhealthy: 1, + unknown: 0 + }; + return statusOrder[statusA] - statusOrder[statusB]; + } + }, + { + id: "statusHistory", + friendlyName: t("uptime30d"), + header: () => {t("uptime30d")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( + ); } + }, + { + accessorKey: "domain", + friendlyName: t("access"), + header: () => {t("access")}, + cell: ({ row }) => { + const resourceRow = row.original; - const domainId = resourceRow.domainId; - const certHostname = resourceRow.fullDomain; - const showHttpsCertIndicator = - build !== "oss" && - resourceRow.ssl && - certHostname != null && - certHostname !== ""; - - return ( -
- {showHttpsCertIndicator ? ( - - ) : null} -
- {!resourceRow.wildcard ? ( + if (!resourceRow.http) { + return ( +
+
+ ); + } + + if (!resourceRow.domainId) { + return ( +
+ +
+ ); + } + + const domainId = resourceRow.domainId; + const certHostname = resourceRow.fullDomain; + const showHttpsCertIndicator = + build !== "oss" && + resourceRow.ssl && + certHostname != null && + certHostname !== ""; + + return ( +
+ {showHttpsCertIndicator ? ( + + ) : null} +
+ {!resourceRow.wildcard ? ( + + ) : ( + {resourceRow.domain} + )} +
+
+ ); + } + }, + { + accessorKey: "authState", + friendlyName: t("authentication"), + header: () => ( + + handleFilterChange("authState", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("authentication")} + className="p-3" + /> + ), + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {resourceRow.authState === "protected" ? ( + + + {t("protected")} + + ) : resourceRow.authState === "not_protected" ? ( + + + {t("notProtected")} + ) : ( - {resourceRow.domain} + - )}
-
- ); - } - }, - { - accessorKey: "authState", - friendlyName: t("authentication"), - header: () => ( - - handleFilterChange("authState", value) - } - searchPlaceholder={t("searchPlaceholder")} - emptyMessage={t("emptySearchOptions")} - label={t("authentication")} - className="p-3" - /> - ), - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {resourceRow.authState === "protected" ? ( - - - {t("protected")} - - ) : resourceRow.authState === "not_protected" ? ( - - - {t("notProtected")} - - ) : ( - - + ); + } + }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => ( + - ); - } - }, - { - accessorKey: "enabled", - friendlyName: t("enabled"), - header: () => ( - - handleFilterChange("enabled", value) - } - searchPlaceholder={t("searchPlaceholder")} - emptyMessage={t("emptySearchOptions")} - label={t("enabled")} - className="p-3" - /> - ), - cell: ({ row }) => ( - - ) - }, - ...(isLabelFeatureEnabled - ? [ - { - id: "labels", - accessorKey: "labels", - header: () => ( - - {t("labels")} - - ), - cell: ({ row }: { row: { original: ResourceRow } }) => { - return ( - - ); - } - } - ] - : []), - { - id: "actions", - enableHiding: false, - header: () => , - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - - - {t("viewSettings")} + onValueChange={(value) => + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedResource(resourceRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - - - -
- ); +
+
+ + + +
+ ); + } } - } - ]; + ]; - const booleanSearchFilterSchema = z - .enum(["true", "false"]) - .optional() - .catch(undefined); + if (isLabelFeatureEnabled) { + cols.splice(cols.length - 1, 0, { + id: "labels", + accessorKey: "labels", + header: () => ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: ResourceRow } }) => ( + + ) + }); + } + + return cols; + }, [isLabelFeatureEnabled, orgId, t, searchParams]); function handleFilterChange( column: string, @@ -623,16 +668,6 @@ export default function ProxyResourcesTable({ }); } - const clearSiteFilter = () => { - handleFilterChange("siteId", undefined); - setSiteFilterOpen(false); - }; - - const onPickSite = (site: Selectedsite) => { - handleFilterChange("siteId", String(site.siteId)); - setSiteFilterOpen(false); - }; - function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From 931ba0f5401ce5f3bd6b7bcac36e5301eaeebe83 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 12 May 2026 17:46:46 +0200 Subject: [PATCH 24/44] =?UTF-8?q?=F0=9F=92=84=20`px-2`=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProxyResourcesTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index a8934b041..51c3254b6 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -856,7 +856,7 @@ function TargetStatusCell({ if (!targets || targets.length === 0) { return ( -
+
{t("resourcesTableNoTargets")}
@@ -876,7 +876,7 @@ function TargetStatusCell({ - ); - } - }, - { - id: "niceId", - accessorKey: "niceId", - friendlyName: t("identifier"), - enableHiding: true, - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - return {row.original.niceId || "-"}; - } - }, - { - id: "sites", - accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), - friendlyName: t("sites"), - header: () => ( - - + return ( - - { + return ( + + ); + }, + cell: ({ row }) => { + return {row.original.niceId || "-"}; + } + }, + { + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", "), + friendlyName: t("sites"), + header: () => ( + -
+ -
- + +
+ +
+ +
+
+ ), + cell: ({ row }) => { + const resourceRow = row.original; + return ( + -
-
- ), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - ); - } - }, - { - accessorKey: "mode", - friendlyName: t("editInternalResourceDialogMode"), - header: () => ( - ( + + handleFilterChange("mode", value) } - ]} - selectedValue={searchParams.get("mode") ?? undefined} - onValueChange={(value) => handleFilterChange("mode", value)} - searchPlaceholder={t("searchPlaceholder")} - emptyMessage={t("emptySearchOptions")} - label={t("editInternalResourceDialogMode")} - className="p-3" - /> - ), - cell: ({ row }) => { - const resourceRow = row.original; - const modeLabels: Record< - "host" | "cidr" | "port" | "http", - string - > = { - host: t("editInternalResourceDialogModeHost"), - cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort"), - http: t("editInternalResourceDialogModeHttp") - }; - return {modeLabels[resourceRow.mode]}; - } - }, - { - accessorKey: "destination", - friendlyName: t("resourcesTableDestination"), - header: () => ( - {t("resourcesTableDestination")} - ), - cell: ({ row }) => { - const resourceRow = row.original; - const display = formatDestinationDisplay(resourceRow); - return ( - - ); - } - }, - { - accessorKey: "alias", - friendlyName: t("resourcesTableAlias"), - header: () => ( - {t("resourcesTableAlias")} - ), - cell: ({ row }) => { - const resourceRow = row.original; - if (resourceRow.mode === "host" && resourceRow.alias) { + ), + cell: ({ row }) => { + const resourceRow = row.original; + const modeLabels: Record< + "host" | "cidr" | "port" | "http", + string + > = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + port: t("editInternalResourceDialogModePort"), + http: t("editInternalResourceDialogModeHttp") + }; + return {modeLabels[resourceRow.mode]}; + } + }, + { + accessorKey: "destination", + friendlyName: t("resourcesTableDestination"), + header: () => ( + + {t("resourcesTableDestination")} + + ), + cell: ({ row }) => { + const resourceRow = row.original; + const display = formatDestinationDisplay(resourceRow); return ( ); } - if (resourceRow.mode === "http") { - const domainId = resourceRow.domainId; - const fullDomain = resourceRow.fullDomain; - const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; - const did = - build !== "oss" && - resourceRow.ssl && - domainId != null && - domainId !== "" && - fullDomain != null && - fullDomain !== ""; + }, + { + accessorKey: "alias", + friendlyName: t("resourcesTableAlias"), + header: () => ( + {t("resourcesTableAlias")} + ), + cell: ({ row }) => { + const resourceRow = row.original; + if (resourceRow.mode === "host" && resourceRow.alias) { + return ( + + ); + } + if (resourceRow.mode === "http") { + const domainId = resourceRow.domainId; + const fullDomain = resourceRow.fullDomain; + const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; + const did = + build !== "oss" && + resourceRow.ssl && + domainId != null && + domainId !== "" && + fullDomain != null && + fullDomain !== ""; - return ( -
- {did ? ( - - ) : null} -
- + return ( +
+ {did ? ( + + ) : null} +
+ +
+ ); + } + return -; + } + }, + { + accessorKey: "aliasAddress", + friendlyName: t("resourcesTableAliasAddress"), + enableHiding: true, + header: () => ( +
+ {t("resourcesTableAliasAddress")} + +
+ ), + cell: ({ row }) => { + const resourceRow = row.original; + return resourceRow.aliasAddress ? ( + + ) : ( + - + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + { + setSelectedInternalResource( + resourceRow + ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
); } - return -; } - }, - { - accessorKey: "aliasAddress", - friendlyName: t("resourcesTableAliasAddress"), - enableHiding: true, - header: () => ( -
- {t("resourcesTableAliasAddress")} - -
- ), - cell: ({ row }) => { - const resourceRow = row.original; - return resourceRow.aliasAddress ? ( - ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: InternalResourceRow } }) => ( + - ) : ( - - - ); - } - }, - { - id: "actions", - enableHiding: false, - header: () => , - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - { - setSelectedInternalResource( - resourceRow - ); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - -
- ); - } + ) + }); } - ]; + + return cols; + }, [isLabelFeatureEnabled, orgId, t, searchParams]); function handleFilterChange( column: string, @@ -638,7 +694,8 @@ export default function ClientResourcesTable({ enableColumnVisibility columnVisibility={{ niceId: false, - aliasAddress: false + aliasAddress: false, + labels: false }} stickyLeftColumn="name" stickyRightColumn="actions" @@ -674,3 +731,101 @@ export default function ClientResourcesTable({ ); } + +type ClientResourceLabelCellProps = { + resource: InternalResourceRow; + orgId: string; +}; + +function ClientResourceLabelCell({ + resource, + orgId +}: ClientResourceLabelCellProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const router = useRouter(); + + const labels = resource.labels ?? []; + const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); + + function toggleResourceLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + startTransition(async () => { + try { + if (action === "attach") { + setOptimisticLabels([...optimisticLabels, label]); + await api.put( + `/org/${orgId}/label/${label.labelId}/attach`, + { siteResourceId: resource.id } + ); + } else { + setOptimisticLabels( + optimisticLabels.filter( + (lb) => lb.labelId !== label.labelId + ) + ); + await api.put( + `/org/${orgId}/label/${label.labelId}/detach`, + { siteResourceId: resource.id } + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } finally { + router.refresh(); + } + }); + } + + return ( +
+ {optimisticLabels.slice(0, 3).map((label) => ( + setIsPopoverOpen(true)} + {...label} + /> + ))} + {optimisticLabels.length > 3 && ( + + )} + + + + + + + + +
+ ); +} From 7120ab4b228b8cc8b73e984413fbfb9e6df30b9b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 12 May 2026 20:45:12 +0200 Subject: [PATCH 30/44] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20filter=20sites=20&?= =?UTF-8?q?=20resources=20by=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/listResources.ts | 61 ++++++++++-------------- server/routers/site/listSites.ts | 44 +++++++---------- 2 files changed, 41 insertions(+), 64 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c6d7d036d..49b7f2b57 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -162,8 +162,8 @@ export type ResourceWithTargets = { labels?: Array>; }; -function queryResourcesBase(isLabelFeatureEnabled: boolean) { - let query = db +function queryResourcesBase() { + return db .select({ resourceId: resources.resourceId, name: resources.name, @@ -209,24 +209,14 @@ function queryResourcesBase(isLabelFeatureEnabled: boolean) { .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) + ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); - - if (isLabelFeatureEnabled) { - query = query - .leftJoin( - resourceLabels, - eq(resourceLabels.resourceId, resources.resourceId) - ) - .leftJoin(labels, eq(labels.labelId, resourceLabels.labelId)); - } - - return query.groupBy( - resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId - ); } export type ListResourcesResponse = PaginatedResponse<{ @@ -390,26 +380,25 @@ export async function listResources( conditions.push(inArray(resources.resourceId, resourcesWithSite)); } if (query) { + const q = "%" + query.toLowerCase() + "%"; const queryList = [ - like( - sql`LOWER(${resources.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.niceId})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${resources.fullDomain})`, - "%" + query.toLowerCase() + "%" - ) + like(sql`LOWER(${resources.name})`, q), + like(sql`LOWER(${resources.niceId})`, q), + like(sql`LOWER(${resources.fullDomain})`, q) ]; if (isLabelFeatureEnabled) { queryList.push( - like( - sql`LOWER(${labels.name})`, - "%" + query.toLowerCase() + "%" + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) ) ); } @@ -417,9 +406,7 @@ export async function listResources( conditions.push(or(...queryList)); } - const baseQuery = queryResourcesBase(isLabelFeatureEnabled).where( - and(...conditions) - ); + const baseQuery = queryResourcesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 99f931bb9..af6514f02 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -190,8 +190,8 @@ const listSitesSchema = z.object({ }) }); -function querySitesBase(isLabelFeatureEnabled: boolean) { - let query = db +function querySitesBase() { + return db .selectDistinct({ siteId: sites.siteId, niceId: sites.niceId, @@ -231,14 +231,6 @@ function querySitesBase(isLabelFeatureEnabled: boolean) { remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ); - - if (isLabelFeatureEnabled) { - query = query - .leftJoin(siteLabels, eq(siteLabels.siteId, sites.siteId)) - .leftJoin(labels, eq(labels.labelId, siteLabels.labelId)); - } - - return query; } type SiteRowBase = Awaited>[0]; @@ -346,37 +338,35 @@ export async function listSites( conditions.push(eq(sites.status, status)); } if (query) { + const q = "%" + query.toLowerCase() + "%"; const queryList = [ - like( - sql`LOWER(${sites.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${sites.niceId})`, - "%" + query.toLowerCase() + "%" - ) + like(sql`LOWER(${sites.name})`, q), + like(sql`LOWER(${sites.niceId})`, q) ]; if (isLabelFeatureEnabled) { queryList.push( - like( - sql`LOWER(${labels.name})`, - "%" + query.toLowerCase() + "%" + inArray( + sites.siteId, + db + .select({ id: siteLabels.siteId }) + .from(siteLabels) + .innerJoin( + labels, + eq(labels.labelId, siteLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) ) ); } conditions.push(or(...queryList)); } - const baseQuery = querySitesBase(isLabelFeatureEnabled).where( - and(...conditions) - ); + const baseQuery = querySitesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase(isLabelFeatureEnabled) - .where(and(...conditions)) - .as("filtered_sites") + querySitesBase().where(and(...conditions)).as("filtered_sites") ); const siteListQuery = baseQuery From ce746a2a218a4747847489908ade4cf8bc26041c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 12 May 2026 22:32:56 +0200 Subject: [PATCH 31/44] =?UTF-8?q?=E2=9C=A8=20Handle=20labels=20for=20machi?= =?UTF-8?q?ne=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 18 +++ server/db/sqlite/schema/schema.ts | 20 +++ .../routers/labels/attachLabelToItem.ts | 43 ++++- .../routers/labels/detachLabelFromItem.ts | 43 ++++- server/routers/client/listClients.ts | 75 +++++++-- .../[orgId]/settings/clients/machine/page.tsx | 3 +- src/components/MachineClientsTable.tsx | 148 +++++++++++++++++- 7 files changed, 320 insertions(+), 30 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 76c842d13..58e78735c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -227,6 +227,24 @@ export const siteResourceLabels = pgTable( (t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)] ); +export const clientLabels = pgTable( + "clientLabels", + { + clientLabelId: serial("clientLabelId").primaryKey(), + clientId: integer("clientId") + .references(() => clients.clientId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] +); + export const targets = pgTable("targets", { targetId: serial("targetId").primaryKey(), resourceId: integer("resourceId") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2acbe0f2a..e3e83d222 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -252,6 +252,26 @@ export const siteResourceLabels = sqliteTable( (t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)] ); +export const clientLabels = sqliteTable( + "clientLabels", + { + clientLabelId: integer("clientLabelId").primaryKey({ + autoIncrement: true + }), + clientId: integer("clientId") + .references(() => clients.clientId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] +); + export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") diff --git a/server/private/routers/labels/attachLabelToItem.ts b/server/private/routers/labels/attachLabelToItem.ts index f98e006be..d011a606d 100644 --- a/server/private/routers/labels/attachLabelToItem.ts +++ b/server/private/routers/labels/attachLabelToItem.ts @@ -12,6 +12,8 @@ */ import { + clients, + clientLabels, db, labels, resourceLabels, @@ -24,7 +26,7 @@ import { import response from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -38,7 +40,8 @@ const paramsSchema = z.strictObject({ const attachLabelBodySchema = z.strictObject({ siteId: z.number().int().optional(), resourceId: z.number().int().optional(), - siteResourceId: z.number().int().optional() + siteResourceId: z.number().int().optional(), + clientId: z.number().int().optional() }); export async function attachLabelToItem( @@ -69,13 +72,14 @@ export async function attachLabelToItem( ); } - const { siteId, resourceId, siteResourceId } = parsedBody.data; + const { siteId, resourceId, siteResourceId, clientId } = + parsedBody.data; - if (!siteId && !resourceId && !siteResourceId) { + if (!siteId && !resourceId && !siteResourceId && !clientId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "At least one of `siteId`, `resourceId` or `siteResourceId` should be provided." + "At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided." ) ); } @@ -175,6 +179,35 @@ export async function attachLabelToItem( .onConflictDoNothing(); } + if (clientId) { + const clientCount = await db.$count( + clients, + and( + eq(clients.clientId, clientId), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) + ); + + if (clientCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with Id ${clientId} doesn't exist.` + ) + ); + } + + // idempotent, calling this endpoint multiple times should attach the label only once + await db + .insert(clientLabels) + .values({ + labelId, + clientId + }) + .onConflictDoNothing(); + } + return response(res, { data: {}, success: true, diff --git a/server/private/routers/labels/detachLabelFromItem.ts b/server/private/routers/labels/detachLabelFromItem.ts index 081349a55..9a5545312 100644 --- a/server/private/routers/labels/detachLabelFromItem.ts +++ b/server/private/routers/labels/detachLabelFromItem.ts @@ -12,6 +12,8 @@ */ import { + clients, + clientLabels, db, labels, resourceLabels, @@ -24,7 +26,7 @@ import { import response from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -38,7 +40,8 @@ const paramsSchema = z.strictObject({ const detachLabelBodySchema = z.strictObject({ siteId: z.number().int().optional(), resourceId: z.number().int().optional(), - siteResourceId: z.number().int().optional() + siteResourceId: z.number().int().optional(), + clientId: z.number().int().optional() }); export async function detachLabelFromItem( @@ -69,13 +72,14 @@ export async function detachLabelFromItem( ); } - const { siteId, resourceId, siteResourceId } = parsedBody.data; + const { siteId, resourceId, siteResourceId, clientId } = + parsedBody.data; - if (!siteId && !resourceId && !siteResourceId) { + if (!siteId && !resourceId && !siteResourceId && !clientId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "At least one of `siteId`, `siteResourceId` or `resourceId` should be provided." + "At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided." ) ); } @@ -175,6 +179,35 @@ export async function detachLabelFromItem( ); } + if (clientId) { + const clientCount = await db.$count( + clients, + and( + eq(clients.clientId, clientId), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) + ); + + if (clientCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with Id ${clientId} doesn't exist.` + ) + ); + } + + await db + .delete(clientLabels) + .where( + and( + eq(clientLabels.labelId, labelId), + eq(clientLabels.clientId, clientId) + ) + ); + } + return response(res, { data: {}, success: true, diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index f5d69857d..220f845f4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,15 +1,20 @@ import { + clientLabels, clients, clientSitesAssociationsCache, currentFingerprint, db, + labels, olms, orgs, roleClients, sites, userClients, - users + users, + type Label } from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -169,6 +174,7 @@ type ClientWithSites = Awaited>[0] & { siteNiceId: string | null; }>; olmUpdateAvailable?: boolean; + labels?: Array>; }; type OlmWithUpdateAvailable = ClientWithSites; @@ -255,6 +261,11 @@ export async function listClients( (client) => client.clientId ); + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + // Get client count with filter const conditions = [ and( @@ -288,18 +299,29 @@ export async function listClients( } if (query) { - conditions.push( - or( - like( - sql`LOWER(${clients.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${clients.niceId})`, - "%" + query.toLowerCase() + "%" + const q = "%" + query.toLowerCase() + "%"; + const queryList = [ + like(sql`LOWER(${clients.name})`, q), + like(sql`LOWER(${clients.niceId})`, q) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( + inArray( + clients.clientId, + db + .select({ id: clientLabels.clientId }) + .from(clientLabels) + .innerJoin( + labels, + eq(labels.labelId, clientLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) ) - ) - ); + ); + } + + conditions.push(or(...queryList)); } const baseQuery = queryClientsBase().where(and(...conditions)); @@ -326,6 +348,30 @@ export async function listClients( const clientIds = clientsList.map((client) => client.clientId); const siteAssociations = await getSiteAssociations(clientIds); + let labelsForClients: Array<{ + labelId: number; + name: string; + color: string; + clientId: number; + }> = []; + + if (isLabelFeatureEnabled && clientIds.length > 0) { + labelsForClients = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + clientId: clientLabels.clientId + }) + .from(labels) + .innerJoin( + clientLabels, + eq(clientLabels.labelId, labels.labelId) + ) + .where(inArray(clientLabels.clientId, clientIds)) + .orderBy(asc(clientLabels.clientLabelId)); + } + // Group site associations by client ID const sitesByClient = siteAssociations.reduce( (acc, association) => { @@ -353,7 +399,10 @@ export async function listClients( const clientsWithSites = clientsList.map((client) => { return { ...client, - sites: sitesByClient[client.clientId] || [] + sites: sitesByClient[client.clientId] || [], + labels: labelsForClients.filter( + (l) => l.clientId === client.clientId + ) }; }); diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index fe9281ac7..066fdc3ea 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -76,7 +76,8 @@ export default async function ClientsPage(props: ClientsPageProps) { agent: client.agent, archived: client.archived || false, blocked: client.blocked || false, - approvalState: client.approvalState ?? "approved" + approvalState: client.approvalState ?? "approved", + labels: client.labels ?? [] }; }; diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 4ef22c83d..61125baad 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -10,8 +10,11 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ArrowRight, ArrowUpDown, @@ -19,12 +22,26 @@ import { CircleSlash, ArrowDown01Icon, ArrowUp10Icon, - ChevronsUpDownIcon + ChevronsUpDownIcon, + PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState, useTransition } from "react"; +import { + startTransition, + useMemo, + useOptimistic, + useState, + useTransition +} from "react"; +import { LabelBadge } from "./label-badge"; +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "./ui/popover"; import { Badge } from "./ui/badge"; import type { PaginationState } from "@tanstack/react-table"; import { ControlledDataTable } from "./ui/controlled-data-table"; @@ -53,6 +70,11 @@ export type ClientRow = { archived?: boolean; blocked?: boolean; approvalState: "approved" | "pending" | "denied"; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; type ClientTableProps = { @@ -84,17 +106,21 @@ export default function MachineClientsTable({ ); const api = createApiClient(useEnvContext()); - const [isRefreshing, startTransition] = useTransition(); + const [isRefreshing, startRefreshTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const defaultMachineColumnVisibility = { subnet: false, userId: false, - niceId: false + niceId: false, + labels: false }; const refreshData = () => { - startTransition(() => { + startRefreshTransition(() => { try { router.refresh(); } catch (error) { @@ -384,6 +410,24 @@ export default function MachineClientsTable({ } ]; + if (isLabelFeatureEnabled) { + baseColumns.push({ + id: "labels", + accessorKey: "labels", + header: () => ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: ClientRow } }) => ( + + ) + }); + } + // Only include actions column if there are rows without userIds if (hasRowsWithoutUserId) { baseColumns.push({ @@ -464,7 +508,7 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); + }, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -591,3 +635,95 @@ export default function MachineClientsTable({ ); } + +type MachineClientLabelCellProps = { + client: ClientRow; + orgId: string; +}; + +function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const router = useRouter(); + + const labels = client.labels ?? []; + const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); + + function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") { + startTransition(async () => { + try { + if (action === "attach") { + setOptimisticLabels([...optimisticLabels, label]); + await api.put( + `/org/${orgId}/label/${label.labelId}/attach`, + { clientId: client.id } + ); + } else { + setOptimisticLabels( + optimisticLabels.filter( + (lb) => lb.labelId !== label.labelId + ) + ); + await api.put( + `/org/${orgId}/label/${label.labelId}/detach`, + { clientId: client.id } + ); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } finally { + router.refresh(); + } + }); + } + + return ( +
+ {optimisticLabels.slice(0, 3).map((label) => ( + setIsPopoverOpen(true)} + {...label} + /> + ))} + {optimisticLabels.length > 3 && ( + + )} + + + + + + + + +
+ ); +} From 6aa406927a19e94ae74169282ee51b89a66955fa Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 18:20:26 +0200 Subject: [PATCH 32/44] =?UTF-8?q?=F0=9F=90=9B=20fix=20error=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/logs/connection/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index c2a630332..883e3daf4 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -294,7 +294,7 @@ export default function ConnectionLogsPage() { } catch (error) { toast({ title: t("error"), - description: t("Failed to filter logs"), + description: formatAxiosError(error), variant: "destructive" }); } finally { From 4334480675044221b0883f115392570cf94a997b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 18:33:29 +0200 Subject: [PATCH 33/44] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/lib/logStreaming/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/private/lib/logStreaming/index.ts b/server/private/lib/logStreaming/index.ts index 18662a7c0..60e0db1aa 100644 --- a/server/private/lib/logStreaming/index.ts +++ b/server/private/lib/logStreaming/index.ts @@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager"; */ export const logStreamingManager = new LogStreamingManager(); -if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here +if (build !== "saas") { + // this is handled separately in the saas build, so we don't want to start it here logStreamingManager.start(); } From 8f7e5ab1ed23cfbd75533456e0e3994d84799afc Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 19:31:53 +0200 Subject: [PATCH 34/44] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20org=20labels=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/(private)/labels/page.tsx | 20 +++++++++++++++++++ src/app/navigation.tsx | 16 ++++++++++++--- src/components/OrgLabelsTable.tsx | 1 + 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/app/[orgId]/settings/(private)/labels/page.tsx create mode 100644 src/components/OrgLabelsTable.tsx diff --git a/src/app/[orgId]/settings/(private)/labels/page.tsx b/src/app/[orgId]/settings/(private)/labels/page.tsx new file mode 100644 index 000000000..54bffb3ff --- /dev/null +++ b/src/app/[orgId]/settings/(private)/labels/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Enterprise Licenses" +}; + +type Props = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +export default async function LabelsPage({ params, searchParams }: Props) { + const { orgId } = await params; + + const sp = await searchParams; + + return <>; +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 3cfe867e3..6d8bffee5 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -23,6 +23,7 @@ import { Server, Settings, SquareMousePointer, + TagIcon, TicketCheck, Unplug, User, @@ -99,7 +100,7 @@ export const orgNavSections = ( href: "/{orgId}/settings/domains", icon: }, - ...(build == "saas" + ...(build === "saas" ? [ { title: "sidebarRemoteExitNodes", @@ -237,10 +238,19 @@ export const orgNavSections = ( title: "sidebarApiKeys", href: "/{orgId}/settings/api-keys", icon: - } + }, + ...(build !== "oss" + ? [ + { + title: "labels", + href: "/{orgId}/settings/labels", + icon: + } + ] + : []) ] }, - ...(build == "saas" && options?.isPrimaryOrg + ...(build === "saas" && options?.isPrimaryOrg ? [ { title: "sidebarBillingAndLicenses", diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx new file mode 100644 index 000000000..bce9a91fe --- /dev/null +++ b/src/components/OrgLabelsTable.tsx @@ -0,0 +1 @@ +"use client"; From 173562654b3ef183c57b4dd4c69422ed5194e112 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 21:09:48 +0200 Subject: [PATCH 35/44] =?UTF-8?q?=E2=9C=A8=20delete=20org=20label=20endpoi?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/auth/actions.ts | 1 + server/private/routers/external.ts | 8 + .../private/routers/labels/deleteOrgLabel.ts | 72 ++++++ server/private/routers/labels/index.ts | 1 + .../settings/(private)/labels/page.tsx | 49 +++- src/components/OrgLabelsTable.tsx | 230 ++++++++++++++++++ src/components/labels-selector.tsx | 21 +- 8 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 server/private/routers/labels/deleteOrgLabel.ts diff --git a/messages/en-US.json b/messages/en-US.json index 751c0e746..07482bf80 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1141,6 +1141,7 @@ "idpErrorNotFound": "IdP not found", "inviteInvalid": "Invalid Invite", "labels": "Labels", + "orgLabelsDescription": "Manage labels in this organization.", "addLabels": "Add labels", "siteLabelsTab": "Labels", "siteLabelsDescription": "Manage labels associated with this site.", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 969f9e4ae..bba2265fb 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -151,6 +151,7 @@ export enum ActionsEnum { listOrgLabels = "listOrgLabels", createOrgLabel = "createOrgLabel", updateOrgLabel = "updateOrgLabel", + deleteOrgLabel = "deleteOrgLabel", attachLabelToItem = "attachLabelToItem", detachLabelFromItem = "detachLabelFromItem", getAlertRule = "getAlertRule", diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 5e20f6db6..8745dffdf 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -757,6 +757,14 @@ authenticated.patch( labels.updateOrgLabel ); +authenticated.delete( + "/org/:orgId/label/:labelId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteOrgLabel), + labels.deleteOrgLabel +); + authenticated.put( "/org/:orgId/label/:labelId/attach", verifyValidLicense, diff --git a/server/private/routers/labels/deleteOrgLabel.ts b/server/private/routers/labels/deleteOrgLabel.ts new file mode 100644 index 000000000..f091c910a --- /dev/null +++ b/server/private/routers/labels/deleteOrgLabel.ts @@ -0,0 +1,72 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +import { db, labels } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + labelId: z.string().transform(Number).pipe(z.int().positive()) +}); + +export async function deleteOrgLabel( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, labelId } = parsedParams.data; + + const [existing] = await db + .select() + .from(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + if (!existing) { + return next(createHttpError(HttpCode.NOT_FOUND, "Label not found")); + } + + await db + .delete(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Label deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts index ffec1229b..d988d8e38 100644 --- a/server/private/routers/labels/index.ts +++ b/server/private/routers/labels/index.ts @@ -16,3 +16,4 @@ export * from "./createOrgLabel"; export * from "./updateOrgLabel"; export * from "./attachLabelToItem"; export * from "./detachLabelFromItem"; +export * from "./deleteOrgLabel"; diff --git a/src/app/[orgId]/settings/(private)/labels/page.tsx b/src/app/[orgId]/settings/(private)/labels/page.tsx index 54bffb3ff..806c60325 100644 --- a/src/app/[orgId]/settings/(private)/labels/page.tsx +++ b/src/app/[orgId]/settings/(private)/labels/page.tsx @@ -1,7 +1,14 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import { AxiosResponse } from "axios"; +import OrgLabelsTable from "@app/components/OrgLabelsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; export const metadata: Metadata = { - title: "Enterprise Licenses" + title: "Labels" }; type Props = { @@ -14,7 +21,43 @@ export const dynamic = "force-dynamic"; export default async function LabelsPage({ params, searchParams }: Props) { const { orgId } = await params; - const sp = await searchParams; + const searchParamsObj = new URLSearchParams(await searchParams); - return <>; + let labels: ListOrgLabelsResponse["labels"] = []; + let pagination: ListOrgLabelsResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${orgId}/labels?${searchParamsObj.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + labels = responseData.labels; + pagination = responseData.pagination; + } catch (e) {} + + const t = await getTranslations(); + + return ( + <> + + + + + ); } diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index bce9a91fe..bc06b9101 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -1 +1,231 @@ "use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal, + PencilIcon, + PencilLineIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "next/navigation"; +import { useActionState, useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; +import { LabelBadge } from "./label-badge"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { cn } from "@app/lib/cn"; +import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; + +export type LabelRow = { + labelId: number; + name: string; + color: string; +}; + +type OrgLabelsTableProps = { + labels: LabelRow[]; + pagination: PaginationState; + orgId: string; + rowCount: number; +}; + +export default function OrgLabelsTable({ + labels, + orgId, + pagination, + rowCount +}: OrgLabelsTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [selectedLabel, setSelectedLabel] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const [isRefreshing, startRefreshTransition] = useTransition(); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + function refreshData() { + startRefreshTransition(async () => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + filter({ searchParams: newSearch }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + enableHiding: false, + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => + }, + { + accessorKey: "actions", + enableHiding: false, + header: () => { + return {t("actions")}; + }, + cell: ({ row }) => ( + + + + + + {t("edit")} + {}}> + + {t("delete")} + + + + + ) + } + ], + [searchParams, t] + ); + + async function deleteLabel() { + // ... + } + + return ( + <> + {selectedLabel && ( + { + setIsDeleteModalOpen(val); + setSelectedLabel(null); + }} + dialog={ +
+

{t("resourceQuestionRemove")}

+

{t("resourceMessageRemove")}

+
+ } + buttonText={t("resourceDeleteConfirm")} + onConfirm={async () => {}} + string={selectedLabel.name} + title={t("resourceDelete")} + /> + )} + + + ); +} + +type EditLabelCellProps = { + label: LabelRow; +}; + +function EditLabelCell({ label }: EditLabelCellProps) { + const t = useTranslations(); + + return ( +
+
+ + {label.name} + + {/* */} +
+ ); +} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 64a80b26a..1f7714d07 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -1,6 +1,15 @@ +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import { useQuery } from "@tanstack/react-query"; -import { useActionState, useMemo, useState, useTransition } from "react"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; import { Command, CommandEmpty, @@ -9,11 +18,6 @@ import { CommandItem, CommandList } from "./ui/command"; -import { Checkbox } from "./ui/checkbox"; -import { useTranslations } from "next-intl"; -import { useDebounce } from "use-debounce"; -import { type Selectedsite, SiteOnlineStatus } from "./site-selector"; -import { Button } from "./ui/button"; import { Select, SelectContent, @@ -21,11 +25,6 @@ import { SelectTrigger, SelectValue } from "./ui/select"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; -import type { AxiosResponse } from "axios"; -import { toast } from "@app/hooks/useToast"; export type SelectedLabel = { name: string; From 9a88394efe9523161f04809bc97a54f83d60c1c9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 21:17:58 +0200 Subject: [PATCH 36/44] =?UTF-8?q?=F0=9F=9B=82=20gate=20label=20endpoints?= =?UTF-8?q?=20behing=20subscription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/middlewares/verifySubscription.ts | 2 +- server/private/routers/external.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 27bd25dfe..92d5d9cfe 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) { next: NextFunction ): Promise { try { - if (build != "saas") { + if (build !== "saas") { return next(); } diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 8745dffdf..481e3a302 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -737,6 +737,7 @@ authenticated.get( "/org/:orgId/labels", verifyValidLicense, verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), verifyUserHasAction(ActionsEnum.listOrgLabels), labels.listOrgLabels ); @@ -745,6 +746,7 @@ authenticated.post( "/org/:orgId/labels", verifyValidLicense, verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), verifyUserHasAction(ActionsEnum.createOrgLabel), labels.createOrgLabel ); @@ -753,6 +755,7 @@ authenticated.patch( "/org/:orgId/label/:labelId", verifyValidLicense, verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), verifyUserHasAction(ActionsEnum.updateOrgLabel), labels.updateOrgLabel ); @@ -769,6 +772,7 @@ authenticated.put( "/org/:orgId/label/:labelId/attach", verifyValidLicense, verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), verifyUserHasAction(ActionsEnum.attachLabelToItem), labels.attachLabelToItem ); @@ -777,6 +781,7 @@ authenticated.put( "/org/:orgId/label/:labelId/detach", verifyValidLicense, verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), verifyUserHasAction(ActionsEnum.detachLabelFromItem), labels.detachLabelFromItem ); From eac36ee442195919cbca1a46d2792902468bd277 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 22:15:43 +0200 Subject: [PATCH 37/44] =?UTF-8?q?=E2=9C=A8=20delete=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 5 +++++ src/components/OrgLabelsTable.tsx | 35 ++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 07482bf80..e42662968 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -255,6 +255,11 @@ "resourceGoTo": "Go to Resource", "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", + "labelDelete": "Delete Label", + "labelDeleteConfirm": "Confirm Delete Label", + "labelErrorDelete": "Failed to delete label", + "labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.", + "labelQuestionRemove": "Are you sure you want to remove the label from the organization?", "visibility": "Visibility", "enabled": "Enabled", "disabled": "Disabled", diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index bc06b9101..0c6349a61 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -141,7 +141,12 @@ export default function OrgLabelsTable({ {t("edit")} - {}}> + { + setSelectedLabel(row.original); + setIsDeleteModalOpen(true); + }} + > {t("delete")} @@ -154,8 +159,22 @@ export default function OrgLabelsTable({ [searchParams, t] ); - async function deleteLabel() { - // ... + function deleteLabel(label: LabelRow) { + startRefreshTransition(async () => { + await api + .delete(`/org/${orgId}/label/${label.labelId}`) + .catch((e) => { + toast({ + variant: "destructive", + title: t("labelErrorDelete"), + description: formatAxiosError(e, t("labelErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }); } return ( @@ -169,14 +188,14 @@ export default function OrgLabelsTable({ }} dialog={
-

{t("resourceQuestionRemove")}

-

{t("resourceMessageRemove")}

+

{t("labelQuestionRemove")}

+

{t("labelMessageRemove")}

} - buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => {}} + buttonText={t("labelDeleteConfirm")} + onConfirm={async () => deleteLabel(selectedLabel)} string={selectedLabel.name} - title={t("resourceDelete")} + title={t("labelDelete")} /> )} Date: Thu, 14 May 2026 22:42:01 +0200 Subject: [PATCH 38/44] =?UTF-8?q?=F0=9F=9A=A7=20=20wip:=20create=20label?= =?UTF-8?q?=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ClientResourcesTable.tsx | 4 +- .../CreateInternalResourceDialog.tsx | 88 +++++++++---------- src/components/CreateOrgLabelDialog.tsx | 74 ++++++++++++++++ src/components/OrgLabelsTable.tsx | 79 ++++------------- 4 files changed, 137 insertions(+), 108 deletions(-) create mode 100644 src/components/CreateOrgLabelDialog.tsx diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 7ebef5795..156cc7f41 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -204,8 +204,8 @@ export default function ClientResourcesTable({ siteId: number ) => { try { - await api.delete(`/site-resource/${resourceId}`).then(() => { - startTransition(() => { + startTransition(async () => { + await api.delete(`/site-resource/${resourceId}`).then(() => { router.refresh(); setIsDeleteModalOpen(false); }); diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 4d2bc0916..dc1dacd4b 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { cleanForFQDN, InternalResourceForm, @@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({ }: CreateInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); - const [isSubmitting, setIsSubmitting] = useState(false); const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); + const [isSubmitting, startTransition] = useTransition(); - async function handleSubmit(values: InternalResourceFormValues) { - setIsSubmitting(true); - try { - let data = { ...values }; - if ( - (data.mode === "host" || data.mode === "http") && - isHostname(data.destination) - ) { - const currentAlias = data.alias?.trim() || ""; - if (!currentAlias) { - let aliasValue = data.destination; - if (data.destination.toLowerCase() === "localhost") { - aliasValue = `${cleanForFQDN(data.name)}.internal`; + function handleSubmit(values: InternalResourceFormValues) { + startTransition(async () => { + try { + let data = { ...values }; + if ( + (data.mode === "host" || data.mode === "http") && + isHostname(data.destination) + ) { + const currentAlias = data.alias?.trim() || ""; + if (!currentAlias) { + let aliasValue = data.destination; + if (data.destination.toLowerCase() === "localhost") { + aliasValue = `${cleanForFQDN(data.name)}.internal`; + } + data = { ...data, alias: aliasValue }; } - data = { ...data, alias: aliasValue }; } - } - await api.put>( - `/org/${orgId}/site-resource`, - { + await api.put< + AxiosResponse<{ data: { siteResourceId: number } }> + >(`/org/${orgId}/site-resource`, { name: data.name, siteIds: data.siteIds, mode: data.mode, @@ -106,32 +106,30 @@ export default function CreateInternalResourceDialog({ clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] - } - ); + }); - toast({ - title: t("createInternalResourceDialogSuccess"), - description: t( - "createInternalResourceDialogInternalResourceCreatedSuccessfully" - ), - variant: "default" - }); - setOpen(false); - onSuccess?.(); - } catch (error) { - toast({ - title: t("createInternalResourceDialogError"), - description: formatAxiosError( - error, - t( - "createInternalResourceDialogFailedToCreateInternalResource" - ) - ), - variant: "destructive" - }); - } finally { - setIsSubmitting(false); - } + toast({ + title: t("createInternalResourceDialogSuccess"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ), + variant: "default" + }); + setOpen(false); + onSuccess?.(); + } catch (error) { + toast({ + title: t("createInternalResourceDialogError"), + description: formatAxiosError( + error, + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) + ), + variant: "destructive" + }); + } + }); } return ( diff --git a/src/components/CreateOrgLabelDialog.tsx b/src/components/CreateOrgLabelDialog.tsx new file mode 100644 index 000000000..f06f979ca --- /dev/null +++ b/src/components/CreateOrgLabelDialog.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { useTranslations } from "next-intl"; +import { useState, useTransition } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "./Credenza"; +import { Button } from "./ui/button"; + +export type CreateOrgLabelDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; + orgId: string; + onSuccess?: () => void; +}; + +export function CreateOrgLabelDialog({ + open, + setOpen, + orgId, + onSuccess +}: CreateOrgLabelDialogProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isSubmitting, startTransition] = useTransition(); + + return ( + + + + + {t("createInternalResourceDialogCreateClientResource")} + + + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} + + + + <> + + + + + + + + + + ); +} diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index 0c6349a61..bcb6f59ea 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -53,7 +53,7 @@ export default function OrgLabelsTable({ rowCount }: OrgLabelsTableProps) { const router = useRouter(); - const pathname = usePathname(); + const { navigate: filter, isNavigating: isFiltering, @@ -63,13 +63,13 @@ export default function OrgLabelsTable({ const [selectedLabel, setSelectedLabel] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isRefreshing, startRefreshTransition] = useTransition(); + const [isRefreshing, startTransition] = useTransition(); const api = createApiClient(useEnvContext()); const t = useTranslations(); function refreshData() { - startRefreshTransition(async () => { + startTransition(async () => { try { router.refresh(); } catch { @@ -82,11 +82,6 @@ export default function OrgLabelsTable({ }); } - function toggleSort(column: string) { - const newSearch = getNextSortOrder(column, searchParams); - filter({ searchParams: newSearch }); - } - const handlePaginationChange = (newPage: PaginationState) => { searchParams.set("page", (newPage.pageIndex + 1).toString()); searchParams.set("pageSize", newPage.pageSize.toString()); @@ -105,25 +100,21 @@ export default function OrgLabelsTable({ accessorKey: "name", enableHiding: false, header: () => { - const nameOrder = getSortDirection("name", searchParams); - const Icon = - nameOrder === "asc" - ? ArrowDown01Icon - : nameOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; - return ( - - ); + return {t("name")}; }, - cell: ({ row }) => + cell: ({ row }) => ( +
+
+ + {row.original.name} +
+ ) }, { accessorKey: "actions", @@ -160,7 +151,7 @@ export default function OrgLabelsTable({ ); function deleteLabel(label: LabelRow) { - startRefreshTransition(async () => { + startTransition(async () => { await api .delete(`/org/${orgId}/label/${label.labelId}`) .catch((e) => { @@ -214,37 +205,3 @@ export default function OrgLabelsTable({ ); } - -type EditLabelCellProps = { - label: LabelRow; -}; - -function EditLabelCell({ label }: EditLabelCellProps) { - const t = useTranslations(); - - return ( -
-
- - {label.name} - - {/* */} -
- ); -} From 68d7b0a41688a1fccaf33375480184f6a119d7dc Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 14 May 2026 22:43:29 +0200 Subject: [PATCH 39/44] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreateEditOrgLabelForm.tsx | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/components/CreateEditOrgLabelForm.tsx diff --git a/src/components/CreateEditOrgLabelForm.tsx b/src/components/CreateEditOrgLabelForm.tsx new file mode 100644 index 000000000..117131aca --- /dev/null +++ b/src/components/CreateEditOrgLabelForm.tsx @@ -0,0 +1,7 @@ +"use client"; + +export type CreateEditOrgLabelProps = {}; + +export function CreateEditOrgLabel({}: CreateEditOrgLabelProps) { + return <>; +} From 25c08e727917abd829e07176dc645f8bc4fc81bc Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 18 May 2026 21:57:44 +0200 Subject: [PATCH 40/44] =?UTF-8?q?=E2=9C=A8=20Create=20label=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 9 ++ src/components/CreateEditOrgLabelForm.tsx | 7 -- src/components/CreateOrgLabelDialog.tsx | 54 +++++++--- src/components/OrgLabelForm.tsx | 125 ++++++++++++++++++++++ src/components/OrgLabelsTable.tsx | 49 ++++++--- src/components/labels-selector.tsx | 2 +- 6 files changed, 208 insertions(+), 38 deletions(-) delete mode 100644 src/components/CreateEditOrgLabelForm.tsx create mode 100644 src/components/OrgLabelForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e42662968..da5c8a4a5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -256,6 +256,15 @@ "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", "labelDelete": "Delete Label", + "labelAdd": "Add Label", + "labelCreateSuccessMessage": "Label Created Successfully", + "labelEditSuccess": "Label Modified Successfully", + "labelNameField": "Label Name", + "labelColorField": "Label Color", + "labelPlaceholder": "Ex: homelab", + "labelCreate": "Create Label", + "createLabelDialogTitle": "Create Label", + "createLabelDialogDescription": "Create a new label that can be attached to this organization", "labelDeleteConfirm": "Confirm Delete Label", "labelErrorDelete": "Failed to delete label", "labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.", diff --git a/src/components/CreateEditOrgLabelForm.tsx b/src/components/CreateEditOrgLabelForm.tsx deleted file mode 100644 index 117131aca..000000000 --- a/src/components/CreateEditOrgLabelForm.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -export type CreateEditOrgLabelProps = {}; - -export function CreateEditOrgLabel({}: CreateEditOrgLabelProps) { - return <>; -} diff --git a/src/components/CreateOrgLabelDialog.tsx b/src/components/CreateOrgLabelDialog.tsx index f06f979ca..d91a075c0 100644 --- a/src/components/CreateOrgLabelDialog.tsx +++ b/src/components/CreateOrgLabelDialog.tsx @@ -1,9 +1,12 @@ "use client"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { createApiClient } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; -import { useState, useTransition } from "react"; +import { useTransition } from "react"; import { Credenza, CredenzaBody, @@ -14,6 +17,7 @@ import { CredenzaHeader, CredenzaTitle } from "./Credenza"; +import { OrgLabelForm } from "./OrgLabelForm"; import { Button } from "./ui/button"; export type CreateOrgLabelDialogProps = { @@ -33,21 +37,45 @@ export function CreateOrgLabelDialog({ const api = createApiClient(useEnvContext()); const [isSubmitting, startTransition] = useTransition(); + async function createOrgLabel(data: { name: string; color: string }) { + try { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/labels`, data); + + if (res.status === 201) { + setOpen(false); + onSuccess?.(); + + toast({ + title: t("success"), + description: t("labelCreateSuccessMessage") + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } + } + return ( - + - - {t("createInternalResourceDialogCreateClientResource")} - + {t("createLabelDialogTitle")} - {t( - "createInternalResourceDialogCreateClientResourceDescription" - )} + {t("createLabelDialogDescription")} - <> + { + startTransition(async () => createOrgLabel(data)); + }} + /> @@ -56,16 +84,16 @@ export function CreateOrgLabelDialog({ onClick={() => setOpen(false)} disabled={isSubmitting} > - {t("createInternalResourceDialogCancel")} + {t("cancel")} diff --git a/src/components/OrgLabelForm.tsx b/src/components/OrgLabelForm.tsx new file mode 100644 index 000000000..492c80e95 --- /dev/null +++ b/src/components/OrgLabelForm.tsx @@ -0,0 +1,125 @@ +"use client"; + +import z from "zod"; +import { Input } from "./ui/input"; +import { useTranslations } from "use-intl"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "./ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; +import { LABEL_COLORS } from "./labels-selector"; + +const labelFormSchema = z.object({ + name: z.string().nonempty(), + color: z + .string() + .regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i) + .nonempty() +}); + +export type LabelFormData = z.infer; + +export type OrgLabelFormProps = { + onSubmit: (data: LabelFormData) => void; +}; + +export function OrgLabelForm({ onSubmit }: OrgLabelFormProps) { + const t = useTranslations(); + + const colorValues = Object.values(LABEL_COLORS); + const randomColor = + colorValues[Math.floor(Math.random() * colorValues.length)]; + + const form = useForm({ + resolver: zodResolver(labelFormSchema), + defaultValues: { + name: "", + color: randomColor + } + }); + + return ( +
+ { + if (await form.trigger()) { + onSubmit(form.getValues()); + } + }} + > + ( + + {t("labelNameField")} + + + + + + )} + /> + + ( + + {t("labelColorField")} + + + + )} + /> + + + ); +} diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index bcb6f59ea..5fd90f28d 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -32,6 +32,7 @@ import { LabelBadge } from "./label-badge"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { cn } from "@app/lib/cn"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; +import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog"; export type LabelRow = { labelId: number; @@ -62,6 +63,8 @@ export default function OrgLabelsTable({ const [selectedLabel, setSelectedLabel] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); @@ -171,27 +174,39 @@ export default function OrgLabelsTable({ return ( <> {selectedLabel && ( - { - setIsDeleteModalOpen(val); - setSelectedLabel(null); - }} - dialog={ -
-

{t("labelQuestionRemove")}

-

{t("labelMessageRemove")}

-
- } - buttonText={t("labelDeleteConfirm")} - onConfirm={async () => deleteLabel(selectedLabel)} - string={selectedLabel.name} - title={t("labelDelete")} - /> + <> + { + setIsDeleteModalOpen(val); + setSelectedLabel(null); + }} + dialog={ +
+

{t("labelQuestionRemove")}

+

{t("labelMessageRemove")}

+
+ } + buttonText={t("labelDeleteConfirm")} + onConfirm={async () => deleteLabel(selectedLabel)} + string={selectedLabel.name} + title={t("labelDelete")} + /> + )} + + startTransition(() => router.refresh())} + /> + setIsCreateModalOpen(true)} tableId="org-labels-table" searchPlaceholder={t("labelSearch")} pagination={pagination} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 1f7714d07..94b8925b0 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -38,7 +38,7 @@ export type LabelsSelectorProps = { toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; }; -const LABEL_COLORS = { +export const LABEL_COLORS = { red: "#ff6467", green: "#05df72", blue: "#51a2ff", From 7968c4357b875e210eddf3c2c08dbe0d488354e6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 18 May 2026 22:14:49 +0200 Subject: [PATCH 41/44] =?UTF-8?q?=E2=9C=A8=20edit=20org=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 + src/components/CreateOrgLabelDialog.tsx | 2 +- src/components/EditOrgLabelDialog.tsx | 109 ++++++++++++++++++++++++ src/components/OrgLabelForm.tsx | 7 +- src/components/OrgLabelsTable.tsx | 20 ++++- 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 src/components/EditOrgLabelDialog.tsx diff --git a/messages/en-US.json b/messages/en-US.json index da5c8a4a5..ec6144233 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -265,6 +265,9 @@ "labelCreate": "Create Label", "createLabelDialogTitle": "Create Label", "createLabelDialogDescription": "Create a new label that can be attached to this organization", + "labelEdit": "Edit Label", + "editLabelDialogTitle": "Update Label", + "editLabelDialogDescription": "Edit a new label that can be attached to this organization", "labelDeleteConfirm": "Confirm Delete Label", "labelErrorDelete": "Failed to delete label", "labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.", diff --git a/src/components/CreateOrgLabelDialog.tsx b/src/components/CreateOrgLabelDialog.tsx index d91a075c0..9e1ab12f5 100644 --- a/src/components/CreateOrgLabelDialog.tsx +++ b/src/components/CreateOrgLabelDialog.tsx @@ -63,7 +63,7 @@ export function CreateOrgLabelDialog({ return ( - + {t("createLabelDialogTitle")} diff --git a/src/components/EditOrgLabelDialog.tsx b/src/components/EditOrgLabelDialog.tsx new file mode 100644 index 000000000..98891cd38 --- /dev/null +++ b/src/components/EditOrgLabelDialog.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useTransition } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "./Credenza"; +import { OrgLabelForm } from "./OrgLabelForm"; +import { Button } from "./ui/button"; + +export type EditOrgLabelDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; + orgId: string; + onSuccess?: () => void; + label: { + name: string; + color: string; + labelId: number; + }; +}; + +export function EditOrgLabelDialog({ + open, + setOpen, + orgId, + onSuccess, + label +}: EditOrgLabelDialogProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isSubmitting, startTransition] = useTransition(); + + async function editOrgLabel(data: { name: string; color: string }) { + try { + const res = await api.patch< + AxiosResponse + >(`/org/${orgId}/label/${label.labelId}`, data); + + if (res.status === 200) { + setOpen(false); + onSuccess?.(); + + toast({ + title: t("success"), + description: t("labelEditSuccessMessage") + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } + } + + return ( + + + + {t("editLabelDialogTitle")} + + {t("editLabelDialogDescription")} + + + + { + startTransition(async () => editOrgLabel(data)); + }} + /> + + + + + + + + + + ); +} diff --git a/src/components/OrgLabelForm.tsx b/src/components/OrgLabelForm.tsx index 492c80e95..32a31550e 100644 --- a/src/components/OrgLabelForm.tsx +++ b/src/components/OrgLabelForm.tsx @@ -34,9 +34,10 @@ export type LabelFormData = z.infer; export type OrgLabelFormProps = { onSubmit: (data: LabelFormData) => void; + defaultValue?: LabelFormData; }; -export function OrgLabelForm({ onSubmit }: OrgLabelFormProps) { +export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) { const t = useTranslations(); const colorValues = Object.values(LABEL_COLORS); @@ -46,8 +47,8 @@ export function OrgLabelForm({ onSubmit }: OrgLabelFormProps) { const form = useForm({ resolver: zodResolver(labelFormSchema), defaultValues: { - name: "", - color: randomColor + name: defaultValue?.name ?? "", + color: defaultValue?.color ?? randomColor } }); diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index 5fd90f28d..3ed95a9dc 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -33,6 +33,7 @@ import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { cn } from "@app/lib/cn"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog"; +import { EditOrgLabelDialog } from "./EditOrgLabelDialog"; export type LabelRow = { labelId: number; @@ -134,7 +135,14 @@ export default function OrgLabelsTable({ - {t("edit")} + { + setSelectedLabel(row.original); + setIsEditModalOpen(true); + }} + > + {t("edit")} + { setSelectedLabel(row.original); @@ -192,6 +200,16 @@ export default function OrgLabelsTable({ string={selectedLabel.name} title={t("labelDelete")} /> + + + startTransition(() => router.refresh()) + } + label={selectedLabel} + /> )} From 2d9c082607ef2ee5198ac29ff00b9695ac6aa16f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 18 May 2026 22:17:49 +0200 Subject: [PATCH 42/44] =?UTF-8?q?=F0=9F=92=84=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- src/components/OrgLabelForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ec6144233..736e8e0c4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -258,7 +258,7 @@ "labelDelete": "Delete Label", "labelAdd": "Add Label", "labelCreateSuccessMessage": "Label Created Successfully", - "labelEditSuccess": "Label Modified Successfully", + "labelEditSuccessMessage": "Label Modified Successfully", "labelNameField": "Label Name", "labelColorField": "Label Color", "labelPlaceholder": "Ex: homelab", diff --git a/src/components/OrgLabelForm.tsx b/src/components/OrgLabelForm.tsx index 32a31550e..9bc81fa07 100644 --- a/src/components/OrgLabelForm.tsx +++ b/src/components/OrgLabelForm.tsx @@ -56,7 +56,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
{ if (await form.trigger()) { onSubmit(form.getValues()); From 1f1791feb71f5884e8a1e34adc89740ecf0c7ad7 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 19 May 2026 22:48:15 +0200 Subject: [PATCH 43/44] =?UTF-8?q?=F0=9F=92=84=20make=20tag=20input=20wrap?= =?UTF-8?q?=20around=20instead=20of=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/multi-select/multi-select-tag-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index 5634f7481..372c9a767 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -41,7 +41,7 @@ export function MultiSelectTagInput({ variant: "outline" }), "justify-between w-full inline-flex", - "text-muted-foreground pl-1.5 cursor-text", + "text-muted-foreground pl-1.5 cursor-text h-auto py-1", "hover:bg-transparent hover:text-muted-foreground", props.disabled && "pointer-events-none opacity-50" )} @@ -49,7 +49,7 @@ export function MultiSelectTagInput({ {props.value.map((option) => ( From 6cacc9b83f55951b64521c8940f29dc15a97b382 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 19 May 2026 22:52:44 +0200 Subject: [PATCH 44/44] =?UTF-8?q?=F0=9F=92=84=20limit=20tag=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/multi-select/multi-select-tag-input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index 372c9a767..9791ccb94 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -61,7 +61,9 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > - {option.text} + + {option.text} +