diff --git a/messages/en-US.json b/messages/en-US.json index 0071ceec0..aa8f902ff 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -255,6 +255,23 @@ "resourceGoTo": "Go to Resource", "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", + "labelDelete": "Delete Label", + "labelAdd": "Add Label", + "labelCreateSuccessMessage": "Label Created Successfully", + "labelEditSuccessMessage": "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", + "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.", + "labelQuestionRemove": "Are you sure you want to remove the label from the organization?", "visibility": "Visibility", "enabled": "Enabled", "disabled": "Disabled", @@ -1140,6 +1157,15 @@ "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "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.", + "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", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 6ae49df8b..886114998 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -148,6 +148,12 @@ export enum ActionsEnum { updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", listAlertRules = "listAlertRules", + listOrgLabels = "listOrgLabels", + createOrgLabel = "createOrgLabel", + updateOrgLabel = "updateOrgLabel", + deleteOrgLabel = "deleteOrgLabel", + attachLabelToItem = "attachLabelToItem", + detachLabelFromItem = "detachLabelFromItem", getAlertRule = "getAlertRule", createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index cadeb7eeb..5f52b7e39 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -163,6 +163,89 @@ export const resources = pgTable("resources", { browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc }); +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() + }, + (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() + }, + (t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)] +); + +export const siteResourceLabels = pgTable( + "siteResourceLabels", + { + siteResourceLabelId: serial("siteResourceLabelId").primaryKey(), + siteResourceId: integer("siteResourceId") + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (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") @@ -1193,3 +1276,4 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; +export type Label = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 99731339c..307806b3e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -184,6 +184,95 @@ export const resources = sqliteTable("resources", { browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc }); +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() + }, + (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() + }, + (t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)] +); + +export const siteResourceLabels = sqliteTable( + "siteResourceLabels", + { + siteResourceLabelId: integer("siteResourceLabelId").primaryKey({ + autoIncrement: true + }), + siteResourceId: integer("siteResourceId") + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (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") @@ -1292,3 +1381,4 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type StatusHistory = InferSelectModel; +export type Label = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index f44cb8bf6..cd6ec59af 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -24,10 +24,12 @@ export enum TierFeature { DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces StandaloneHealthChecks = "standaloneHealthChecks", AlertingRules = "alertingRules", - WildcardSubdomain = "wildcardSubdomain" + WildcardSubdomain = "wildcardSubdomain", + Labels = "labels" } export const tierMatrix: Record = { + [TierFeature.Labels]: ["tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], 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(); } 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 fc71c72c4..1a5e3b554 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -32,6 +32,7 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; +import * as labels from "#private/routers/labels"; import { verifyOrgAccess, @@ -733,6 +734,59 @@ authenticated.get( alertRule.getAlertRule ); +authenticated.get( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), + verifyUserHasAction(ActionsEnum.listOrgLabels), + labels.listOrgLabels +); + +authenticated.post( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), + verifyUserHasAction(ActionsEnum.createOrgLabel), + labels.createOrgLabel +); + +authenticated.patch( + "/org/:orgId/label/:labelId", + verifyValidLicense, + verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), + verifyUserHasAction(ActionsEnum.updateOrgLabel), + labels.updateOrgLabel +); + +authenticated.delete( + "/org/:orgId/label/:labelId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteOrgLabel), + labels.deleteOrgLabel +); + +authenticated.put( + "/org/:orgId/label/:labelId/attach", + verifyValidLicense, + verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), + verifyUserHasAction(ActionsEnum.attachLabelToItem), + labels.attachLabelToItem +); + +authenticated.put( + "/org/:orgId/label/:labelId/detach", + verifyValidLicense, + verifyOrgAccess, + verifyValidSubscription(tierMatrix.labels), + 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 new file mode 100644 index 000000000..d011a606d --- /dev/null +++ b/server/private/routers/labels/attachLabelToItem.ts @@ -0,0 +1,224 @@ +/* + * 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 { + clients, + clientLabels, + db, + labels, + resourceLabels, + resources, + siteLabels, + siteResourceLabels, + siteResources, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, isNull } 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(), + siteResourceId: z.number().int().optional(), + clientId: 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, siteResourceId, clientId } = + parsedBody.data; + + if (!siteId && !resourceId && !siteResourceId && !clientId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` 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.` + ) + ); + } + + // idempotent, calling this endpoint multiple times should attach the label only once + await db + .insert(siteLabels) + .values({ + labelId, + siteId + }) + .onConflictDoNothing(); + } + + 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.` + ) + ); + } + + // idempotent, calling this endpoint multiple times should attach the label only once + await db + .insert(resourceLabels) + .values({ + labelId, + resourceId + }) + .onConflictDoNothing(); + } + + if (siteResourceId) { + const resourceCount = await db.$count( + siteResources, + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.orgId, orgId) + ) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `SiteResource with Id ${siteResourceId} doesn't exist.` + ) + ); + } + + // idempotent, calling this endpoint multiple times should attach the label only once + await db + .insert(siteResourceLabels) + .values({ + labelId, + siteResourceId + }) + .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, + error: false, + message: "Label attached 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 new file mode 100644 index 000000000..074a96207 --- /dev/null +++ b/server/private/routers/labels/createOrgLabel.ts @@ -0,0 +1,149 @@ +/* + * 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 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() +}); + +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, + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ); + + if (siteCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with Id ${siteId} doesn't exist.` + ) + ); + } + } + + 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.` + ) + ); + } + } + + 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/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/detachLabelFromItem.ts b/server/private/routers/labels/detachLabelFromItem.ts new file mode 100644 index 000000000..9a5545312 --- /dev/null +++ b/server/private/routers/labels/detachLabelFromItem.ts @@ -0,0 +1,224 @@ +/* + * 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 { + clients, + clientLabels, + db, + labels, + resourceLabels, + resources, + siteLabels, + siteResourceLabels, + siteResources, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, isNull } 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(), + siteResourceId: z.number().int().optional(), + clientId: 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, siteResourceId, clientId } = + parsedBody.data; + + if (!siteId && !resourceId && !siteResourceId && !clientId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` 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) + ) + ); + } + + if (siteResourceId) { + const resourceCount = await db.$count( + siteResources, + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.orgId, orgId) + ) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `SiteResource with Id ${siteResourceId} doesn't exist.` + ) + ); + } + + await db + .delete(siteResourceLabels) + .where( + and( + eq(siteResourceLabels.labelId, labelId), + eq(siteResourceLabels.siteResourceId, siteResourceId) + ) + ); + } + + 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, + 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 new file mode 100644 index 000000000..d988d8e38 --- /dev/null +++ b/server/private/routers/labels/index.ts @@ -0,0 +1,19 @@ +/* + * 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"; +export * from "./attachLabelToItem"; +export * from "./detachLabelFromItem"; +export * from "./deleteOrgLabel"; 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..eb5f5177a --- /dev/null +++ b/server/private/routers/labels/updateOrgLabel.ts @@ -0,0 +1,101 @@ +/* + * 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 { 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/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/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; +}; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c63bd965e..76084076c 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,8 +1,10 @@ import { browserGatewayTarget, db, + labels, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceLabels, resourcePassword, resourcePincode, resources, @@ -10,8 +12,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"; @@ -156,6 +161,7 @@ export type ResourceWithTargets = { siteNiceId: string; online?: boolean; // undefined for local sites }>; + labels?: Array>; }; function queryResourcesBase() { @@ -291,6 +297,11 @@ export async function listResources( ); } + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db @@ -328,24 +339,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)); } @@ -389,6 +382,32 @@ export async function listResources( .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); conditions.push(inArray(resources.resourceId, resourcesWithSite)); } + if (query) { + const q = "%" + query.toLowerCase() + "%"; + const queryList = [ + like(sql`LOWER(${resources.name})`, q), + like(sql`LOWER(${resources.niceId})`, q), + like(sql`LOWER(${resources.fullDomain})`, q) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) + ); + } + + conditions.push(or(...queryList)); + } const baseQuery = queryResourcesBase().where(and(...conditions)); @@ -410,6 +429,36 @@ 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) + ) + .orderBy(asc(resourceLabels.resourceLabelId)); + } + const allResourceTargets = resourceIdList.length === 0 ? [] @@ -486,7 +535,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/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index fc4ea5be1..af6514f02 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 @@ -187,7 +192,7 @@ const listSitesSchema = z.object({ function querySitesBase() { return db - .select({ + .selectDistinct({ siteId: sites.siteId, niceId: sites.niceId, name: sites.name, @@ -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<{ @@ -308,6 +314,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; @@ -319,33 +330,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)); } + if (query) { + const q = "%" + query.toLowerCase() + "%"; + const queryList = [ + like(sql`LOWER(${sites.name})`, q), + like(sql`LOWER(${sites.niceId})`, q) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( + 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().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase() - .where(and(...conditions)) - .as("filtered_sites") + querySitesBase().where(and(...conditions)).as("filtered_sites") ); const siteListQuery = baseQuery @@ -367,11 +388,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; + }> = []; + + 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)) + .orderBy(asc(siteLabels.siteLabelId)); + } + 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/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index c7099de40..ac243328c 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,14 @@ -import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; +import { + db, + DB_TYPE, + Label, + SiteResource, + siteNetworks, + siteResourceLabels, + siteResources, + sites, + labels +} from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -9,6 +19,8 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -69,16 +81,11 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ default: "asc", description: "Sort order" }), - siteId: z.coerce - .number() - .int() - .positive() - .optional() - .openapi({ - type: "integer", - description: - "When set, only site resources associated with this site (via network) are returned" - }) + siteId: z.coerce.number().int().positive().optional().openapi({ + type: "integer", + description: + "When set, only site resources associated with this site (via network) are returned" + }) }); export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ @@ -88,6 +95,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteNames: string[]; siteNiceIds: string[]; siteAddresses: (string | null)[]; + labels?: Array>; })[]; }>; @@ -234,6 +242,11 @@ export async function listAllSiteResourcesByOrg( const { page, pageSize, query, mode, sort_by, order, siteId } = parsedQuery.data; + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + const conditions = [and(eq(siteResources.orgId, orgId))]; if (siteId != null) { @@ -258,41 +271,41 @@ export async function listAllSiteResourcesByOrg( inArray(siteResources.siteResourceId, resourcesForSite) ); } - if (query) { - conditions.push( - or( - like( - sql`LOWER(${siteResources.name})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${siteResources.niceId})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${siteResources.destination})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${siteResources.alias})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${siteResources.aliasAddress})`, - "%" + query.toLowerCase() + "%" - ), - like( - sql`LOWER(${sites.name})`, - "%" + query.toLowerCase() + "%" - ) - ) - ); - } if (mode) { conditions.push(eq(siteResources.mode, mode)); } + if (query) { + const q = "%" + query.toLowerCase() + "%"; + const queryList = [ + like(sql`LOWER(${siteResources.name})`, q), + like(sql`LOWER(${siteResources.niceId})`, q), + like(sql`LOWER(${siteResources.destination})`, q), + like(sql`LOWER(${siteResources.alias})`, q), + like(sql`LOWER(${siteResources.aliasAddress})`, q), + like(sql`LOWER(${sites.name})`, q) + ]; + + if (isLabelFeatureEnabled) { + queryList.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) + ); + } + + conditions.push(or(...queryList)); + } + const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( @@ -315,11 +328,51 @@ export async function listAllSiteResourcesByOrg( countQuery ]); - const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow); + const siteResourcesList = siteResourcesRaw.map( + transformSiteResourceRow + ); + + const siteResourceIdList = siteResourcesList.map( + (r) => r.siteResourceId + ); + + let labelsForSiteResources: Array<{ + labelId: number; + name: string; + color: string; + siteResourceId: number; + }> = []; + + if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + labelsForSiteResources = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + siteResourceId: siteResourceLabels.siteResourceId + }) + .from(labels) + .innerJoin( + siteResourceLabels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIdList + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)); + } return response(res, { data: { - siteResources: siteResourcesList, + siteResources: siteResourcesList.map((r) => ({ + ...r, + labels: labelsForSiteResources.filter( + (l) => l.siteResourceId === r.siteResourceId + ) + })), pagination: { total: totalCount, pageSize, @@ -340,4 +393,4 @@ export async function listAllSiteResourcesByOrg( ) ); } -} \ No newline at end of file +} 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..806c60325 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/labels/page.tsx @@ -0,0 +1,63 @@ +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: "Labels" +}; + +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 searchParamsObj = new URLSearchParams(await searchParams); + + 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/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/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 { diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 42d4e69eb..ad661f55b 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -127,7 +127,8 @@ export default async function ClientResourcesPage( authDaemonPort: siteResource.authDaemonPort ?? null, subdomain: siteResource.subdomain ?? null, domainId: siteResource.domainId ?? null, - fullDomain: siteResource.fullDomain ?? null + fullDomain: siteResource.fullDomain ?? null, + labels: siteResource.labels ?? [] }; } ); diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 243067adf..1f43efba7 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 || 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/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/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 88b1e938e..156cc7f41 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -2,7 +2,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { DataTable } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; @@ -30,13 +29,21 @@ import { ChevronDown, ChevronsUpDownIcon, Funnel, - MoreHorizontal + MoreHorizontal, + PlusIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { useEffect, useMemo, useState, useTransition } from "react"; +import { + startTransition, + useEffect, + useMemo, + useOptimistic, + useState, + useTransition +} from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import type { PaginationState } from "@tanstack/react-table"; @@ -53,6 +60,10 @@ import { } from "@app/components/ResourceSitesStatusCell"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { build } from "@server/build"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { LabelBadge } from "./label-badge"; +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -84,6 +95,11 @@ export type InternalResourceRow = { subdomain?: string | null; domainId?: string | null; fullDomain?: string | null; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; function formatDestinationDisplay(row: InternalResourceRow): string { @@ -141,7 +157,10 @@ export default function ClientResourcesTable({ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [siteFilterOpen, setSiteFilterOpen] = useState(false); - const [isRefreshing, startTransition] = useTransition(); + const [isRefreshing, startRefreshTransition] = useTransition(); + + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); useEffect(() => { const interval = setInterval(() => { @@ -167,7 +186,7 @@ export default function ClientResourcesTable({ }, [initialFilterSite, siteIdQ, siteIdNum, t]); const refreshData = () => { - startTransition(() => { + startRefreshTransition(() => { try { router.refresh(); } catch (error) { @@ -185,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); }); @@ -254,296 +273,333 @@ export default function ClientResourcesTable({ ); } - const internalColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: t("name"), - header: () => { - const nameOrder = getSortDirection("name", searchParams); - const Icon = - nameOrder === "asc" - ? ArrowDown01Icon - : nameOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; + const internalColumns = useMemo< + ExtendedColumnDef[] + >(() => { + 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 ( - - ); - } - }, - { - 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 && ( + + )} + + + + + + + + +
+ ); +} 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..9e1ab12f5 --- /dev/null +++ b/src/components/CreateOrgLabelDialog.tsx @@ -0,0 +1,102 @@ +"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 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(); + + 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("createLabelDialogTitle")} + + {t("createLabelDialogDescription")} + + + + { + startTransition(async () => createOrgLabel(data)); + }} + /> + + + + + + + + + + ); +} 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/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index a70e6d8e5..8e113f340 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 && ( + + )} + + + + + + + + +
+ ); +} diff --git a/src/components/OrgLabelForm.tsx b/src/components/OrgLabelForm.tsx new file mode 100644 index 000000000..9bc81fa07 --- /dev/null +++ b/src/components/OrgLabelForm.tsx @@ -0,0 +1,126 @@ +"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; + defaultValue?: LabelFormData; +}; + +export function OrgLabelForm({ onSubmit, defaultValue }: 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: defaultValue?.name ?? "", + color: defaultValue?.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 new file mode 100644 index 000000000..3ed95a9dc --- /dev/null +++ b/src/components/OrgLabelsTable.tsx @@ -0,0 +1,240 @@ +"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"; +import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog"; +import { EditOrgLabelDialog } from "./EditOrgLabelDialog"; + +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 { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [selectedLabel, setSelectedLabel] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [isRefreshing, startTransition] = useTransition(); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + function refreshData() { + startTransition(async () => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + 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: () => { + return {t("name")}; + }, + cell: ({ row }) => ( +
+
+ + {row.original.name} +
+ ) + }, + { + accessorKey: "actions", + enableHiding: false, + header: () => { + return {t("actions")}; + }, + cell: ({ row }) => ( + + + + + + { + setSelectedLabel(row.original); + setIsEditModalOpen(true); + }} + > + {t("edit")} + + { + setSelectedLabel(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + ) + } + ], + [searchParams, t] + ); + + function deleteLabel(label: LabelRow) { + startTransition(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 ( + <> + {selectedLabel && ( + <> + { + 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()) + } + label={selectedLabel} + /> + + )} + + startTransition(() => router.refresh())} + /> + + setIsCreateModalOpen(true)} + tableId="org-labels-table" + searchPlaceholder={t("labelSearch")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} + /> + + ); +} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index a3280691f..da161a0ec 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"; @@ -43,6 +47,7 @@ import { Clock, Funnel, MoreHorizontal, + PlusIcon, ShieldCheck, ShieldOff, XCircle @@ -51,6 +56,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { + startTransition, useEffect, useMemo, useOptimistic, @@ -64,8 +70,8 @@ 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"; +import { LabelsSelector, type SelectedLabel } from "./labels-selector"; +import { LabelBadge } from "./label-badge"; export type TargetHealth = { targetId: number; @@ -98,31 +104,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; @@ -131,6 +119,11 @@ type ProxyResourcesTableProps = { initialFilterSite?: Selectedsite | null; }; +const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + export default function ProxyResourcesTable({ resources, orgId, @@ -154,6 +147,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); @@ -234,494 +230,441 @@ export default function ProxyResourcesTable({ } } - function TargetStatusCell({ - targets, - healthStatus - }: { - targets?: TargetHealth[]; - healthStatus?: string; - }) { - const overallStatus = healthStatus; + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; - if (!targets || targets.length === 0) { - return ( -
- - - {t("resourcesTableNoTargets")} - -
- ); - } + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - const unknownTargets = targets.filter( - (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" - ); + const siteFilterOpenRef = useRef(siteFilterOpen); + siteFilterOpenRef.current = siteFilterOpen; - 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 selectedSiteRef = useRef(selectedSite); + selectedSiteRef.current = selectedSite; - 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 clearSiteFilterRef = useRef(clearSiteFilter); + clearSiteFilterRef.current = clearSiteFilter; - 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 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; - if ( - !resourceRow.http || - resourceRow.browserAccessType !== "http" - ) { - return -; - } - 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; - if ( - !resourceRow.http || - resourceRow.browserAccessType !== "http" - ) { - return -; - } - 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; + if ( + !resourceRow.http || + resourceRow.browserAccessType !== "http" + ) { + return -; + } 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; + if ( + !resourceRow.http || + resourceRow.browserAccessType !== "http" + ) { + return -; + } + 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 }) => ( - - ) - }, - { - 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, @@ -738,16 +681,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); @@ -813,7 +746,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" /> @@ -821,6 +758,217 @@ 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 +}: { + 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: ( @@ -860,3 +1008,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; + } +} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 1e50b543f..4412ecccf 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 { @@ -26,30 +36,35 @@ import { ArrowUpRight, ChevronDown, ChevronsUpDownIcon, - MoreHorizontal + MoreHorizontal, + PlusIcon } 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, + useMemo, + 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 { 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"; + export type SiteRow = { id: number; nice: string; @@ -66,6 +81,11 @@ export type SiteRow = { exitNodeEndpoint?: string; remoteExitNodeId?: string; resourceCount: number; + labels?: Array<{ + labelId: number; + name: string; + color: string; + }>; }; type SitesTableProps = { @@ -96,6 +116,9 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const { isPaidUser } = usePaidStatus(); + const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -158,7 +181,8 @@ export default function SitesTable({ }); } - const columns: ExtendedColumnDef[] = [ + const columns = useMemo[]>(() => { + const cols: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, @@ -366,7 +390,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")} @@ -437,7 +461,7 @@ export default function SitesTable({ header: () => { return {t("address")}; }, - cell: ({ row }: { row: any }) => { + cell: ({ row }) => { const originalRow = row.original; return originalRow.address ? (
@@ -488,16 +512,6 @@ export default function SitesTable({ {t("sitesTableViewPrivateResources")} - { - setSelectedSite(siteRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - ( + + {t("labels")} + + ), + cell: ({ row }: { row: { original: SiteRow } }) => ( + + ) + }); + } + + return cols; + }, [isLabelFeatureEnabled, orgId, t, searchParams]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); @@ -622,7 +653,8 @@ export default function SitesTable({ niceId: false, nice: false, exitNode: false, - address: false + address: false, + labels: false }} enableColumnVisibility stickyLeftColumn="name" @@ -631,3 +663,102 @@ export default function SitesTable({ ); } + +type SiteLabelCellProps = { + site: SiteRow; + orgId: string; +}; + +function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + 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 ( +
+ {optimisticLabels.slice(0, 3).map((label) => ( + setIsPopoverOpen(true)} + {...label} + /> + ))} + {optimisticLabels.length > 3 && ( + + )} + + + + + + + + +
+ ); +} diff --git a/src/components/label-badge.tsx b/src/components/label-badge.tsx new file mode 100644 index 000000000..9d84cc350 --- /dev/null +++ b/src/components/label-badge.tsx @@ -0,0 +1,40 @@ +import { cn } from "@app/lib/cn"; +import { Button } from "./ui/button"; + +export type LabelBadgeProps = { + name: string; + color: string; + onClick?: () => void; + className?: string; +}; + +export function LabelBadge({ + onClick, + name, + color, + className +}: LabelBadgeProps) { + return ( + + ); +} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx new file mode 100644 index 000000000..94b8925b0 --- /dev/null +++ b/src/components/labels-selector.tsx @@ -0,0 +1,236 @@ +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 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, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; + +export type SelectedLabel = { + name: string; + color: string; + labelId: number; +}; + +export type LabelsSelectorProps = { + orgId: string; + selectedLabels: SelectedLabel[]; + toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; +}; + +export const LABEL_COLORS = { + red: "#ff6467", + green: "#05df72", + blue: "#51a2ff", + yellow: "#fdc744", + orange: "#ff8905", + purple: "#a684ff", + gray: "#b4b4b4" +}; + +export function LabelsSelector({ + orgId, + selectedLabels, + toggleLabel +}: LabelsSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const api = createApiClient(useEnvContext()); + + 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] + ); + + 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(); + try { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/labels`, { name, 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(""); + } + + return ( + + + + + {labelSearchQuery.trim().length > 0 ? ( +
+ + {t("createNewLabel", { + label: labelSearchQuery.trim() + })} + + +
+ + + + + +
+
+ ) : ( + t("labelsNotFound") + )} +
+ + {labelsShown.map((label) => ( + { + toggleLabel( + label, + selectedIds.has(label.labelId) + ? "detach" + : "attach" + ); + // } else { + // onSelectionChange([ + // ...selectedLabels, + // label + // ]); + // } + }} + > + {}} + aria-hidden + tabIndex={-1} + /> +
+ + + {label.name} + +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index 5634f7481..9791ccb94 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) => ( @@ -61,7 +61,9 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > - {option.text} + + {option.text} +
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,