From 102a2354075345c4c3b62ec4b64eb2e018be5e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:38 -0700 Subject: [PATCH 001/176] Adjust schema for many to one site resources --- server/db/pg/schema/schema.ts | 12 +++++++++--- server/db/sqlite/schema/schema.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b93c21fd6..685cca0f2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -216,9 +216,6 @@ export const exitNodes = pgTable("exitNodes", { export const siteResources = pgTable("siteResources", { // this is for the clients siteResourceId: serial("siteResourceId").primaryKey(), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), @@ -241,6 +238,15 @@ export const siteResources = pgTable("siteResources", { .default("site") }); +export const siteSiteResources = pgTable("siteSiteResources", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const clientSiteResources = pgTable("clientSiteResources", { clientId: integer("clientId") .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 188caac2b..20fca1c94 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -239,9 +239,6 @@ export const siteResources = sqliteTable("siteResources", { siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), @@ -266,6 +263,15 @@ export const siteResources = sqliteTable("siteResources", { .default("site") }); +export const siteSiteResources = sqliteTable("siteSiteResources", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const clientSiteResources = sqliteTable("clientSiteResources", { clientId: integer("clientId") .notNull() From d8b511b198759692f449b017bf1a770ed7b5540d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:49 -0700 Subject: [PATCH 002/176] Adjust create and update to be many to one --- .../siteResource/createSiteResource.ts | 46 +-- .../siteResource/updateSiteResource.ts | 296 ++++++++++-------- 2 files changed, 193 insertions(+), 149 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b9494776e..273c7c022 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -8,6 +8,7 @@ import { SiteResource, siteResources, sites, + siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -23,7 +24,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -37,7 +38,7 @@ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), - siteId: z.int(), + siteIds: z.array(z.int()), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), @@ -159,7 +160,7 @@ export async function createSiteResource( const { orgId } = parsedParams.data; const { name, - siteId, + siteIds, mode, // protocol, // proxyPort, @@ -178,14 +179,14 @@ export async function createSiteResource( } = parsedBody.data; // Verify the site exists and belongs to the org - const [site] = await db + const sitesToAssign = await db .select() .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))) .limit(1); - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + if (sitesToAssign.length !== siteIds.length) { + return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); } const [org] = await db @@ -289,7 +290,6 @@ export async function createSiteResource( await db.transaction(async (trx) => { // Create the site resource const insertValues: typeof siteResources.$inferInsert = { - siteId, niceId, orgId, name, @@ -317,6 +317,13 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// + for (const siteId of siteIds) { + await trx.insert(siteSiteResources).values({ + siteId: siteId, + siteResourceId: siteResourceId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -359,17 +366,18 @@ export async function createSiteResource( ); } - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + // Not sure what this is doing?? + // const [newt] = await trx + // .select() + // .from(newts) + // .where(eq(newts.siteId, site.siteId)) + // .limit(1); - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); - } + // if (!newt) { + // return next( + // createHttpError(HttpCode.NOT_FOUND, "Newt not found") + // ); + // } await rebuildClientAssociationsFromSiteResource( newSiteResource, @@ -387,7 +395,7 @@ export async function createSiteResource( } logger.info( - `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` + `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` ); return response(res, { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 596ed9a3f..f22c5a047 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -9,6 +9,7 @@ import { roles, roleSiteResources, sites, + siteSiteResources, Transaction, userSiteResources } from "@server/db"; @@ -16,7 +17,7 @@ import { siteResources, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, inArray } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -42,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - siteId: z.int(), + siteIds: z.array(z.int()), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr"]).optional(), @@ -166,7 +167,7 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteId, // because it can change + siteIds, // because it can change mode, destination, alias, @@ -181,16 +182,6 @@ export async function updateSiteResource( authDaemonMode } = parsedBody.data; - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); - } - // Check if site resource exists const [existingSiteResource] = await db .select() @@ -230,6 +221,24 @@ export async function updateSiteResource( ); } + // Verify the site exists and belongs to the org + const sitesToAssign = await db + .select() + .from(sites) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.orgId, existingSiteResource.orgId) + ) + ) + .limit(1); + + if (sitesToAssign.length !== siteIds.length) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); + } + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) @@ -247,25 +256,20 @@ export async function updateSiteResource( ); } - let existingSite = site; - let siteChanged = false; - if (existingSiteResource.siteId !== siteId) { - siteChanged = true; - // get the existing site - [existingSite] = await db - .select() - .from(sites) - .where(eq(sites.siteId, existingSiteResource.siteId)) - .limit(1); + let sitesChanged = false; + const existingSiteIds = await db + .select() + .from(siteSiteResources) + .where(eq(siteSiteResources.siteResourceId, siteResourceId)); - if (!existingSite) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Existing site not found" - ) - ); - } + const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); + const newSiteIdSet = new Set(siteIds); + + if ( + existingSiteIdSet.size !== newSiteIdSet.size || + ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) + ) { + sitesChanged = true; } // make sure the alias is unique within the org if provided @@ -295,7 +299,7 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (siteChanged) { + if (sitesChanged) { // delete the existing site resource await trx .delete(siteResources) @@ -321,7 +325,8 @@ export async function updateSiteResource( const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -335,7 +340,6 @@ export async function updateSiteResource( .update(siteResources) .set({ name: name, - siteId: siteId, mode: mode, destination: destination, enabled: enabled, @@ -423,7 +427,8 @@ export async function updateSiteResource( // Update the site resource const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -437,7 +442,6 @@ export async function updateSiteResource( .update(siteResources) .set({ name: name, - siteId: siteId, mode: mode, destination: destination, enabled: enabled, @@ -454,6 +458,20 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteSiteResources) + .where( + eq(siteSiteResources.siteResourceId, siteResourceId) + ); + + for (const siteId of siteIds) { + await trx.insert(siteSiteResources).values({ + siteId: siteId, + siteResourceId: siteResourceId + }); + } + await trx .delete(clientSiteResources) .where( @@ -524,13 +542,16 @@ export async function updateSiteResource( } logger.info( - `Updated site resource ${siteResourceId} for site ${siteId}` + `Updated site resource ${siteResourceId}` ); await handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, - { siteId: site.siteId, orgId: site.orgId }, + siteIds.map((siteId) => ({ + siteId, + orgId: existingSiteResource.orgId + })), trx ); } @@ -557,7 +578,7 @@ export async function updateSiteResource( export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, - site: { siteId: number; orgId: string }, + sites: { siteId: number; orgId: string }[], trx: Transaction ) { logger.debug( @@ -594,101 +615,116 @@ export async function handleMessagingForUpdatedSiteResource( // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all if (destinationChanged || aliasChanged || portRangesChanged) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged) { - const oldTargets = generateSubnetProxyTargets( - existingSiteResource, - mergedAllClients - ); - const newTargets = generateSubnetProxyTargets( - updatedSiteResource, - mergedAllClients - ); - - await updateTargets(newt.newtId, { - oldTargets: oldTargets, - newTargets: newTargets - }, newt.version); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed - const oldDestinationStillInUseSites = await trx + for (const site of sites) { + const [newt] = await trx .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if destination changed + if (destinationChanged || portRangesChanged) { + const oldTargets = generateSubnetProxyTargets( + existingSiteResource, + mergedAllClients + ); + const newTargets = generateSubnetProxyTargets( + updatedSiteResource, + mergedAllClients ); - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets, + newTargets: newTargets + }, + newt.version + ); + } - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - client.clientId, - updatedSiteResource.siteId, - destinationChanged - ? { - oldRemoteSubnets: !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); + const olmJobs: Promise[] = []; + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + // todo: optimize this query if needed + const oldDestinationStillInUseSites = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteSiteResources, + eq( + siteSiteResources.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteSiteResources.siteId, site.siteId), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + + const oldDestinationStillInUseByASite = + oldDestinationStillInUseSites.length > 0; + + // we also need to update the remote subnets on the olms for each client that has access to this site + olmJobs.push( + updatePeerData( + client.clientId, + site.siteId, + destinationChanged + ? { + oldRemoteSubnets: + !oldDestinationStillInUseByASite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliasChanged + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + ) + ); + } + + await Promise.all(olmJobs); } - - await Promise.all(olmJobs); } } From 7cbe3d42a14bf88176233a07d7988bbf71937b22 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 12:10:04 -0700 Subject: [PATCH 003/176] Working on refactoring --- server/lib/blueprints/applyBlueprint.ts | 56 ++++---- server/lib/blueprints/clientResources.ts | 125 +++++++++++++----- server/lib/blueprints/types.ts | 3 +- .../siteResource/createSiteResource.ts | 27 ++-- .../siteResource/listAllSiteResourcesByOrg.ts | 20 +-- 5 files changed, 156 insertions(+), 75 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index a304bb392..fd189e6ca 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -121,8 +121,8 @@ export async function applyBlueprint({ for (const result of clientResourcesResults) { if ( result.oldSiteResource && - result.oldSiteResource.siteId != - result.newSiteResource.siteId + JSON.stringify(result.newSites?.sort()) !== + JSON.stringify(result.oldSites?.sort()) ) { // query existing associations const existingRoleIds = await trx @@ -222,38 +222,46 @@ export async function applyBlueprint({ trx ); } else { - const [newSite] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, result.newSiteResource.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) + let good = true; + for (const newSite of result.newSites) { + const [site] = await trx + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, newSite.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) ) - ) - .limit(1); + .limit(1); + + if (!site) { + logger.debug( + `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + ); + good = false; + break; + } - if (!newSite) { logger.debug( - `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}` ); - continue; } - logger.debug( - `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` - ); + if (!good) { + continue; + } await handleMessagingForUpdatedSiteResource( result.oldSiteResource, result.newSiteResource, - { - siteId: newSite.sites.siteId, - orgId: newSite.sites.orgId - }, + result.newSites.map((site) => ({ + siteId: site.siteId, + orgId: result.newSiteResource.orgId + })), trx ); } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 80c691c63..2ad36cd9f 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -3,8 +3,10 @@ import { clientSiteResources, roles, roleSiteResources, + Site, SiteResource, siteResources, + siteSiteResources, Transaction, userOrgs, users, @@ -19,6 +21,8 @@ import { getNextAvailableAliasAddress } from "../ip"; export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; + newSites: { siteId: number }[]; + oldSites: { siteId: number }[]; }[]; export async function updateClientResources( @@ -43,36 +47,75 @@ export async function updateClientResources( ) .limit(1); - const resourceSiteId = resourceData.site; - let site; - - if (resourceSiteId) { - // Look up site by niceId - [site] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where( - and( - eq(sites.niceId, resourceSiteId), - eq(sites.orgId, orgId) + const existingSiteIds = await trx + .select({ siteId: sites.siteId }) + .from(siteSiteResources) + .where( + and( + eq( + siteSiteResources.siteResourceId, + existingResource.siteResourceId ) ) - .limit(1); - } else if (siteId) { - // Use the provided siteId directly, but verify it belongs to the org - [site] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); - } else { - throw new Error(`Target site is required`); + ); + + let allSites: { siteId: number }[] = []; + if (resourceData.site) { + let siteSingle; + const resourceSiteId = resourceData.site; + + if (resourceSiteId) { + // Look up site by niceId + [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, resourceSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!siteSingle) { + throw new Error( + `Site not found: ${resourceSiteId} in org ${orgId}` + ); + } + allSites.push(siteSingle); } - if (!site) { - throw new Error( - `Site not found: ${resourceSiteId} in org ${orgId}` - ); + if (resourceData.sites) { + for (const siteNiceId of resourceData.sites) { + const [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, siteNiceId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + if (!site) { + throw new Error( + `Site not found: ${siteId} in org ${orgId}` + ); + } + allSites.push(site); + } } if (existingResource) { @@ -81,7 +124,6 @@ export async function updateClientResources( .update(siteResources) .set({ name: resourceData.name || resourceNiceId, - siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, enabled: true, // hardcoded for now @@ -102,6 +144,17 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; + await trx + .delete(siteSiteResources) + .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + + for (const site of allSites) { + await trx.insert(siteSiteResources).values({ + siteId: site.siteId, + siteResourceId: siteResourceId + }); + } + await trx .delete(clientSiteResources) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); @@ -204,7 +257,9 @@ export async function updateClientResources( results.push({ newSiteResource: updatedResource, - oldSiteResource: existingResource + oldSiteResource: existingResource, + newSites: allSites, + oldSites: existingSiteIds }); } else { let aliasAddress: string | null = null; @@ -218,7 +273,6 @@ export async function updateClientResources( .insert(siteResources) .values({ orgId: orgId, - siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, @@ -235,6 +289,13 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; + for (const site of allSites) { + await trx.insert(siteSiteResources).values({ + siteId: site.siteId, + siteResourceId: siteResourceId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -324,7 +385,11 @@ export async function updateClientResources( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); - results.push({ newSiteResource: newResource }); + results.push({ + newSiteResource: newResource, + newSites: allSites, + oldSites: existingSiteIds + }); } } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 2239e4f9a..efbdb3891 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -312,7 +312,8 @@ export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr"]), - site: z.string(), + site: z.string(), // DEPRECATED IN FAVOR OF sites + sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 273c7c022..4fa8c9960 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -366,18 +366,23 @@ export async function createSiteResource( ); } - // Not sure what this is doing?? - // const [newt] = await trx - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); + for (const siteToAssign of sitesToAssign) { + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteToAssign.siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt not found for site ${siteToAssign.siteId}` + ) + ); + } + } - // if (!newt) { - // return next( - // createHttpError(HttpCode.NOT_FOUND, "Newt not found") - // ); - // } await rebuildClientAssociationsFromSiteResource( newSiteResource, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3320aa3b7..40736f7c0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites } from "@server/db"; +import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -73,9 +73,9 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { - siteName: string; - siteNiceId: string; - siteAddress: string | null; + siteNames: string[]; + siteNiceIds: string[]; + siteAddresses: (string | null)[]; })[]; }>; @@ -83,7 +83,6 @@ function querySiteResourcesBase() { return db .select({ siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, orgId: siteResources.orgId, niceId: siteResources.niceId, name: siteResources.name, @@ -100,14 +99,17 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address + siteNames: sql`array_agg(${sites.name})`, + siteNiceIds: sql`array_agg(${sites.niceId})`, + siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); + .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) + .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .groupBy(siteResources.siteResourceId); } + registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", From b7421e47ccf8da49cd6e334f6324b70c9dd307e6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:22:04 -0700 Subject: [PATCH 004/176] Switch to using networks --- server/db/pg/schema/schema.ts | 31 ++++++++++++++++++++++++++----- server/db/sqlite/schema/schema.ts | 25 ++++++++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 685cca0f2..d76f4241d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -81,6 +81,10 @@ export const sites = pgTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + networkId: integer("networkId").references( + () => networks.networkId, + { onDelete: "set null" } + ), name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), @@ -219,6 +223,16 @@ export const siteResources = pgTable("siteResources", { orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references( + () => networks.networkId, + { onDelete: "set null" } + ), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { + onDelete: "restrict" + } + ), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" @@ -238,13 +252,19 @@ export const siteResources = pgTable("siteResources", { .default("site") }); -export const siteSiteResources = pgTable("siteSiteResources", { - siteId: integer("siteId") +export const networks = pgTable("networks", { + networkId: serial("networkId").primaryKey(), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + scope: varchar("scope") + .$type<"global" | "resource">() .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") + .default("global"), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const clientSiteResources = pgTable("clientSiteResources", { @@ -1080,3 +1100,4 @@ export type RequestAuditLog = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type Network = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 20fca1c94..c1555d7ee 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -82,6 +82,9 @@ export const sites = sqliteTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), name: text("name").notNull(), pubKey: text("pubKey"), subnet: text("subnet"), @@ -242,6 +245,13 @@ export const siteResources = sqliteTable("siteResources", { orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { onDelete: "restrict" } + ), niceId: text("niceId").notNull(), name: text("name").notNull(), mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" @@ -263,13 +273,17 @@ export const siteResources = sqliteTable("siteResources", { .default("site") }); -export const siteSiteResources = sqliteTable("siteSiteResources", { - siteId: integer("siteId") +export const networks = sqliteTable("networks", { + networkId: integer("networkId").primaryKey({ autoIncrement: true }), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + scope: text("scope") + .$type<"global" | "resource">() .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") + .default("global"), + orgId: text("orgId") .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) + .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const clientSiteResources = sqliteTable("clientSiteResources", { @@ -1164,6 +1178,7 @@ export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type SiteResource = InferSelectModel; +export type Network = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; From 6f2e37948c089b40877155cbad3d4b278d649cfe Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:30:00 -0700 Subject: [PATCH 005/176] Its many to one now --- server/db/pg/schema/schema.ts | 22 ++++++++++++++-------- server/db/sqlite/schema/schema.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d76f4241d..d4817283c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -81,10 +81,6 @@ export const sites = pgTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), - networkId: integer("networkId").references( - () => networks.networkId, - { onDelete: "set null" } - ), name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), @@ -223,10 +219,9 @@ export const siteResources = pgTable("siteResources", { orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - networkId: integer("networkId").references( - () => networks.networkId, - { onDelete: "set null" } - ), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), defaultNetworkId: integer("defaultNetworkId").references( () => networks.networkId, { @@ -267,6 +262,17 @@ export const networks = pgTable("networks", { .notNull() }); +export const siteNetworks = pgTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) +}); + export const clientSiteResources = pgTable("clientSiteResources", { clientId: integer("clientId") .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c1555d7ee..2578e236d 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -286,6 +286,17 @@ export const networks = sqliteTable("networks", { .references(() => orgs.orgId, { onDelete: "cascade" }) }); +export const siteNetworks = sqliteTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) +}); + export const clientSiteResources = sqliteTable("clientSiteResources", { clientId: integer("clientId") .notNull() From 2093bb5357ca4beb70a98235119b28796b7dda2b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:44:59 -0700 Subject: [PATCH 006/176] Remove siteSiteResources --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/blueprints/clientResources.ts | 55 +++++++++++-------- .../siteResource/createSiteResource.ts | 30 ++++++++-- .../siteResource/listAllSiteResourcesByOrg.ts | 8 ++- .../siteResource/updateSiteResource.ts | 34 +++++++----- 6 files changed, 87 insertions(+), 48 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d4817283c..bb4a096df 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", { export const networks = pgTable("networks", { networkId: serial("networkId").primaryKey(), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: varchar("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2578e236d..c28816883 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", { export const networks = sqliteTable("networks", { networkId: integer("networkId").primaryKey({ autoIncrement: true }), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: text("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 2ad36cd9f..42c3b76da 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -5,12 +5,13 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, - siteSiteResources, Transaction, userOrgs, users, - userSiteResources + userSiteResources, + networks } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne, inArray, or } from "drizzle-orm"; @@ -47,17 +48,12 @@ export async function updateClientResources( ) .limit(1); - const existingSiteIds = await trx - .select({ siteId: sites.siteId }) - .from(siteSiteResources) - .where( - and( - eq( - siteSiteResources.siteResourceId, - existingResource.siteResourceId - ) - ) - ); + const existingSiteIds = existingResource?.networkId + ? await trx + .select({ siteId: sites.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, existingResource.networkId)) + : []; let allSites: { siteId: number }[] = []; if (resourceData.site) { @@ -144,15 +140,19 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; - await trx - .delete(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + if (updatedResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedResource.networkId) + ); - for (const site of allSites) { - await trx.insert(siteSiteResources).values({ - siteId: site.siteId, - siteResourceId: siteResourceId - }); + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: updatedResource.networkId + }); + } } await trx @@ -268,12 +268,21 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, niceId: resourceNiceId, + networkId: network.networkId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, @@ -290,9 +299,9 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; for (const site of allSites) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: site.siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4fa8c9960..720b55f6c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -5,10 +5,11 @@ import { orgs, roles, roleSiteResources, + siteNetworks, + networks, SiteResource, siteResources, sites, - siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -186,7 +187,9 @@ export async function createSiteResource( .limit(1); if (sitesToAssign.length !== siteIds.length) { - return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); } const [org] = await db @@ -288,11 +291,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + + if (!network) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Failed to create network` + ) + ); + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { niceId, orgId, name, + networkId: network.networkId, mode: mode as "host" | "cidr", destination, enabled, @@ -318,9 +339,9 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } @@ -383,7 +404,6 @@ export async function createSiteResource( } } - await rebuildClientAssociationsFromSiteResource( newSiteResource, trx diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 40736f7c0..2d90d69e0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; +import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -99,13 +99,15 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + networkId: siteResources.networkId, + defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) - .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index f22c5a047..338957249 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -8,8 +8,9 @@ import { orgs, roles, roleSiteResources, + siteNetworks, sites, - siteSiteResources, + networks, Transaction, userSiteResources } from "@server/db"; @@ -257,10 +258,14 @@ export async function updateSiteResource( } let sitesChanged = false; - const existingSiteIds = await db - .select() - .from(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + const existingSiteIds = existingSiteResource.networkId + ? await db + .select() + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, existingSiteResource.networkId) + ) + : []; const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); const newSiteIdSet = new Set(siteIds); @@ -460,15 +465,17 @@ export async function updateSiteResource( // delete the site - site resources associations await trx - .delete(siteSiteResources) + .delete(siteNetworks) .where( - eq(siteSiteResources.siteResourceId, siteResourceId) + eq(siteNetworks.networkId, updatedSiteResource.networkId!) + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that ); for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: updatedSiteResource.networkId! + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that }); } @@ -664,10 +671,11 @@ export async function handleMessagingForUpdatedSiteResource( ) ) .innerJoin( - siteSiteResources, + siteNetworks, eq( - siteSiteResources.siteResourceId, - siteResources.siteResourceId + siteNetworks.networkId, + siteResources.networkId + // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that ) ) .where( @@ -676,7 +684,7 @@ export async function handleMessagingForUpdatedSiteResource( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteSiteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( siteResources.destination, existingSiteResource.destination From 87524fe8aefad5285cc261bd492afb9e3d575422 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Mar 2026 21:44:59 -0700 Subject: [PATCH 007/176] Remove siteSiteResources --- server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/blueprints/clientResources.ts | 56 +++++++++++-------- .../siteResource/createSiteResource.ts | 30 ++++++++-- .../siteResource/listAllSiteResourcesByOrg.ts | 8 ++- .../siteResource/updateSiteResource.ts | 31 +++++----- 6 files changed, 85 insertions(+), 48 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index d4817283c..bb4a096df 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", { export const networks = pgTable("networks", { networkId: serial("networkId").primaryKey(), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: varchar("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2578e236d..c28816883 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", { export const networks = sqliteTable("networks", { networkId: integer("networkId").primaryKey({ autoIncrement: true }), - niceId: text("niceId").notNull(), - name: text("name").notNull(), + niceId: text("niceId"), + name: text("name"), scope: text("scope") .$type<"global" | "resource">() .notNull() diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 2ad36cd9f..dd609936d 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -5,12 +5,13 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, - siteSiteResources, Transaction, userOrgs, users, - userSiteResources + userSiteResources, + networks } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne, inArray, or } from "drizzle-orm"; @@ -47,17 +48,12 @@ export async function updateClientResources( ) .limit(1); - const existingSiteIds = await trx - .select({ siteId: sites.siteId }) - .from(siteSiteResources) - .where( - and( - eq( - siteSiteResources.siteResourceId, - existingResource.siteResourceId - ) - ) - ); + const existingSiteIds = existingResource?.networkId + ? await trx + .select({ siteId: sites.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, existingResource.networkId)) + : []; let allSites: { siteId: number }[] = []; if (resourceData.site) { @@ -144,15 +140,19 @@ export async function updateClientResources( const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; - await trx - .delete(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + if (updatedResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedResource.networkId) + ); - for (const site of allSites) { - await trx.insert(siteSiteResources).values({ - siteId: site.siteId, - siteResourceId: siteResourceId - }); + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: updatedResource.networkId + }); + } } await trx @@ -268,12 +268,22 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, niceId: resourceNiceId, + networkId: network.networkId, + defaultNetworkId: network.networkId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, @@ -290,9 +300,9 @@ export async function updateClientResources( const siteResourceId = newResource.siteResourceId; for (const site of allSites) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: site.siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4fa8c9960..720b55f6c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -5,10 +5,11 @@ import { orgs, roles, roleSiteResources, + siteNetworks, + networks, SiteResource, siteResources, sites, - siteSiteResources, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; @@ -186,7 +187,9 @@ export async function createSiteResource( .limit(1); if (sitesToAssign.length !== siteIds.length) { - return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found")); + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); } const [org] = await db @@ -288,11 +291,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + + if (!network) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Failed to create network` + ) + ); + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { niceId, orgId, name, + networkId: network.networkId, mode: mode as "host" | "cidr", destination, enabled, @@ -318,9 +339,9 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: network.networkId }); } @@ -383,7 +404,6 @@ export async function createSiteResource( } } - await rebuildClientAssociationsFromSiteResource( newSiteResource, trx diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 40736f7c0..2d90d69e0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db"; +import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -99,13 +99,15 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + networkId: siteResources.networkId, + defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) - .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId)) - .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId)) + .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index f22c5a047..f7e1262bb 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -8,8 +8,9 @@ import { orgs, roles, roleSiteResources, + siteNetworks, sites, - siteSiteResources, + networks, Transaction, userSiteResources } from "@server/db"; @@ -257,10 +258,14 @@ export async function updateSiteResource( } let sitesChanged = false; - const existingSiteIds = await db - .select() - .from(siteSiteResources) - .where(eq(siteSiteResources.siteResourceId, siteResourceId)); + const existingSiteIds = existingSiteResource.networkId + ? await db + .select() + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, existingSiteResource.networkId) + ) + : []; const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); const newSiteIdSet = new Set(siteIds); @@ -460,15 +465,15 @@ export async function updateSiteResource( // delete the site - site resources associations await trx - .delete(siteSiteResources) + .delete(siteNetworks) .where( - eq(siteSiteResources.siteResourceId, siteResourceId) + eq(siteNetworks.networkId, updatedSiteResource.networkId!) ); for (const siteId of siteIds) { - await trx.insert(siteSiteResources).values({ + await trx.insert(siteNetworks).values({ siteId: siteId, - siteResourceId: siteResourceId + networkId: updatedSiteResource.networkId! }); } @@ -664,10 +669,10 @@ export async function handleMessagingForUpdatedSiteResource( ) ) .innerJoin( - siteSiteResources, + siteNetworks, eq( - siteSiteResources.siteResourceId, - siteResources.siteResourceId + siteNetworks.networkId, + siteResources.networkId ) ) .where( @@ -676,7 +681,7 @@ export async function handleMessagingForUpdatedSiteResource( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteSiteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( siteResources.destination, existingSiteResource.destination From a1ce7f54a0e511b0ffec46e09241588cd0cd4687 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 20 Mar 2026 09:17:10 -0700 Subject: [PATCH 008/176] Continue to rebase --- server/routers/site/deleteSite.ts | 27 +++++++++++-------- .../siteResource/deleteSiteResource.ts | 21 ++++++++------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 587572535..344f6b4e3 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Site, siteResources } from "@server/db"; +import { db, Site, siteNetworks, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -71,18 +71,23 @@ export async function deleteSite( await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // delete all of the site resources on this site - const siteResourcesOnSite = trx - .delete(siteResources) - .where(eq(siteResources.siteId, siteId)) - .returning(); + const networks = await trx + .select({ networkId: siteNetworks.networkId }) + .from(siteNetworks) + .where(eq(siteNetworks.siteId, siteId)); // loop through them - for (const removedSiteResource of await siteResourcesOnSite) { - await rebuildClientAssociationsFromSiteResource( - removedSiteResource, - trx - ); + for (const network of await networks) { + const [siteResource] = await trx + .select() + .from(siteResources) + .where(eq(siteResources.networkId, network.networkId)); + if (siteResource) { + await rebuildClientAssociationsFromSiteResource( + siteResource, + trx + ); + } } // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 5b50b0ea3..8d08d545d 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -70,17 +70,18 @@ export async function deleteSiteResource( .where(and(eq(siteResources.siteResourceId, siteResourceId))) .returning(); - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, removedSiteResource.siteId)) - .limit(1); + // not sure why this is here... + // const [newt] = await trx + // .select() + // .from(newts) + // .where(eq(newts.siteId, removedSiteResource.siteId)) + // .limit(1); - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); - } + // if (!newt) { + // return next( + // createHttpError(HttpCode.NOT_FOUND, "Newt not found") + // ); + // } await rebuildClientAssociationsFromSiteResource( removedSiteResource, From d85496453f63e9a420c4dce307eb8538563eef7a Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 21 Mar 2026 10:40:12 -0700 Subject: [PATCH 009/176] Change SSH WIP --- server/private/routers/ssh/signSshKey.ts | 129 ++++++++++++----------- 1 file changed, 70 insertions(+), 59 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 5cffb4a34..b9b6fed1a 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -21,7 +21,7 @@ import { roles, roundTripMessageTracker, siteResources, - sites, + siteNetworks, userOrgs } from "@server/db"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -62,11 +62,11 @@ const bodySchema = z export type SignSshKeyResponse = { certificate: string; - messageId: number; + messageIds: number[]; sshUsername: string; sshHost: string; resourceId: number; - siteId: number; + siteIds: number[]; keyId: string; validPrincipals: string[]; validAfter: string; @@ -250,10 +250,7 @@ export async function signSshKey( .update(userOrgs) .set({ pamUsername: usernameToUse }) .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, userId) - ) + and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)) ); } else { usernameToUse = userOrg.pamUsername; @@ -374,21 +371,12 @@ export async function signSshKey( const homedir = roleRow?.sshCreateHomeDir ?? null; const sudoMode = roleRow?.sshSudoMode ?? "none"; - // get the site - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, resource.siteId)) - .limit(1); + const sites = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId!)); - if (!newt) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Site associated with resource not found" - ) - ); - } + const siteIds = sites.map((site) => site.siteId); // Sign the public key const now = BigInt(Math.floor(Date.now() / 1000)); @@ -402,43 +390,64 @@ export async function signSshKey( validBefore: now + validFor }); - const [message] = await db - .insert(roundTripMessageTracker) - .values({ - wsClientId: newt.newtId, - messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000) - }) - .returning(); + const messageIds: number[] = []; + for (const siteId of siteIds) { + // get the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (!message) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create message tracker entry" - ) - ); - } - - await sendToClient(newt.newtId, { - type: `newt/pam/connection`, - data: { - messageId: message.messageId, - orgId: orgId, - agentPort: resource.authDaemonPort ?? 22123, - externalAuthDaemon: resource.authDaemonMode === "remote", - agentHost: resource.destination, - caCert: caKeys.publicKeyOpenSSH, - username: usernameToUse, - niceId: resource.niceId, - metadata: { - sudoMode: sudoMode, - sudoCommands: parsedSudoCommands, - homedir: homedir, - groups: parsedGroups - } + if (!newt) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site associated with resource not found" + ) + ); } - }); + + const [message] = await db + .insert(roundTripMessageTracker) + .values({ + wsClientId: newt.newtId, + messageType: `newt/pam/connection`, + sentAt: Math.floor(Date.now() / 1000) + }) + .returning(); + + if (!message) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create message tracker entry" + ) + ); + } + + messageIds.push(message.messageId); + + await sendToClient(newt.newtId, { + type: `newt/pam/connection`, + data: { + messageId: message.messageId, + orgId: orgId, + agentPort: resource.authDaemonPort ?? 22123, + externalAuthDaemon: resource.authDaemonMode === "remote", + agentHost: resource.destination, + caCert: caKeys.publicKeyOpenSSH, + username: usernameToUse, + niceId: resource.niceId, + metadata: { + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups + } + } + }); + } const expiresIn = Number(validFor); // seconds @@ -459,18 +468,20 @@ export async function signSshKey( metadata: JSON.stringify({ resourceId: resource.siteResourceId, resource: resource.name, - siteId: resource.siteId, + siteIds: siteIds }) }); + // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS + return response(res, { data: { certificate: cert.certificate, - messageId: message.messageId, + messageIds: messageIds, sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, - siteId: resource.siteId, + siteIds: siteIds, keyId: cert.keyId, validPrincipals: cert.validPrincipals, validAfter: cert.validAfter.toISOString(), From c48bc714430a6e52b5f39ec24f04c28413f7e7ef Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:18:34 -0700 Subject: [PATCH 010/176] Update crud endpoints and ui --- server/private/routers/ssh/signSshKey.ts | 6 +- .../routers/siteResource/getSiteResource.ts | 13 +-- .../siteResource/listAllSiteResourcesByOrg.ts | 2 + .../routers/siteResource/listSiteResources.ts | 17 +++- .../settings/resources/client/page.tsx | 8 +- src/components/ClientResourcesTable.tsx | 85 +++++++++++++++---- 6 files changed, 94 insertions(+), 37 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index b9b6fed1a..46976bb1d 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -63,10 +63,12 @@ const bodySchema = z export type SignSshKeyResponse = { certificate: string; messageIds: number[]; + messageId: number; sshUsername: string; sshHost: string; resourceId: number; siteIds: number[]; + siteId: number; keyId: string; validPrincipals: string[]; validAfter: string; @@ -472,16 +474,16 @@ export async function signSshKey( }) }); - // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS - return response(res, { data: { certificate: cert.certificate, messageIds: messageIds, + messageId: messageIds[0], // just pick the first one for backward compatibility sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, siteIds: siteIds, + siteId: siteIds[0], // just pick the first one for backward compatibility keyId: cert.keyId, validPrincipals: cert.validPrincipals, validAfter: cert.validAfter.toISOString(), diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index be28d36e4..2e3dfe87b 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({ .transform((val) => (val ? Number(val) : undefined)) .pipe(z.int().positive().optional()) .optional(), - siteId: z.string().transform(Number).pipe(z.int().positive()), niceId: z.string().optional(), orgId: z.string() }); async function query( siteResourceId?: number, - siteId?: number, niceId?: string, orgId?: string ) { - if (siteResourceId && siteId && orgId) { + if (siteResourceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .limit(1); return siteResource; - } else if (niceId && siteId && orgId) { + } else if (niceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.niceId, niceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) @@ -84,7 +80,6 @@ registry.registerPath({ request: { params: z.object({ niceId: z.string(), - siteId: z.number(), orgId: z.string() }) }, @@ -107,10 +102,10 @@ export async function getSiteResource( ); } - const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; + const { siteResourceId, niceId, orgId } = parsedParams.data; // Get the site resource - const siteResource = await query(siteResourceId, siteId, niceId, orgId); + const siteResource = await query(siteResourceId, niceId, orgId); if (!siteResource) { return next( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 2d90d69e0..759b06d4d 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -73,6 +73,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { + siteIds: number[]; siteNames: string[]; siteNiceIds: string[]; siteAddresses: (string | null)[]; @@ -103,6 +104,7 @@ function querySiteResourcesBase() { defaultNetworkId: siteResources.defaultNetworkId, siteNames: sql`array_agg(${sites.name})`, siteNiceIds: sql`array_agg(${sites.niceId})`, + siteIds: sql`array_agg(${sites.siteId})`, siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})` }) .from(siteResources) diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 358aa0497..8a1469f76 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, networks, siteNetworks } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -108,13 +108,21 @@ export async function listSiteResources( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } - // Get site resources + // Get site resources by joining networks to siteResources via siteNetworks const siteResourcesList = await db .select() - .from(siteResources) + .from(siteNetworks) + .innerJoin( + networks, + eq(siteNetworks.networkId, networks.networkId) + ) + .innerJoin( + siteResources, + eq(siteResources.networkId, networks.networkId) + ) .where( and( - eq(siteResources.siteId, siteId), + eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId) ) ) @@ -128,6 +136,7 @@ export async function listSiteResources( .limit(limit) .offset(offset); + return response(res, { data: { siteResources: siteResourcesList }, success: true, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f0f582f0f..8ba3e29e6 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -60,17 +60,17 @@ export default async function ClientResourcesPage( id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, - siteName: siteResource.siteName, - siteAddress: siteResource.siteAddress || null, + siteNames: siteResource.siteNames, + siteAddresses: siteResource.siteAddresses || null, mode: siteResource.mode || ("port" as any), // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, - siteId: siteResource.siteId, + siteIds: siteResource.siteIds, destination: siteResource.destination, // destinationPort: siteResource.destinationPort, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, - siteNiceId: siteResource.siteNiceId, + siteNiceIds: siteResource.siteNiceIds, niceId: siteResource.niceId, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5066f273d..a45dc944e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -21,6 +21,7 @@ import { ArrowUp10Icon, ArrowUpDown, ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -43,14 +44,14 @@ export type InternalResourceRow = { id: number; name: string; orgId: string; - siteName: string; - siteAddress: string | null; + siteNames: string[]; + siteAddresses: (string | null)[]; + siteIds: number[]; + siteNiceIds: string[]; // mode: "host" | "cidr" | "port"; mode: "host" | "cidr"; // protocol: string | null; // proxyPort: number | null; - siteId: number; - siteNiceId: string; destination: string; // destinationPort: number | null; alias: string | null; @@ -136,6 +137,60 @@ export default function ClientResourcesTable({ } }; + function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) { + const { siteNames, siteNiceIds, orgId } = resourceRow; + + if (!siteNames || siteNames.length === 0) { + return -; + } + + if (siteNames.length === 1) { + return ( + + + + ); + } + + return ( + + + + + + {siteNames.map((siteName, idx) => ( + + + {siteName} + + + + ))} + + + ); + } + const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -185,21 +240,11 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", + accessorKey: "siteNames", friendlyName: t("site"), header: () => {t("site")}, cell: ({ row }) => { - const resourceRow = row.original; - return ( - - - - ); + return ; } }, { @@ -399,7 +444,7 @@ export default function ClientResourcesTable({ onConfirm={async () => deleteInternalResource( selectedInternalResource!.id, - selectedInternalResource!.siteId + selectedInternalResource!.siteIds[0] ) } string={selectedInternalResource.name} @@ -433,7 +478,11 @@ export default function ClientResourcesTable({ { From c4f48f5748af5eba3045ccba01a56d3f0cba78bf Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:29:47 -0700 Subject: [PATCH 011/176] WIP - more conversion --- .../handleOlmServerInitAddPeerHandshake.ts | 240 +++++++++--------- .../olm/handleOlmServerPeerAddMessage.ts | 42 ++- 2 files changed, 135 insertions(+), 147 deletions(-) diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index 54badb2dc..0eda41e04 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -4,10 +4,12 @@ import { db, exitNodes, Site, - siteResources + siteNetworks, + siteResources, + sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Olm, sites } from "@server/db"; +import { clients, Olm } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import logger from "@server/logger"; import { initPeerAddHandshake } from "./peers"; @@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( const { siteId, resourceId, chainId } = message.data; - let site: Site | null = null; + const sendCancel = async () => { + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { chainId } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + }; + + let sitesToProcess: Site[] = []; + if (siteId) { - // get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (siteRes) { - site = siteRes; + sitesToProcess = [siteRes]; } - } - - if (resourceId && !site) { + } else if (resourceId) { const resources = await db .select() .from(siteResources) @@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( ); if (!resources || resources.length === 0) { - logger.error(`handleOlmServerPeerAddMessage: Resource not found`); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + logger.error( + `handleOlmServerInitAddPeerHandshake: Resource not found` + ); + await sendCancel(); return; } if (resources.length > 1) { // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches logger.error( - `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` + `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` ); return; } @@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( if (currentResourceAssociationCaches.length === 0) { logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + await sendCancel(); return; } - const siteIdFromResource = resource.siteId; - - // get the site - const [siteRes] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteIdFromResource)); - if (!siteRes) { + if (!resource.networkId) { logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site} not found` + `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` ); + await sendCancel(); return; } - site = siteRes; + // Get all sites associated with this resource's network via siteNetworks + const siteRows = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId)); + + if (!siteRows || siteRows.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` + ); + await sendCancel(); + return; + } + + // Fetch full site objects for all network members + const foundSites = await Promise.all( + siteRows.map(async ({ siteId: sid }) => { + const [s] = await db + .select() + .from(sites) + .where(eq(sites.siteId, sid)) + .limit(1); + return s ?? null; + }) + ); + + sitesToProcess = foundSites.filter((s): s is Site => s !== null); } - if (!site) { - logger.error(`handleOlmServerPeerAddMessage: Site not found`); + if (sitesToProcess.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites to process` + ); + await sendCancel(); return; } - // check if the client can access this site using the cache - const currentSiteAssociationCaches = await db - .select() - .from(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.clientId, client.clientId), - eq(clientSitesAssociationsCache.siteId, site.siteId) - ) - ); + let handshakeInitiated = false; - if (currentSiteAssociationCaches.length === 0) { - logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, + for (const site of sitesToProcess) { + // Check if the client can access this site using the cache + const currentSiteAssociationCaches = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) + ) + ); + + if (currentSiteAssociationCaches.length === 0) { + logger.warn( + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping` + ); + continue; + } + + if (!site.exitNodeId) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` + ); + continue; + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)); + + if (!exitNode) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` + ); + continue; + } + + // Trigger the peer add handshake — if the peer was already added this will be a no-op + await initPeerAddHandshake( + client.clientId, { - type: "olm/wg/peer/chain/cancel", - data: { - chainId + siteId: site.siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint } }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - if (!site.exitNodeId) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - // get the exit node from the side - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)); - - if (!exitNode) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` + chainId ); - return; + + handshakeInitiated = true; } - // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch - // if it has already been added this will be a no-op - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { - siteId: site.siteId, - exitNode: { - publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint - } - }, - olm.olmId, - chainId - ); + if (!handshakeInitiated) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` + ); + await sendCancel(); + } return; -}; +}; \ No newline at end of file diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 64284f493..5f46ea84c 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -1,43 +1,25 @@ import { - Client, clientSiteResourcesAssociationsCache, db, - ExitNode, - Org, - orgs, - roleClients, - roles, + networks, + siteNetworks, siteResources, - Transaction, - userClients, - userOrgs, - users } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, - olms, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; -import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateAliasConfig, - getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; -import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { addPeer as newtAddPeer, - deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( @@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .where( + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, and( - eq(siteResources.siteId, site.siteId), - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(networks.networkId, siteNetworks.networkId), + eq(siteNetworks.siteId, site.siteId) + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId ) ); From 1366901e24c38e12bd8d96e46f45dd51946e75a2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Mar 2026 14:40:57 -0700 Subject: [PATCH 012/176] Adjust build functions --- server/routers/newt/buildConfiguration.ts | 9 +++++++-- server/routers/olm/buildConfiguration.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index c3a261f03..875a42c7e 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -4,8 +4,10 @@ import { clientSitesAssociationsCache, db, ExitNode, + networks, resources, Site, + siteNetworks, siteResources, targetHealthCheck, targets @@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient( // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); - // Get all enabled site resources for this site + // Get all enabled site resources for this site by joining through siteNetworks and networks const allSiteResources = await db .select() .from(siteResources) - .where(eq(siteResources.siteId, siteId)); + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) + .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) + .where(eq(siteNetworks.siteId, siteId)) + .then((rows) => rows.map((r) => r.siteResources)); const targetsToSend: SubnetProxyTarget[] = []; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index bc2611b1c..4182725d3 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -4,6 +4,8 @@ import { clientSitesAssociationsCache, db, exitNodes, + networks, + siteNetworks, siteResources, sites } from "@server/db"; @@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient( clientSiteResourcesAssociationsCache.siteResourceId ) ) + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, + eq(networks.networkId, siteNetworks.networkId) + ) .where( and( - eq(siteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId @@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient( ) ); + if (jitMode) { // Add site configuration to the array siteConfigurations.push({ From b648aa605c21b83fc701bc3b0264699914bc7634 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 18:50:09 +0100 Subject: [PATCH 013/176] =?UTF-8?q?=F0=9F=94=A7=20un=20comment=20volumes?= =?UTF-8?q?=20in=20docker=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.pgr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 9e6b2c5af..764c09150 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - # volumes: - # - ./config/postgres:/var/lib/postgresql/data + volumes: + - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no From 02033f611f102bd1dfd1bab99b82b1cacea2a1c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 23 Mar 2026 11:44:02 -0700 Subject: [PATCH 014/176] First pass at HA --- server/lib/rebuildClientAssociations.ts | 581 +++++++++++++++--------- 1 file changed, 355 insertions(+), 226 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 121e2c7f0..ece603916 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -11,6 +11,7 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, sites, Transaction, @@ -47,15 +48,23 @@ export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db ) { - // get the site - const [site] = await trx - .select() - .from(sites) - .where(eq(sites.siteId, siteResource.siteId)) - .limit(1); + // get all sites associated with this siteResource via its network + const sitesList = siteResource.networkId + ? await trx + .select() + .from(sites) + .innerJoin( + siteNetworks, + eq(siteNetworks.siteId, sites.siteId) + ) + .where(eq(siteNetworks.networkId, siteResource.networkId)) + .then((rows) => rows.map((row) => row.sites)) + : []; - if (!site) { - throw new Error(`Site with ID ${siteResource.siteId} not found`); + if (sitesList.length === 0) { + logger.warn( + `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` + ); } const roleIds = await trx @@ -136,7 +145,7 @@ export async function getClientSiteResourceAccess( const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); return { - site, + sitesList, mergedAllClients, mergedAllClientIds }; @@ -152,40 +161,51 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { - const siteId = siteResource.siteId; - - const { site, mergedAllClients, mergedAllClientIds } = + const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); /////////// process the client-siteResource associations /////////// - // get all of the clients associated with other resources on this site - const allUpdatedClientsFromOtherResourcesOnThisSite = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .innerJoin( - siteResources, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq(siteResources.siteId, siteId), - ne(siteResources.siteResourceId, siteResource.siteResourceId) - ) - ); + // get all of the clients associated with other resources in the same network, + // joined through siteNetworks so we know which siteId each client belongs to + const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId + ? await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq(siteResources.networkId, siteResource.networkId), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ) + : []; - const allClientIdsFromOtherResourcesOnThisSite = Array.from( - new Set( - allUpdatedClientsFromOtherResourcesOnThisSite.map( - (row) => row.clientId - ) - ) - ); + // Build a per-site map so the loop below can check by siteId rather than + // across the entire network. + const clientsFromOtherResourcesBySite = new Map>(); + for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) { + if (!clientsFromOtherResourcesBySite.has(row.siteId)) { + clientsFromOtherResourcesBySite.set(row.siteId, new Set()); + } + clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); + } const existingClientSiteResources = await trx .select({ @@ -259,82 +279,90 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-site associations /////////// - const existingClientSites = await trx - .select({ - clientId: clientSitesAssociationsCache.clientId - }) - .from(clientSitesAssociationsCache) - .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); + for (const site of sitesList) { + const siteId = site.siteId; - const existingClientSiteIds = existingClientSites.map( - (row) => row.clientId - ); + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); - // Get full client details for existing clients (needed for sending delete messages) - const existingClients = await trx - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .where(inArray(clients.clientId, existingClientSiteIds)); + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); - const clientSitesToAdd = mergedAllClientIds.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = + existingClientSiteIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where(inArray(clients.clientId, existingClientSiteIds)) + : []; - const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ - clientId, - siteId - })); + const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); - if (clientSitesToInsert.length > 0) { - await trx - .insert(clientSitesAssociationsCache) - .values(clientSitesToInsert) - .returning(); - } + const clientSitesToAdd = mergedAllClientIds.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource + ); - // Now remove any client-site associations that should no longer exist - const clientSitesToRemove = existingClientSiteIds.filter( - (clientId) => - !mergedAllClientIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); - if (clientSitesToRemove.length > 0) { - await trx - .delete(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.siteId, siteId), - inArray( - clientSitesAssociationsCache.clientId, - clientSitesToRemove + if (clientSitesToInsert.length > 0) { + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .returning(); + } + + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !mergedAllClientIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource + ); + + if (clientSitesToRemove.length > 0) { + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) ) - ) - ); + ); + } + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); } - /////////// send the messages /////////// - - // Now handle the messages to add/remove peers on both the newt and olm sides - await handleMessagesForSiteClients( - site, - siteId, - mergedAllClients, - existingClients, - clientSitesToAdd, - clientSitesToRemove, - trx - ); - // Handle subnet proxy target updates for the resource associations await handleSubnetProxyTargetUpdates( siteResource, + sitesList, mergedAllClients, existingResourceClients, clientSiteResourcesToAdd, @@ -623,6 +651,7 @@ export async function updateClientSiteDestinations( async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, + sitesList: Site[], allClients: { clientId: number; pubKey: string | null; @@ -637,131 +666,144 @@ async function handleSubnetProxyTargetUpdates( clientSiteResourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Get the newt for this site - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteResource.siteId)) - .limit(1); + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; - if (!newt) { - logger.warn( - `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` - ); - return; - } + for (const siteData of sitesList) { + const siteId = siteData.siteId; - const proxyJobs = []; - const olmJobs = []; - // Generate targets for added associations - if (clientSiteResourcesToAdd.length > 0) { - const addedClients = allClients.filter((client) => - clientSiteResourcesToAdd.includes(client.clientId) - ); + // Get the newt for this site + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (addedClients.length > 0) { - const targetsToAdd = generateSubnetProxyTargets( - siteResource, - addedClients + if (!newt) { + logger.warn( + `Newt not found for site ${siteId}, skipping subnet proxy target updates` ); - - if (targetsToAdd.length > 0) { - logger.info( - `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targetsToAdd, - newt.version - ) - ); - } - - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, - siteResource.siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + continue; } - } - // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here - - // Generate targets for removed associations - if (clientSiteResourcesToRemove.length > 0) { - const removedClients = existingClients.filter((client) => - clientSiteResourcesToRemove.includes(client.clientId) - ); - - if (removedClients.length > 0) { - const targetsToRemove = generateSubnetProxyTargets( - siteResource, - removedClients + // Generate targets for added associations + if (clientSiteResourcesToAdd.length > 0) { + const addedClients = allClients.filter((client) => + clientSiteResourcesToAdd.includes(client.clientId) ); - if (targetsToRemove.length > 0) { - logger.info( - `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` + if (addedClients.length > 0) { + const targetsToAdd = generateSubnetProxyTargets( + siteResource, + addedClients ); - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targetsToRemove, - newt.version - ) - ); - } - for (const client of removedClients) { - // Check if this client still has access to another resource on this site with the same destination - const destinationStillInUse = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, siteResource.siteId), - eq( - siteResources.destination, - siteResource.destination - ), - ne( - siteResources.siteResourceId, - siteResource.siteResourceId - ) + if (targetsToAdd.length > 0) { + logger.info( + `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}` + ); + proxyJobs.push( + addSubnetProxyTargets( + newt.newtId, + targetsToAdd, + newt.version ) ); + } - // Only remove remote subnet if no other resource uses the same destination - const remoteSubnetsToRemove = - destinationStillInUse.length > 0 - ? [] - : generateRemoteSubnets([siteResource]); + for (const client of addedClients) { + olmJobs.push( + addPeerData( + client.clientId, + siteId, + generateRemoteSubnets([siteResource]), + generateAliasConfig([siteResource]) + ) + ); + } + } + } - olmJobs.push( - removePeerData( - client.clientId, - siteResource.siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) + // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here + + // Generate targets for removed associations + if (clientSiteResourcesToRemove.length > 0) { + const removedClients = existingClients.filter((client) => + clientSiteResourcesToRemove.includes(client.clientId) + ); + + if (removedClients.length > 0) { + const targetsToRemove = generateSubnetProxyTargets( + siteResource, + removedClients ); + + if (targetsToRemove.length > 0) { + logger.info( + `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}` + ); + proxyJobs.push( + removeSubnetProxyTargets( + newt.newtId, + targetsToRemove, + newt.version + ) + ); + } + + for (const client of removedClients) { + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site — a resource on a different + // site sharing the same network should not block removal here. + const destinationStillInUse = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteNetworks.siteId, siteId), + eq( + siteResources.destination, + siteResource.destination + ), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ); + + // Only remove remote subnet if no other resource uses the same destination + const remoteSubnetsToRemove = + destinationStillInUse.length > 0 + ? [] + : generateRemoteSubnets([siteResource]); + + olmJobs.push( + removePeerData( + client.clientId, + siteId, + remoteSubnetsToRemove, + generateAliasConfig([siteResource]) + ) + ); + } } } } @@ -868,10 +910,25 @@ export async function rebuildClientAssociationsFromClient( ) : []; - // Group by siteId for site-level associations - const newSiteIds = Array.from( - new Set(newSiteResources.map((sr) => sr.siteId)) + // Group by siteId for site-level associations — look up via siteNetworks since + // siteResources no longer carries a direct siteId column. + const networkIds = Array.from( + new Set( + newSiteResources + .map((sr) => sr.networkId) + .filter((id): id is number => id !== null) + ) ); + const newSiteIds = + networkIds.length > 0 + ? await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, networkIds)) + .then((rows) => + Array.from(new Set(rows.map((r) => r.siteId))) + ) + : []; /////////// Process client-siteResource associations /////////// @@ -1144,13 +1201,45 @@ async function handleMessagesForClientResources( resourcesToAdd.includes(r.siteResourceId) ); + // Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId + const addedNetworkIds = Array.from( + new Set( + addedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const addedSiteNetworkRows = + addedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, addedNetworkIds)) + : []; + const addedNetworkToSites = new Map(); + for (const row of addedSiteNetworkRows) { + if (!addedNetworkToSites.has(row.networkId)) { + addedNetworkToSites.set(row.networkId, []); + } + addedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const addedBySite = new Map(); for (const resource of addedResources) { - if (!addedBySite.has(resource.siteId)) { - addedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (addedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!addedBySite.has(siteId)) { + addedBySite.set(siteId, []); + } + addedBySite.get(siteId)!.push(resource); } - addedBySite.get(resource.siteId)!.push(resource); } // Add subnet proxy targets for each site @@ -1192,7 +1281,7 @@ async function handleMessagesForClientResources( olmJobs.push( addPeerData( client.clientId, - resource.siteId, + siteId, generateRemoteSubnets([resource]), generateAliasConfig([resource]) ) @@ -1204,7 +1293,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition` ); } else { throw error; @@ -1221,13 +1310,45 @@ async function handleMessagesForClientResources( .from(siteResources) .where(inArray(siteResources.siteResourceId, resourcesToRemove)); + // Build (resource, siteId) pairs via siteNetworks + const removedNetworkIds = Array.from( + new Set( + removedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const removedSiteNetworkRows = + removedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, removedNetworkIds)) + : []; + const removedNetworkToSites = new Map(); + for (const row of removedSiteNetworkRows) { + if (!removedNetworkToSites.has(row.networkId)) { + removedNetworkToSites.set(row.networkId, []); + } + removedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const removedBySite = new Map(); for (const resource of removedResources) { - if (!removedBySite.has(resource.siteId)) { - removedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (removedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!removedBySite.has(siteId)) { + removedBySite.set(siteId, []); + } + removedBySite.get(siteId)!.push(resource); } - removedBySite.get(resource.siteId)!.push(resource); } // Remove subnet proxy targets for each site @@ -1265,7 +1386,11 @@ async function handleMessagesForClientResources( } try { - // Check if this client still has access to another resource on this site with the same destination + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site — a resource on a different + // site sharing the same network should not block removal here. const destinationStillInUse = await trx .select() .from(siteResources) @@ -1276,13 +1401,17 @@ async function handleMessagesForClientResources( siteResources.siteResourceId ) ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteResources.siteId, resource.siteId), + eq(siteNetworks.siteId, siteId), eq( siteResources.destination, resource.destination @@ -1304,7 +1433,7 @@ async function handleMessagesForClientResources( olmJobs.push( removePeerData( client.clientId, - resource.siteId, + siteId, remoteSubnetsToRemove, generateAliasConfig([resource]) ) @@ -1316,7 +1445,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal` ); } else { throw error; From 6d0e10a4aa799b9531f0f427a1fbb01b22968ce0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 20:02:53 +0100 Subject: [PATCH 015/176] =?UTF-8?q?=F0=9F=9A=A7=20=20user=20table=20pagina?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/user/listUsers.ts | 127 +++++++++---- .../[orgId]/settings/access/users/page.tsx | 16 +- src/components/UsersTable.tsx | 168 +++++++++++------- 3 files changed, 205 insertions(+), 106 deletions(-) diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 40ca7ef2f..86fc9b770 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -5,33 +5,67 @@ import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, asc, desc, like, or, sql, type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { eq } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() }); const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .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() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["username"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -async function queryUsers(orgId: string, limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -54,16 +88,12 @@ async function queryUsers(orgId: string, limit: number, offset: number) { .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) - .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where(eq(userOrgs.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)); } -export type ListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; -}; +export type ListUsersResponse = PaginatedResponse<{ + users: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -92,7 +122,7 @@ export async function listUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, sort_by, order, query } = parsedQuery.data; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -106,24 +136,57 @@ export async function listUsers( const { orgId } = parsedParams.data; - const usersWithRoles = await queryUsers( - orgId.toString(), - limit, - offset + const conditions = [and(eq(userOrgs.orgId, orgId))]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${users.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.username})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + const countQuery = db.$count( + queryUsersBase() + .where(and(...conditions)) + .as("filtered_users") ); - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); + const userListQuery = queryUsersBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.name) + ); + + const [count, usersWithRoles] = await Promise.all([ + countQuery, + userListQuery + ]); return response(res, { data: { users: usersWithRoles, pagination: { total: count, - limit, - offset + page, + pageSize } }, success: true, diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..5297e747e 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -3,40 +3,46 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import UsersTable, { UserRow } from "../../../../../components/UsersTable"; +import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type UsersPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function UsersPage(props: UsersPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - const getUser = cache(verifySession); - const user = await getUser(); + const user = await verifySession(); const t = await getTranslations(); let users: ListUsersResponse["users"] = []; + let pagination: ListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/users`, await authCookieHeader()) + >(`/org/${params.orgId}/users?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { users = res.data.data.users; + pagination = res.data.data.pagination; } const invitationsRes = await internal diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9b1dfee68..c6a02ab69 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, type PaginationState } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -9,14 +9,22 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ArrowUpDown, + ChevronsUpDownIcon, + Crown, + MoreHorizontal +} from "lucide-react"; import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useTransition } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; @@ -24,6 +32,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "./IdpTypeBadge"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import type { filter } from "d3"; +import { useDebouncedCallback } from "use-debounce"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; export type UserRow = { id: string; @@ -42,39 +55,44 @@ export type UserRow = { type UsersTableProps = { users: UserRow[]; + pagination: PaginationState; + rowCount: number; }; -export default function UsersTable({ users: u }: UsersTableProps) { +export default function UsersTable({ + users, + pagination, + rowCount +}: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); const router = useRouter(); const api = createApiClient(useEnvContext()); const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); - - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setUsers(u); - }, [u]); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [isRefreshing, startTransition] = useTransition(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const columns: ExtendedColumnDef[] = [ @@ -83,15 +101,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { enableHiding: false, friendlyName: t("username"), header: ({ column }) => { + const nameOrder = getSortDirection("username", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -100,17 +124,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "idpName", friendlyName: t("identityProvider"), header: ({ column }) => { - return ( - - ); + return {t("identityProvider")}; }, cell: ({ row }) => { const userRow = row.original; @@ -127,17 +141,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "role", friendlyName: t("role"), header: ({ column }) => { - return ( - - ); + return {t("role")}; }, cell: ({ row }) => { const userRow = row.original; @@ -184,9 +188,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { isDisabled && e.preventDefault() } > - + {t("accessUsersManage")} @@ -218,10 +220,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -256,15 +255,36 @@ export default function UsersTable({ users: u }: UsersTableProps) { email: selectedUser.email || "" }) }); - - setUsers((prev) => - prev.filter((u) => u.id !== selectedUser?.id) - ); } } + router.refresh(); setIsDeleteModalOpen(false); } + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> } buttonText={t("userRemoveOrgConfirm")} - onConfirm={removeUser} + onConfirm={async () => startTransition(removeUser)} string={ selectedUser ? getUserDisplayName({ @@ -293,12 +313,22 @@ export default function UsersTable({ users: u }: UsersTableProps) { title={t("userRemoveOrg")} /> - { - router.push( - `/${org?.org.orgId}/settings/access/users/create` + pagination={pagination} + rowCount={rowCount} + isNavigatingToAddPage={isNavigatingToAddPage} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + rows={users} + searchPlaceholder={t("accessUsersSearch")} + tableId="users-table" + onAdd={() => { + startNavigation(() => + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ) ); }} onRefresh={refreshData} From 0461b5a764f8919392dd226f812220d05bd6ae5e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:09:49 +0100 Subject: [PATCH 016/176] =?UTF-8?q?=E2=9C=A8=20finish=20users=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/access/users/page.tsx | 9 ++++++++- src/components/UsersTable.tsx | 5 ++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 5297e747e..d44082b13 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -107,7 +107,14 @@ export default async function UsersPage(props: UsersPageProps) { /> - + diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index c6a02ab69..ee376f8ba 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -68,12 +68,11 @@ export default function UsersTable({ const [selectedUser, setSelectedUser] = useState(null); const router = useRouter(); const api = createApiClient(useEnvContext()); - const { user, updateUser } = useUserContext(); + const { user } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isRefreshing, startTransition] = useTransition(); - const pathname = usePathname(); const { navigate: filter, isNavigating: isFiltering, @@ -332,7 +331,7 @@ export default function UsersTable({ ); }} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} /> ); From 062bec23b68c3d38b01ee9e05799e975eae0c5bd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:11:12 +0100 Subject: [PATCH 017/176] =?UTF-8?q?=F0=9F=8C=90=20update=20translation=20f?= =?UTF-8?q?or=20single=20user=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/UsersTable.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index dc84ec405..7107a00fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -262,6 +262,7 @@ "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", + "accessUserManage": "Manage User", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", "accessUserCreate": "Create User", diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index ee376f8ba..9da0aa1ca 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -188,7 +188,7 @@ export default function UsersTable({ } > - {t("accessUsersManage")} + {t("accessUserManage")} {!isDisabled && ( From 294532ecbb4757a360fa84e775974abfb12496b1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:34:44 +0100 Subject: [PATCH 018/176] =?UTF-8?q?=E2=9C=A8=20roles=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/role/listRoles.ts | 121 +++++++++++++----- .../[orgId]/settings/access/roles/page.tsx | 19 ++- src/components/RolesDataTable.tsx | 41 ------ src/components/RolesTable.tsx | 82 ++++++++++-- src/components/UsersDataTable.tsx | 41 ------ src/components/UsersTable.tsx | 35 +++-- 6 files changed, 187 insertions(+), 152 deletions(-) delete mode 100644 src/components/RolesDataTable.tsx delete mode 100644 src/components/UsersDataTable.tsx diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index f1b057a11..ba46e40c4 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -3,34 +3,68 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; import { ActionsEnum } from "@server/auth/actions"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { object, z } from "zod"; import { fromError } from "zod-validation-error"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); const listRolesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .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() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -async function queryRoles(orgId: string, limit: number, offset: number) { - return await db +function queryRolesBase() { + return db .select({ roleId: roles.roleId, orgId: roles.orgId, @@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) { sshUnixGroups: roles.sshUnixGroups }) .from(roles) - .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) - .where(eq(roles.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(orgs, eq(roles.orgId, orgs.orgId)); + // .where(eq(roles.orgId, orgId)) + // .limit(limit) + // .offset(offset); } -export type ListRolesResponse = { - roles: NonNullable>>; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; +export type ListRolesResponse = PaginatedResponse<{ + roles: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -88,7 +117,7 @@ export async function listRoles( ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, sort_by, order } = parsedQuery.data; const parsedParams = listRolesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -102,14 +131,36 @@ export async function listRoles( const { orgId } = parsedParams.data; - const countQuery: any = db - .select({ count: sql`cast(count(*) as integer)` }) - .from(roles) - .where(eq(roles.orgId, orgId)); + const conditions = [and(eq(roles.orgId, orgId))]; - const rolesList = await queryRoles(orgId, limit, offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (query) { + conditions.push( + like(sql`LOWER(${roles.name})`, "%" + query.toLowerCase() + "%") + ); + } + + const countQuery = db.$count( + queryRolesBase() + .where(and(...conditions)) + .as("filtered_roles") + ); + + const rolesListQuery = queryRolesBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(roles[sort_by]) + : desc(roles[sort_by]) + : asc(roles.name) + ); + + const [totalCount, rolesList] = await Promise.all([ + countQuery, + rolesListQuery + ]); let rolesWithAllowSsh = rolesList; if (rolesList.length > 0) { @@ -135,8 +186,8 @@ export async function listRoles( roles: rolesWithAllowSsh, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 7165d9e6c..a2d415be4 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -11,24 +11,32 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type RolesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function RolesPage(props: RolesPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let roles: ListRolesResponse["roles"] = []; + let pagination: ListRolesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/roles`, await authCookieHeader()) + >(`/org/${params.orgId}/roles?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { roles = res.data.data.roles; + pagination = res.data.data.pagination; } const invitationsRes = await internal @@ -63,7 +71,14 @@ export default async function RolesPage(props: RolesPageProps) { description={t("accessRolesDescription")} /> - + ); diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx deleted file mode 100644 index 5a2d1cb4c..000000000 --- a/src/components/RolesDataTable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createRole?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function RolesDataTable({ - columns, - data, - createRole, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index bf17f63f7..75a484ab0 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -7,7 +7,13 @@ import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { toast } from "@app/hooks/useToast"; import { Role } from "@server/db"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpDown, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; @@ -18,24 +24,40 @@ import { DropdownMenuItem } from "./ui/dropdown-menu"; import EditRoleForm from "./EditRoleForm"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { useDebouncedCallback } from "use-debounce"; export type RoleRow = Role; type RolesTableProps = { roles: RoleRow[]; + pagination: PaginationState; + rowCount: number; }; -export default function UsersTable({ roles }: RolesTableProps) { +export default function UsersTable({ + roles, + pagination, + rowCount +}: RolesTableProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const router = useRouter(); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [roleToRemove, setRoleToRemove] = useState(null); const t = useTranslations(); - const [isRefreshing, startTransition] = useTransition(); const refreshData = async () => { console.log("Data refreshed"); @@ -56,15 +78,17 @@ export default function UsersTable({ roles }: RolesTableProps) { enableHiding: false, friendlyName: t("name"), header: ({ column }) => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( - ); } @@ -148,6 +172,30 @@ export default function UsersTable({ roles }: RolesTableProps) { } ]; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> {editingRole && ( @@ -191,10 +239,18 @@ export default function UsersTable({ roles }: RolesTableProps) { /> )} - { + rows={roles} + tableId="roles-table" + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + searchPlaceholder={t("accessRolesSearch")} + addButtonText={t("accessRolesAdd")} + rowCount={rowCount} + pagination={pagination} + onAdd={() => { setIsCreateModalOpen(true); }} onRefresh={() => startTransition(refreshData)} diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx deleted file mode 100644 index ececa4c17..000000000 --- a/src/components/UsersDataTable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - inviteUser?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - inviteUser, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9da0aa1ca..163274877 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,7 @@ "use client"; -import { ColumnDef, type PaginationState } from "@tanstack/react-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -8,35 +9,29 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, ArrowRight, ArrowUp10Icon, - ArrowUpDown, ChevronsUpDownIcon, - Crown, MoreHorizontal } from "lucide-react"; -import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect, useTransition } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { toast } from "@app/hooks/useToast"; -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import IdpTypeBadge from "./IdpTypeBadge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import type { filter } from "d3"; -import { useDebouncedCallback } from "use-debounce"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; -import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; export type UserRow = { id: string; From efb2e78d9d50eec9f8dfa8f23b7d0fb33c05d057 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 23 Mar 2026 21:34:58 +0100 Subject: [PATCH 019/176] =?UTF-8?q?=F0=9F=90=9B=20fix=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RolesTable.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index 75a484ab0..9eb3c7a12 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -2,33 +2,31 @@ import CreateRoleForm from "@app/components/CreateRoleForm"; import DeleteRoleForm from "@app/components/DeleteRoleForm"; -import { RolesDataTable } from "@app/components/RolesDataTable"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { Role } from "@server/db"; +import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, ArrowUp10Icon, - ArrowUpDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import EditRoleForm from "./EditRoleForm"; +import { ControlledDataTable } from "./ui/controlled-data-table"; import { DropdownMenu, - DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuItem + DropdownMenuItem, + DropdownMenuTrigger } from "./ui/dropdown-menu"; -import EditRoleForm from "./EditRoleForm"; -import type { PaginationState } from "@tanstack/react-table"; -import { ControlledDataTable } from "./ui/controlled-data-table"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; -import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; -import { useDebouncedCallback } from "use-debounce"; export type RoleRow = Role; From 2841c5ed4e867a34e8a23ac7700834dfb2a74845 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 29 Mar 2026 14:19:26 -0700 Subject: [PATCH 020/176] basic rules --- messages/en-US.json | 68 + src/app/[orgId]/settings/alerting/page.tsx | 24 + src/app/navigation.tsx | 6 + src/components/AlertRuleCredenza.tsx | 1431 ++++++++++++++++++++ src/components/AlertingRulesTable.tsx | 297 ++++ src/lib/alertRulesLocalStorage.ts | 129 ++ 6 files changed, 1955 insertions(+) create mode 100644 src/app/[orgId]/settings/alerting/page.tsx create mode 100644 src/components/AlertRuleCredenza.tsx create mode 100644 src/components/AlertingRulesTable.tsx create mode 100644 src/lib/alertRulesLocalStorage.ts diff --git a/messages/en-US.json b/messages/en-US.json index 7a3fde1d4..b2f750cb1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1321,10 +1321,78 @@ "sidebarGeneral": "Manage", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarAlerting": "Alerting", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", + "alertingTitle": "Alerting rules", + "alertingDescription": "Define sources, triggers, and actions for notifications. Rules are stored locally in this browser until server-side alerting is available.", + "alertingRules": "Alert rules", + "alertingSearchRules": "Search rules…", + "alertingAddRule": "Create rule", + "alertingColumnSource": "Source", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Actions", + "alertingColumnEnabled": "Enabled", + "alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.", + "alertingDeleteRule": "Delete alert rule", + "alertingRuleDeleted": "Alert rule deleted", + "alertingRuleSaved": "Alert rule saved", + "alertingEditRule": "Edit alert rule", + "alertingCreateRule": "Create alert rule", + "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.", + "alertingRuleNamePlaceholder": "Production site down", + "alertingRuleEnabled": "Rule enabled", + "alertingSectionSource": "Source", + "alertingSourceType": "Source type", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Health check", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Health checks", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "When to alert", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerHcHealthy": "Health check healthy", + "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingSectionActions": "Actions", + "alertingAddAction": "Add action", + "alertingActionNotify": "Notify", + "alertingActionSms": "SMS", + "alertingActionWebhook": "Webhook", + "alertingActionType": "Action type", + "alertingNotifyUsers": "Users", + "alertingNotifyRoles": "Roles", + "alertingNotifyEmails": "Email addresses", + "alertingEmailPlaceholder": "Add email and press Enter", + "alertingSmsNumbers": "Phone numbers", + "alertingSmsPlaceholder": "Add number and press Enter", + "alertingWebhookMethod": "HTTP method", + "alertingWebhookSecret": "Signing secret (optional)", + "alertingWebhookSecretPlaceholder": "HMAC secret", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Add header", + "alertingSelectSites": "Select sites…", + "alertingSitesSelected": "{count} sites selected", + "alertingSelectHealthChecks": "Select health checks…", + "alertingHealthChecksSelected": "{count} health checks selected", + "alertingNoHealthChecks": "No targets with health checks enabled", + "alertingSelectUsers": "Select users…", + "alertingUsersSelected": "{count} users selected", + "alertingSelectRoles": "Select roles…", + "alertingRolesSelected": "{count} roles selected", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingErrorNameRequired": "Enter a name", + "alertingErrorActionsMin": "Add at least one action", + "alertingErrorPickSites": "Select at least one site", + "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorTriggerSite": "Choose a site trigger", + "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", + "alertingErrorSmsPhones": "Add at least one phone number", + "alertingErrorWebhookUrl": "Enter a valid webhook URL", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx new file mode 100644 index 000000000..3d100bed2 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -0,0 +1,24 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import { getTranslations } from "next-intl/server"; + +type AlertingPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function AlertingPage(props: AlertingPageProps) { + const params = await props.params; + const t = await getTranslations(); + + return ( + <> + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 66e6cdad0..4d3fd027c 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { + BellRing, Boxes, Building2, Cable, @@ -219,6 +220,11 @@ export const orgNavSections = ( title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", icon: + }, + { + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: } ] }, diff --git a/src/components/AlertRuleCredenza.tsx b/src/components/AlertRuleCredenza.tsx new file mode 100644 index 000000000..141011cb1 --- /dev/null +++ b/src/components/AlertRuleCredenza.tsx @@ -0,0 +1,1431 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Separator } from "@app/components/ui/separator"; +import { Switch } from "@app/components/ui/switch"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { toast } from "@app/hooks/useToast"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { + type AlertRule, + type AlertTrigger, + isoNow, + newRuleId, + upsertRule +} from "@app/lib/alertRulesLocalStorage"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import type { Control, UseFormReturn } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; +import { useDebounce } from "use-debounce"; +import { z } from "zod"; + +const FORM_ID = "alert-rule-form"; + +const tagSchema = z.object({ + id: z.string(), + text: z.string() +}); + +type FormAction = + | { + type: "notify"; + userIds: string[]; + roleIds: number[]; + emailTags: Tag[]; + } + | { type: "sms"; phoneTags: Tag[] } + | { + type: "webhook"; + url: string; + method: string; + headers: { key: string; value: string }[]; + secret: string; + }; + +export type AlertRuleFormValues = { + name: string; + enabled: boolean; + sourceType: "site" | "health_check"; + siteIds: number[]; + targetIds: number[]; + trigger: AlertTrigger; + actions: FormAction[]; +}; + +function buildFormSchema(t: (k: string) => string) { + return z + .object({ + name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + enabled: z.boolean(), + sourceType: z.enum(["site", "health_check"]), + siteIds: z.array(z.number()), + targetIds: z.array(z.number()), + trigger: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" + ]), + actions: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("sms"), + phoneTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + secret: z.string() + }) + ]) + ) + .min(1, { message: t("alertingErrorActionsMin") }) + }) + .superRefine((val, ctx) => { + if (val.sourceType === "site" && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickSites"), + path: ["siteIds"] + }); + } + if ( + val.sourceType === "health_check" && + val.targetIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickHealthChecks"), + path: ["targetIds"] + }); + } + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline" + ]; + const hcTriggers: AlertTrigger[] = [ + "health_check_healthy", + "health_check_unhealthy" + ]; + if ( + val.sourceType === "site" && + !siteTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerSite"), + path: ["trigger"] + }); + } + if ( + val.sourceType === "health_check" && + !hcTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerHealth"), + path: ["trigger"] + }); + } + val.actions.forEach((a, i) => { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorNotifyRecipients"), + path: ["actions", i, "userIds"] + }); + } + } + if (a.type === "sms" && a.phoneTags.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorSmsPhones"), + path: ["actions", i, "phoneTags"] + }); + } + if (a.type === "webhook") { + try { + // eslint-disable-next-line no-new + new URL(a.url.trim()); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorWebhookUrl"), + path: ["actions", i, "url"] + }); + } + } + }); + }); +} + +function defaultFormValues(): AlertRuleFormValues { + return { + name: "", + enabled: true, + sourceType: "site", + siteIds: [], + targetIds: [], + trigger: "site_offline", + actions: [ + { + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + } + ] + }; +} + +function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { + const actions: FormAction[] = rule.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify", + userIds: a.userIds.map(String), + roleIds: [...a.roleIds], + emailTags: a.emails.map((e) => ({ id: e, text: e })) + }; + } + if (a.type === "sms") { + return { + type: "sms", + phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) + }; + } + return { + type: "webhook", + url: a.url, + method: a.method, + headers: + a.headers.length > 0 + ? a.headers.map((h) => ({ ...h })) + : [{ key: "", value: "" }], + secret: a.secret ?? "" + }; + }); + return { + name: rule.name, + enabled: rule.enabled, + sourceType: rule.source.type, + siteIds: + rule.source.type === "site" ? [...rule.source.siteIds] : [], + targetIds: + rule.source.type === "health_check" + ? [...rule.source.targetIds] + : [], + trigger: rule.trigger, + actions + }; +} + +function formValuesToRule( + v: AlertRuleFormValues, + id: string, + createdAt: string +): AlertRule { + const source = + v.sourceType === "site" + ? { type: "site" as const, siteIds: v.siteIds } + : { + type: "health_check" as const, + targetIds: v.targetIds + }; + const actions = v.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify" as const, + userIds: a.userIds, + roleIds: a.roleIds, + emails: a.emailTags.map((t) => t.text.trim()).filter(Boolean) + }; + } + if (a.type === "sms") { + return { + type: "sms" as const, + phoneNumbers: a.phoneTags + .map((t) => t.text.trim()) + .filter(Boolean) + }; + } + return { + type: "webhook" as const, + url: a.url.trim(), + method: a.method, + headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), + secret: a.secret.trim() || undefined + }; + }); + return { + id, + name: v.name.trim(), + enabled: v.enabled, + createdAt, + updatedAt: isoNow(), + source, + trigger: v.trigger, + actions + }; +} + +type TargetRow = { + targetId: number; + resourceName: string; + ip: string; + port: number; +}; + +function useHealthCheckOptions(orgId: string) { + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, perPage: 10_000 }) + ); + return useMemo(() => { + const rows: TargetRow[] = []; + for (const r of resources) { + for (const t of r.targets) { + const ext = t as typeof t & { hcEnabled?: boolean }; + if (ext.hcEnabled === true) { + rows.push({ + targetId: t.targetId, + resourceName: r.name, + ip: t.ip, + port: t.port + }); + } + } + } + return rows; + }, [resources]); +} + +type AlertRuleCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + rule: AlertRule | null; + onSaved: () => void; +}; + +export default function AlertRuleCredenza({ + open, + setOpen, + orgId, + rule, + onSaved +}: AlertRuleCredenzaProps) { + const t = useTranslations(); + const schema = useMemo(() => buildFormSchema(t), [t]); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultFormValues() + }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "actions" + }); + + const sourceType = form.watch("sourceType"); + const trigger = form.watch("trigger"); + + const ruleKey = rule?.id ?? "__new__"; + useEffect(() => { + if (!open) return; + if (rule) { + form.reset(ruleToFormValues(rule)); + } else { + form.reset(defaultFormValues()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset when opening or switching create/edit target + }, [open, ruleKey]); + + useEffect(() => { + if (sourceType === "site") { + if ( + trigger !== "site_online" && + trigger !== "site_offline" + ) { + form.setValue("trigger", "site_offline"); + } + } else if ( + trigger !== "health_check_healthy" && + trigger !== "health_check_unhealthy" + ) { + form.setValue("trigger", "health_check_unhealthy"); + } + }, [sourceType, trigger, form]); + + const onSubmit = form.handleSubmit((values) => { + const id = rule?.id ?? newRuleId(); + const createdAt = rule?.createdAt ?? isoNow(); + const next = formValuesToRule(values, id, createdAt); + upsertRule(orgId, next); + toast({ title: t("alertingRuleSaved") }); + onSaved(); + setOpen(false); + }); + + return ( + + + + + {rule + ? t("alertingEditRule") + : t("alertingCreateRule")} + + + {t("alertingRuleCredenzaDescription")} + + + +
+ + ( + + {t("name")} + + + + + + )} + /> + ( + + + {t("alertingRuleEnabled")} + + + + + + )} + /> + +
+

+ {t("alertingSectionSource")} +

+ ( + + + {t("alertingSourceType")} + + + + + )} + /> + {sourceType === "site" ? ( + ( + + + {t("alertingPickSites")} + + + + + )} + /> + ) : ( + ( + + + {t( + "alertingPickHealthChecks" + )} + + + + + )} + /> + )} +
+ + + +
+

+ {t("alertingSectionTrigger")} +

+ ( + + + {t("alertingTrigger")} + + + + + )} + /> +
+ + + +
+
+

+ {t("alertingSectionActions")} +

+ { + if (type === "notify") { + append({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } else if (type === "sms") { + append({ + type: "sms", + phoneTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { key: "", value: "" } + ], + secret: "" + }); + } + }} + /> +
+ {fields.map((f, index) => ( + remove(index)} + canRemove={fields.length > 1} + /> + ))} +
+ + +
+ + + + + + +
+
+ ); +} + +function DropdownAddAction({ + onPick +}: { + onPick: (type: "notify" | "sms" | "webhook") => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + return ( + + + + + +
+ + + +
+
+
+ ); +} + +function SiteMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: sites = [] } = useQuery( + orgQueries.sites({ orgId, query: debounced, perPage: 500 }) + ); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectSites") + : t("alertingSitesSelected", { count: value.length }); + return ( + + + + + + + + + {t("siteNotFound")} + + {sites.map((s) => ( + toggle(s.siteId)} + className="cursor-pointer" + > + + {s.name} + + ))} + + + + + + ); +} + +function HealthCheckMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const rows = useHealthCheckOptions(orgId); + const filtered = useMemo(() => { + const qq = q.trim().toLowerCase(); + if (!qq) return rows; + return rows.filter( + (r) => + r.resourceName.toLowerCase().includes(qq) || + `${r.ip}:${r.port}`.includes(qq) + ); + }, [rows, q]); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectHealthChecks") + : t("alertingHealthChecksSelected", { count: value.length }); + return ( + + + + + + + + + + {t("alertingNoHealthChecks")} + + + {filtered.map((r) => ( + toggle(r.targetId)} + className="cursor-pointer" + > + + + {r.resourceName} · {r.ip}:{r.port} + + + ))} + + + + + + ); +} + +function ActionBlock({ + orgId, + index, + control, + form, + onRemove, + canRemove +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; + onRemove: () => void; + canRemove: boolean; +}) { + const t = useTranslations(); + const type = form.watch(`actions.${index}.type`); + return ( +
+ {canRemove && ( + + )} + ( + + {t("alertingActionType")} + + + )} + /> + {type === "notify" && ( + + )} + {type === "sms" && ( + + )} + {type === "webhook" && ( + + )} +
+ ); +} + +function NotifyActionFields({ + orgId, + index, + control, + form +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [emailActiveIdx, setEmailActiveIdx] = useState(null); + const userIds = form.watch(`actions.${index}.userIds`) ?? []; + const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; + const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; + + return ( +
+ + {t("alertingNotifyUsers")} + + form.setValue(`actions.${index}.userIds`, ids) + } + /> + + + {t("alertingNotifyRoles")} + + form.setValue(`actions.${index}.roleIds`, ids) + } + /> + + ( + + {t("alertingNotifyEmails")} + + { + const next = + typeof updater === "function" + ? updater(emailTags) + : updater; + form.setValue( + `actions.${index}.emailTags`, + next + ); + }} + activeTagIndex={emailActiveIdx} + setActiveTagIndex={setEmailActiveIdx} + placeholder={t( + "alertingEmailPlaceholder" + )} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> +
+ ); +} + +function SmsActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); + const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; + return ( + ( + + {t("alertingSmsNumbers")} + + { + const next = + typeof updater === "function" + ? updater(phoneTags) + : updater; + form.setValue( + `actions.${index}.phoneTags`, + next + ); + }} + activeTagIndex={phoneActiveIdx} + setActiveTagIndex={setPhoneActiveIdx} + placeholder={t("alertingSmsPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> + ); +} + +function WebhookActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + return ( +
+ ( + + URL + + + + + + )} + /> + ( + + {t("alertingWebhookMethod")} + + + + )} + /> + ( + + {t("alertingWebhookSecret")} + + + + + + )} + /> + +
+ ); +} + +function WebhookHeadersField({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const headers = + form.watch(`actions.${index}.headers` as const) ?? []; + return ( +
+ {t("alertingWebhookHeaders")} + {headers.map((_, hi) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} + +
+ ); +} + +function UserMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: string[]; + onChange: (v: string[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: users = [] } = useQuery(orgQueries.users({ orgId })); + const shown = useMemo(() => { + const qq = debounced.trim().toLowerCase(); + if (!qq) return users.slice(0, 200); + return users + .filter((u) => { + const label = getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }).toLowerCase(); + return ( + label.includes(qq) || + (u.email ?? "").toLowerCase().includes(qq) + ); + }) + .slice(0, 200); + }, [users, debounced]); + const toggle = (id: string) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectUsers") + : t("alertingUsersSelected", { count: value.length }); + return ( + + + + + + + + + {t("noResults")} + + {shown.map((u) => { + const uid = String(u.id); + return ( + toggle(uid)} + className="cursor-pointer" + > + + {getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + })} + + ); + })} + + + + + + ); +} + +function RoleMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectRoles") + : t("alertingRolesSelected", { count: value.length }); + return ( + + + + + + + + + {roles.map((r) => ( + toggle(r.roleId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx new file mode 100644 index 000000000..5a6b0f060 --- /dev/null +++ b/src/components/AlertingRulesTable.tsx @@ -0,0 +1,297 @@ +"use client"; + +import AlertRuleCredenza from "@app/components/AlertRuleCredenza"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { + DataTable, + ExtendedColumnDef +} from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { + type AlertRule, + deleteRule, + isoNow, + loadRules, + upsertRule +} from "@app/lib/alertRulesLocalStorage"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import moment from "moment"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { Badge } from "@app/components/ui/badge"; + +type AlertingRulesTableProps = { + orgId: string; +}; + +function sourceSummary(rule: AlertRule, t: (k: string, o?: Record) => string) { + if (rule.source.type === "site") { + return t("alertingSummarySites", { + count: rule.source.siteIds.length + }); + } + return t("alertingSummaryHealthChecks", { + count: rule.source.targetIds.length + }); +} + +function triggerLabel(rule: AlertRule, t: (k: string) => string) { + switch (rule.trigger) { + case "site_online": + return t("alertingTriggerSiteOnline"); + case "site_offline": + return t("alertingTriggerSiteOffline"); + case "health_check_healthy": + return t("alertingTriggerHcHealthy"); + case "health_check_unhealthy": + return t("alertingTriggerHcUnhealthy"); + default: + return rule.trigger; + } +} + +function actionBadges(rule: AlertRule, t: (k: string) => string) { + return rule.actions.map((a, i) => { + if (a.type === "notify") { + return ( + + {t("alertingActionNotify")} + + ); + } + if (a.type === "sms") { + return ( + + {t("alertingActionSms")} + + ); + } + return ( + + {t("alertingActionWebhook")} + + ); + }); +} + +export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { + const t = useTranslations(); + const [rows, setRows] = useState([]); + const [credenzaOpen, setCredenzaOpen] = useState(false); + const [credenzaRule, setCredenzaRule] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshFromStorage = useCallback(() => { + setRows(loadRules(orgId)); + }, [orgId]); + + useEffect(() => { + refreshFromStorage(); + }, [refreshFromStorage]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((r) => setTimeout(r, 200)); + refreshFromStorage(); + } finally { + setIsRefreshing(false); + } + }; + + const setEnabled = (rule: AlertRule, enabled: boolean) => { + upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() }); + refreshFromStorage(); + }; + + const confirmDelete = async () => { + if (!selected) return; + deleteRule(orgId, selected.id); + refreshFromStorage(); + setDeleteOpen(false); + setSelected(null); + toast({ title: t("alertingRuleDeleted") }); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ) + }, + { + id: "source", + friendlyName: t("alertingColumnSource"), + header: () => {t("alertingColumnSource")}, + cell: ({ row }) => ( + {sourceSummary(row.original, t)} + ) + }, + { + id: "trigger", + friendlyName: t("alertingColumnTrigger"), + header: () => ( + {t("alertingColumnTrigger")} + ), + cell: ({ row }) => {triggerLabel(row.original, t)} + }, + { + id: "actionsCol", + friendlyName: t("alertingColumnActions"), + header: () => ( + {t("alertingColumnActions")} + ), + cell: ({ row }) => ( +
+ {actionBadges(row.original, t)} +
+ ) + }, + { + accessorKey: "enabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + {t("alertingColumnEnabled")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + setEnabled(r, v)} + /> + ); + } + }, + { + accessorKey: "createdAt", + friendlyName: t("createdAt"), + header: () => ( + {t("createdAt")} + ), + cell: ({ row }) => ( + {moment(row.original.createdAt).format("lll")} + ) + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setCredenzaRule(r); + setCredenzaOpen(true); + }} + > + {t("edit")} + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + +
+ ); + } + } + ]; + + return ( + <> + { + setCredenzaOpen(v); + if (!v) setCredenzaRule(null); + }} + orgId={orgId} + rule={credenzaRule} + onSaved={refreshFromStorage} + /> + {selected && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("alertingDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={confirmDelete} + string={selected.name} + title={t("alertingDeleteRule")} + /> + )} + { + setCredenzaRule(null); + setCredenzaOpen(true); + }} + onRefresh={refreshData} + isRefreshing={isRefreshing} + addButtonText={t("alertingAddRule")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + /> + + ); +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts new file mode 100644 index 000000000..2e26cee71 --- /dev/null +++ b/src/lib/alertRulesLocalStorage.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +const STORAGE_PREFIX = "pangolin:alert-rules:"; + +export const webhookHeaderEntrySchema = z.object({ + key: z.string(), + value: z.string() +}); + +export const alertActionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emails: z.array(z.string()) + }), + z.object({ + type: z.literal("sms"), + phoneNumbers: z.array(z.string()) + }), + z.object({ + type: z.literal("webhook"), + url: z.string().url(), + method: z.string().min(1), + headers: z.array(webhookHeaderEntrySchema), + secret: z.string().optional() + }) +]); + +export const alertSourceSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("site"), + siteIds: z.array(z.number()) + }), + z.object({ + type: z.literal("health_check"), + targetIds: z.array(z.number()) + }) +]); + +export const alertTriggerSchema = z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" +]); + +export const alertRuleSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255), + enabled: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + source: alertSourceSchema, + trigger: alertTriggerSchema, + actions: z.array(alertActionSchema).min(1) +}); + +export type AlertRule = z.infer; +export type AlertAction = z.infer; +export type AlertTrigger = z.infer; + +function storageKey(orgId: string) { + return `${STORAGE_PREFIX}${orgId}`; +} + +export function loadRules(orgId: string): AlertRule[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = localStorage.getItem(storageKey(orgId)); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + const out: AlertRule[] = []; + for (const item of parsed) { + const r = alertRuleSchema.safeParse(item); + if (r.success) { + out.push(r.data); + } + } + return out; + } catch { + return []; + } +} + +export function saveRules(orgId: string, rules: AlertRule[]) { + if (typeof window === "undefined") { + return; + } + localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); +} + +export function upsertRule(orgId: string, rule: AlertRule) { + const rules = loadRules(orgId); + const i = rules.findIndex((r) => r.id === rule.id); + if (i >= 0) { + rules[i] = rule; + } else { + rules.push(rule); + } + saveRules(orgId, rules); +} + +export function deleteRule(orgId: string, ruleId: string) { + const rules = loadRules(orgId).filter((r) => r.id !== ruleId); + saveRules(orgId, rules); +} + +export function newRuleId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function isoNow() { + return new Date().toISOString(); +} From 4cce6e0820a366bfd9e505db3c22e4ab860f5eb4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 29 Mar 2026 20:25:17 -0700 Subject: [PATCH 021/176] add node graph and editor --- messages/en-US.json | 23 +- package-lock.json | 130 +- package.json | 1 + .../settings/access/users/create/page.tsx | 19 +- .../settings/alerting/[ruleId]/page.tsx | 52 + .../[orgId]/settings/alerting/create/page.tsx | 40 + src/components/AlertRuleCredenza.tsx | 1431 ----------------- src/components/AlertingRulesTable.tsx | 65 +- .../alert-rule-editor/AlertRuleFields.tsx | 981 +++++++++++ .../AlertRuleGraphEditor.tsx | 715 ++++++++ src/lib/alertRuleForm.ts | 283 ++++ src/lib/alertRulesLocalStorage.ts | 4 + 12 files changed, 2252 insertions(+), 1492 deletions(-) create mode 100644 src/app/[orgId]/settings/alerting/[ruleId]/page.tsx create mode 100644 src/app/[orgId]/settings/alerting/create/page.tsx delete mode 100644 src/components/AlertRuleCredenza.tsx create mode 100644 src/components/alert-rule-editor/AlertRuleFields.tsx create mode 100644 src/components/alert-rule-editor/AlertRuleGraphEditor.tsx create mode 100644 src/lib/alertRuleForm.ts diff --git a/messages/en-US.json b/messages/en-US.json index b2f750cb1..753437a35 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1237,6 +1237,7 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", + "search": "Search…", "searchPlaceholder": "Search...", "emptySearchOptions": "No options found", "create": "Create", @@ -1327,10 +1328,10 @@ "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "alertingTitle": "Alerting rules", - "alertingDescription": "Define sources, triggers, and actions for notifications. Rules are stored locally in this browser until server-side alerting is available.", + "alertingDescription": "Define sources, triggers, and actions for notifications.", "alertingRules": "Alert rules", "alertingSearchRules": "Search rules…", - "alertingAddRule": "Create rule", + "alertingAddRule": "Create Rule", "alertingColumnSource": "Source", "alertingColumnTrigger": "Trigger", "alertingColumnActions": "Actions", @@ -1350,6 +1351,10 @@ "alertingSourceHealthCheck": "Health check", "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", + "alertingPickResources": "Resources", + "alertingSelectResources": "Select resources…", + "alertingResourcesSelected": "{count} resources selected", + "alertingResourcesEmpty": "No resources with targets in the first 10 results.", "alertingSectionTrigger": "Trigger", "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", @@ -1378,6 +1383,7 @@ "alertingSelectHealthChecks": "Select health checks…", "alertingHealthChecksSelected": "{count} health checks selected", "alertingNoHealthChecks": "No targets with health checks enabled", + "alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.", "alertingSelectUsers": "Select users…", "alertingUsersSelected": "{count} users selected", "alertingSelectRoles": "Select roles…", @@ -1393,6 +1399,19 @@ "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingErrorSmsPhones": "Add at least one phone number", "alertingErrorWebhookUrl": "Enter a valid webhook URL", + "alertingConfigureSource": "Configure Source", + "alertingConfigureTrigger": "Configure Trigger", + "alertingConfigureActions": "Configure Actions", + "alertingBackToRules": "Back to Rules", + "alertingDraftBadge": "Draft — save to store this rule", + "alertingSidebarHint": "Click a step on the canvas to edit it here.", + "alertingGraphCanvasTitle": "Rule Flow", + "alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.", + "alertingNodeNotConfigured": "Not configured yet", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "Source", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Action", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/package-lock.json b/package-lock.json index 7b63f1691..69a0b5a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xyflow/react": "^12.8.4", "arctic": "3.7.0", "axios": "1.13.5", "better-sqlite3": "11.9.1", @@ -1058,6 +1059,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2353,6 +2355,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2375,6 +2378,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2397,6 +2401,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2413,6 +2418,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2429,6 +2435,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2445,6 +2452,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2461,6 +2469,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2477,6 +2486,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2493,6 +2503,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2509,6 +2520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2525,6 +2537,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2541,6 +2554,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2563,6 +2577,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2585,6 +2600,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2607,6 +2623,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2629,6 +2646,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2651,6 +2669,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2673,6 +2692,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2695,6 +2715,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2714,6 +2735,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2733,6 +2755,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2752,6 +2775,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3002,6 +3026,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6948,6 +6973,7 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8408,6 +8434,7 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8523,6 +8550,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8682,7 +8710,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8798,7 +8825,6 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { @@ -8833,7 +8859,6 @@ "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8843,7 +8868,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -8870,6 +8894,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8965,6 +8990,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -8992,6 +9018,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9017,6 +9044,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9027,6 +9055,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9113,8 +9142,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9188,6 +9216,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9642,6 +9671,38 @@ "win32" ] }, + "node_modules/@xyflow/react": { + "version": "12.8.4", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz", + "integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.68", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.68", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz", + "integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -9661,6 +9722,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10109,6 +10171,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10180,6 +10243,7 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10308,6 +10372,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10490,6 +10555,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -11214,6 +11285,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -11654,7 +11726,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "engines": { "node": ">=20" }, @@ -12289,6 +12360,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12374,6 +12446,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12510,6 +12583,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12903,6 +12977,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -15320,7 +15395,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15331,7 +15405,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15419,6 +15492,7 @@ "version": "15.5.12", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "peer": true, "dependencies": { "@next/env": "15.5.12", "@swc/helpers": "0.5.15", @@ -16377,6 +16451,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16881,6 +16956,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16912,6 +16988,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17204,6 +17281,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18665,7 +18743,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -19140,6 +19219,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19567,6 +19647,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19773,6 +19854,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -19788,6 +19870,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 05ae3b49f..7f7900eb5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xyflow/react": "^12.8.4", "arctic": "3.7.0", "axios": "1.13.5", "better-sqlite3": "11.9.1", diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0263d2b72..04d347698 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -340,15 +340,16 @@ export default function Page() { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); - const res = await api.post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleIds, - validHours: parseInt(values.validForHours), - sendEmail - } - ) + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx new file mode 100644 index 000000000..b9379f7cc --- /dev/null +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import { ruleToFormValues } from "@app/lib/alertRuleForm"; +import type { AlertRule } from "@app/lib/alertRulesLocalStorage"; +import { getRule } from "@app/lib/alertRulesLocalStorage"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export default function EditAlertRulePage() { + const t = useTranslations(); + const params = useParams(); + const router = useRouter(); + const orgId = params.orgId as string; + const ruleId = params.ruleId as string; + const [rule, setRule] = useState(undefined); + + useEffect(() => { + const r = getRule(orgId, ruleId); + setRule(r ?? null); + }, [orgId, ruleId]); + + useEffect(() => { + if (rule === null) { + router.replace(`/${orgId}/settings/alerting`); + } + }, [rule, orgId, router]); + + if (rule === undefined) { + return ( +
+ {t("loading")} +
+ ); + } + + if (rule === null) { + return null; + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx new file mode 100644 index 000000000..24c0d2ffe --- /dev/null +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import { defaultFormValues } from "@app/lib/alertRuleForm"; +import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export default function NewAlertRulePage() { + const t = useTranslations(); + const params = useParams(); + const orgId = params.orgId as string; + const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>( + null + ); + + useEffect(() => { + setMeta({ id: newRuleId(), createdAt: isoNow() }); + }, []); + + if (!meta) { + return ( +
+ {t("loading")} +
+ ); + } + + return ( + + ); +} diff --git a/src/components/AlertRuleCredenza.tsx b/src/components/AlertRuleCredenza.tsx deleted file mode 100644 index 141011cb1..000000000 --- a/src/components/AlertRuleCredenza.tsx +++ /dev/null @@ -1,1431 +0,0 @@ -"use client"; - -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { Button } from "@app/components/ui/button"; -import { Checkbox } from "@app/components/ui/checkbox"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Separator } from "@app/components/ui/separator"; -import { Switch } from "@app/components/ui/switch"; -import { TagInput, type Tag } from "@app/components/tags/tag-input"; -import { toast } from "@app/hooks/useToast"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { - type AlertRule, - type AlertTrigger, - isoNow, - newRuleId, - upsertRule -} from "@app/lib/alertRulesLocalStorage"; -import { orgQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useState } from "react"; -import type { Control, UseFormReturn } from "react-hook-form"; -import { useFieldArray, useForm } from "react-hook-form"; -import { useDebounce } from "use-debounce"; -import { z } from "zod"; - -const FORM_ID = "alert-rule-form"; - -const tagSchema = z.object({ - id: z.string(), - text: z.string() -}); - -type FormAction = - | { - type: "notify"; - userIds: string[]; - roleIds: number[]; - emailTags: Tag[]; - } - | { type: "sms"; phoneTags: Tag[] } - | { - type: "webhook"; - url: string; - method: string; - headers: { key: string; value: string }[]; - secret: string; - }; - -export type AlertRuleFormValues = { - name: string; - enabled: boolean; - sourceType: "site" | "health_check"; - siteIds: number[]; - targetIds: number[]; - trigger: AlertTrigger; - actions: FormAction[]; -}; - -function buildFormSchema(t: (k: string) => string) { - return z - .object({ - name: z.string().min(1, { message: t("alertingErrorNameRequired") }), - enabled: z.boolean(), - sourceType: z.enum(["site", "health_check"]), - siteIds: z.array(z.number()), - targetIds: z.array(z.number()), - trigger: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_unhealthy" - ]), - actions: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userIds: z.array(z.string()), - roleIds: z.array(z.number()), - emailTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("sms"), - phoneTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("webhook"), - url: z.string(), - method: z.string(), - headers: z.array( - z.object({ - key: z.string(), - value: z.string() - }) - ), - secret: z.string() - }) - ]) - ) - .min(1, { message: t("alertingErrorActionsMin") }) - }) - .superRefine((val, ctx) => { - if (val.sourceType === "site" && val.siteIds.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorPickSites"), - path: ["siteIds"] - }); - } - if ( - val.sourceType === "health_check" && - val.targetIds.length === 0 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorPickHealthChecks"), - path: ["targetIds"] - }); - } - const siteTriggers: AlertTrigger[] = [ - "site_online", - "site_offline" - ]; - const hcTriggers: AlertTrigger[] = [ - "health_check_healthy", - "health_check_unhealthy" - ]; - if ( - val.sourceType === "site" && - !siteTriggers.includes(val.trigger) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorTriggerSite"), - path: ["trigger"] - }); - } - if ( - val.sourceType === "health_check" && - !hcTriggers.includes(val.trigger) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorTriggerHealth"), - path: ["trigger"] - }); - } - val.actions.forEach((a, i) => { - if (a.type === "notify") { - if ( - a.userIds.length === 0 && - a.roleIds.length === 0 && - a.emailTags.length === 0 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorNotifyRecipients"), - path: ["actions", i, "userIds"] - }); - } - } - if (a.type === "sms" && a.phoneTags.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorSmsPhones"), - path: ["actions", i, "phoneTags"] - }); - } - if (a.type === "webhook") { - try { - // eslint-disable-next-line no-new - new URL(a.url.trim()); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("alertingErrorWebhookUrl"), - path: ["actions", i, "url"] - }); - } - } - }); - }); -} - -function defaultFormValues(): AlertRuleFormValues { - return { - name: "", - enabled: true, - sourceType: "site", - siteIds: [], - targetIds: [], - trigger: "site_offline", - actions: [ - { - type: "notify", - userIds: [], - roleIds: [], - emailTags: [] - } - ] - }; -} - -function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { - const actions: FormAction[] = rule.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify", - userIds: a.userIds.map(String), - roleIds: [...a.roleIds], - emailTags: a.emails.map((e) => ({ id: e, text: e })) - }; - } - if (a.type === "sms") { - return { - type: "sms", - phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) - }; - } - return { - type: "webhook", - url: a.url, - method: a.method, - headers: - a.headers.length > 0 - ? a.headers.map((h) => ({ ...h })) - : [{ key: "", value: "" }], - secret: a.secret ?? "" - }; - }); - return { - name: rule.name, - enabled: rule.enabled, - sourceType: rule.source.type, - siteIds: - rule.source.type === "site" ? [...rule.source.siteIds] : [], - targetIds: - rule.source.type === "health_check" - ? [...rule.source.targetIds] - : [], - trigger: rule.trigger, - actions - }; -} - -function formValuesToRule( - v: AlertRuleFormValues, - id: string, - createdAt: string -): AlertRule { - const source = - v.sourceType === "site" - ? { type: "site" as const, siteIds: v.siteIds } - : { - type: "health_check" as const, - targetIds: v.targetIds - }; - const actions = v.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify" as const, - userIds: a.userIds, - roleIds: a.roleIds, - emails: a.emailTags.map((t) => t.text.trim()).filter(Boolean) - }; - } - if (a.type === "sms") { - return { - type: "sms" as const, - phoneNumbers: a.phoneTags - .map((t) => t.text.trim()) - .filter(Boolean) - }; - } - return { - type: "webhook" as const, - url: a.url.trim(), - method: a.method, - headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), - secret: a.secret.trim() || undefined - }; - }); - return { - id, - name: v.name.trim(), - enabled: v.enabled, - createdAt, - updatedAt: isoNow(), - source, - trigger: v.trigger, - actions - }; -} - -type TargetRow = { - targetId: number; - resourceName: string; - ip: string; - port: number; -}; - -function useHealthCheckOptions(orgId: string) { - const { data: resources = [] } = useQuery( - orgQueries.resources({ orgId, perPage: 10_000 }) - ); - return useMemo(() => { - const rows: TargetRow[] = []; - for (const r of resources) { - for (const t of r.targets) { - const ext = t as typeof t & { hcEnabled?: boolean }; - if (ext.hcEnabled === true) { - rows.push({ - targetId: t.targetId, - resourceName: r.name, - ip: t.ip, - port: t.port - }); - } - } - } - return rows; - }, [resources]); -} - -type AlertRuleCredenzaProps = { - open: boolean; - setOpen: (open: boolean) => void; - orgId: string; - rule: AlertRule | null; - onSaved: () => void; -}; - -export default function AlertRuleCredenza({ - open, - setOpen, - orgId, - rule, - onSaved -}: AlertRuleCredenzaProps) { - const t = useTranslations(); - const schema = useMemo(() => buildFormSchema(t), [t]); - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: defaultFormValues() - }); - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "actions" - }); - - const sourceType = form.watch("sourceType"); - const trigger = form.watch("trigger"); - - const ruleKey = rule?.id ?? "__new__"; - useEffect(() => { - if (!open) return; - if (rule) { - form.reset(ruleToFormValues(rule)); - } else { - form.reset(defaultFormValues()); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reset when opening or switching create/edit target - }, [open, ruleKey]); - - useEffect(() => { - if (sourceType === "site") { - if ( - trigger !== "site_online" && - trigger !== "site_offline" - ) { - form.setValue("trigger", "site_offline"); - } - } else if ( - trigger !== "health_check_healthy" && - trigger !== "health_check_unhealthy" - ) { - form.setValue("trigger", "health_check_unhealthy"); - } - }, [sourceType, trigger, form]); - - const onSubmit = form.handleSubmit((values) => { - const id = rule?.id ?? newRuleId(); - const createdAt = rule?.createdAt ?? isoNow(); - const next = formValuesToRule(values, id, createdAt); - upsertRule(orgId, next); - toast({ title: t("alertingRuleSaved") }); - onSaved(); - setOpen(false); - }); - - return ( - - - - - {rule - ? t("alertingEditRule") - : t("alertingCreateRule")} - - - {t("alertingRuleCredenzaDescription")} - - - -
- - ( - - {t("name")} - - - - - - )} - /> - ( - - - {t("alertingRuleEnabled")} - - - - - - )} - /> - -
-

- {t("alertingSectionSource")} -

- ( - - - {t("alertingSourceType")} - - - - - )} - /> - {sourceType === "site" ? ( - ( - - - {t("alertingPickSites")} - - - - - )} - /> - ) : ( - ( - - - {t( - "alertingPickHealthChecks" - )} - - - - - )} - /> - )} -
- - - -
-

- {t("alertingSectionTrigger")} -

- ( - - - {t("alertingTrigger")} - - - - - )} - /> -
- - - -
-
-

- {t("alertingSectionActions")} -

- { - if (type === "notify") { - append({ - type: "notify", - userIds: [], - roleIds: [], - emailTags: [] - }); - } else if (type === "sms") { - append({ - type: "sms", - phoneTags: [] - }); - } else { - append({ - type: "webhook", - url: "", - method: "POST", - headers: [ - { key: "", value: "" } - ], - secret: "" - }); - } - }} - /> -
- {fields.map((f, index) => ( - remove(index)} - canRemove={fields.length > 1} - /> - ))} -
- - -
- - - - - - -
-
- ); -} - -function DropdownAddAction({ - onPick -}: { - onPick: (type: "notify" | "sms" | "webhook") => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - return ( - - - - - -
- - - -
-
-
- ); -} - -function SiteMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const [debounced] = useDebounce(q, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ orgId, query: debounced, perPage: 500 }) - ); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectSites") - : t("alertingSitesSelected", { count: value.length }); - return ( - - - - - - - - - {t("siteNotFound")} - - {sites.map((s) => ( - toggle(s.siteId)} - className="cursor-pointer" - > - - {s.name} - - ))} - - - - - - ); -} - -function HealthCheckMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const rows = useHealthCheckOptions(orgId); - const filtered = useMemo(() => { - const qq = q.trim().toLowerCase(); - if (!qq) return rows; - return rows.filter( - (r) => - r.resourceName.toLowerCase().includes(qq) || - `${r.ip}:${r.port}`.includes(qq) - ); - }, [rows, q]); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectHealthChecks") - : t("alertingHealthChecksSelected", { count: value.length }); - return ( - - - - - - - - - - {t("alertingNoHealthChecks")} - - - {filtered.map((r) => ( - toggle(r.targetId)} - className="cursor-pointer" - > - - - {r.resourceName} · {r.ip}:{r.port} - - - ))} - - - - - - ); -} - -function ActionBlock({ - orgId, - index, - control, - form, - onRemove, - canRemove -}: { - orgId: string; - index: number; - control: Control; - form: UseFormReturn; - onRemove: () => void; - canRemove: boolean; -}) { - const t = useTranslations(); - const type = form.watch(`actions.${index}.type`); - return ( -
- {canRemove && ( - - )} - ( - - {t("alertingActionType")} - - - )} - /> - {type === "notify" && ( - - )} - {type === "sms" && ( - - )} - {type === "webhook" && ( - - )} -
- ); -} - -function NotifyActionFields({ - orgId, - index, - control, - form -}: { - orgId: string; - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const [emailActiveIdx, setEmailActiveIdx] = useState(null); - const userIds = form.watch(`actions.${index}.userIds`) ?? []; - const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; - const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; - - return ( -
- - {t("alertingNotifyUsers")} - - form.setValue(`actions.${index}.userIds`, ids) - } - /> - - - {t("alertingNotifyRoles")} - - form.setValue(`actions.${index}.roleIds`, ids) - } - /> - - ( - - {t("alertingNotifyEmails")} - - { - const next = - typeof updater === "function" - ? updater(emailTags) - : updater; - form.setValue( - `actions.${index}.emailTags`, - next - ); - }} - activeTagIndex={emailActiveIdx} - setActiveTagIndex={setEmailActiveIdx} - placeholder={t( - "alertingEmailPlaceholder" - )} - delimiterList={[",", "Enter"]} - /> - - - - )} - /> -
- ); -} - -function SmsActionFields({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); - const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; - return ( - ( - - {t("alertingSmsNumbers")} - - { - const next = - typeof updater === "function" - ? updater(phoneTags) - : updater; - form.setValue( - `actions.${index}.phoneTags`, - next - ); - }} - activeTagIndex={phoneActiveIdx} - setActiveTagIndex={setPhoneActiveIdx} - placeholder={t("alertingSmsPlaceholder")} - delimiterList={[",", "Enter"]} - /> - - - - )} - /> - ); -} - -function WebhookActionFields({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - return ( -
- ( - - URL - - - - - - )} - /> - ( - - {t("alertingWebhookMethod")} - - - - )} - /> - ( - - {t("alertingWebhookSecret")} - - - - - - )} - /> - -
- ); -} - -function WebhookHeadersField({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const headers = - form.watch(`actions.${index}.headers` as const) ?? []; - return ( -
- {t("alertingWebhookHeaders")} - {headers.map((_, hi) => ( -
- ( - - - - - - )} - /> - ( - - - - - - )} - /> - -
- ))} - -
- ); -} - -function UserMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: string[]; - onChange: (v: string[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const [debounced] = useDebounce(q, 150); - const { data: users = [] } = useQuery(orgQueries.users({ orgId })); - const shown = useMemo(() => { - const qq = debounced.trim().toLowerCase(); - if (!qq) return users.slice(0, 200); - return users - .filter((u) => { - const label = getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - }).toLowerCase(); - return ( - label.includes(qq) || - (u.email ?? "").toLowerCase().includes(qq) - ); - }) - .slice(0, 200); - }, [users, debounced]); - const toggle = (id: string) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectUsers") - : t("alertingUsersSelected", { count: value.length }); - return ( - - - - - - - - - {t("noResults")} - - {shown.map((u) => { - const uid = String(u.id); - return ( - toggle(uid)} - className="cursor-pointer" - > - - {getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - })} - - ); - })} - - - - - - ); -} - -function RoleMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectRoles") - : t("alertingRolesSelected", { count: value.length }); - return ( - - - - - - - - - {roles.map((r) => ( - toggle(r.roleId)} - className="cursor-pointer" - > - - {r.name} - - ))} - - - - - - ); -} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 5a6b0f060..97e1d1755 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -1,12 +1,8 @@ "use client"; -import AlertRuleCredenza from "@app/components/AlertRuleCredenza"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { - DataTable, - ExtendedColumnDef -} from "@app/components/ui/data-table"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -24,6 +20,8 @@ import { } from "@app/lib/alertRulesLocalStorage"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import moment from "moment"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@app/components/ui/badge"; @@ -32,7 +30,14 @@ type AlertingRulesTableProps = { orgId: string; }; -function sourceSummary(rule: AlertRule, t: (k: string, o?: Record) => string) { +function ruleHref(orgId: string, ruleId: string) { + return `/${orgId}/settings/alerting/${ruleId}`; +} + +function sourceSummary( + rule: AlertRule, + t: (k: string, o?: Record) => string +) { if (rule.source.type === "site") { return t("alertingSummarySites", { count: rule.source.siteIds.length @@ -83,10 +88,9 @@ function actionBadges(rule: AlertRule, t: (k: string) => string) { } export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { + const router = useRouter(); const t = useTranslations(); const [rows, setRows] = useState([]); - const [credenzaOpen, setCredenzaOpen] = useState(false); - const [credenzaRule, setCredenzaRule] = useState(null); const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); @@ -146,10 +150,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { id: "source", friendlyName: t("alertingColumnSource"), - header: () => {t("alertingColumnSource")}, - cell: ({ row }) => ( - {sourceSummary(row.original, t)} - ) + header: () => ( + {t("alertingColumnSource")} + ), + cell: ({ row }) => {sourceSummary(row.original, t)} }, { id: "trigger", @@ -190,9 +194,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { accessorKey: "createdAt", friendlyName: t("createdAt"), - header: () => ( - {t("createdAt")} - ), + header: () => {t("createdAt")}, cell: ({ row }) => ( {moment(row.original.createdAt).format("lll")} ) @@ -204,13 +206,10 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { cell: ({ row }) => { const r = row.original; return ( -
+
- - { - setCredenzaRule(r); - setCredenzaOpen(true); - }} - > - {t("edit")} - { setSelected(r); @@ -238,6 +229,11 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { +
); } @@ -246,16 +242,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { return ( <> - { - setCredenzaOpen(v); - if (!v) setCredenzaRule(null); - }} - orgId={orgId} - rule={credenzaRule} - onSaved={refreshFromStorage} - /> {selected && ( { - setCredenzaRule(null); - setCredenzaOpen(true); + router.push(`/${orgId}/settings/alerting/create`); }} onRefresh={refreshData} isRefreshing={isRefreshing} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx new file mode 100644 index 000000000..5a2e42393 --- /dev/null +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -0,0 +1,981 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { TagInput } from "@app/components/tags/tag-input"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { + type AlertRuleFormAction, + type AlertRuleFormValues +} from "@app/lib/alertRuleForm"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import type { Control, UseFormReturn } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useDebounce } from "use-debounce"; + +export function DropdownAddAction({ + onPick +}: { + onPick: (type: "notify" | "sms" | "webhook") => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + return ( + + + + + +
+ + + +
+
+
+ ); +} + +function SiteMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: sites = [] } = useQuery( + orgQueries.sites({ orgId, query: debounced, perPage: 500 }) + ); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectSites") + : t("alertingSitesSelected", { count: value.length }); + return ( + + + + + + + + + {t("siteNotFound")} + + {sites.map((s) => ( + toggle(s.siteId)} + className="cursor-pointer" + > + + {s.name} + + ))} + + + + + + ); +} + +const ALERT_RESOURCES_PAGE_SIZE = 10; + +function ResourceTenMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: resources = [] } = useQuery( + orgQueries.resources({ + orgId, + perPage: ALERT_RESOURCES_PAGE_SIZE + }) + ); + const rows = useMemo(() => { + const out: { + resourceId: number; + name: string; + targetIds: number[]; + }[] = []; + for (const r of resources) { + const targetIds = r.targets.map((x) => x.targetId); + if (targetIds.length > 0) { + out.push({ + resourceId: r.resourceId, + name: r.name, + targetIds + }); + } + } + return out; + }, [resources]); + + const selectedResourceCount = useMemo( + () => + rows.filter( + (row) => + row.targetIds.length > 0 && + row.targetIds.every((id) => value.includes(id)) + ).length, + [rows, value] + ); + + const toggle = (targetIds: number[]) => { + const allOn = + targetIds.length > 0 && + targetIds.every((id) => value.includes(id)); + if (allOn) { + onChange(value.filter((id) => !targetIds.includes(id))); + } else { + onChange([...new Set([...value, ...targetIds])]); + } + }; + + const summary = + selectedResourceCount === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { + count: selectedResourceCount + }); + + return ( + + + + + +
+ {rows.length === 0 ? ( +

+ {t("alertingResourcesEmpty")} +

+ ) : ( + rows.map((row) => { + const checked = + row.targetIds.length > 0 && + row.targetIds.every((id) => + value.includes(id) + ); + return ( + + ); + }) + )} +
+
+
+ ); +} + +export function ActionBlock({ + orgId, + index, + control, + form, + onRemove, + canRemove +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; + onRemove: () => void; + canRemove: boolean; +}) { + const t = useTranslations(); + const type = form.watch(`actions.${index}.type`); + return ( +
+ {canRemove && ( + + )} + ( + + {t("alertingActionType")} + + + )} + /> + {type === "notify" && ( + + )} + {type === "sms" && ( + + )} + {type === "webhook" && ( + + )} +
+ ); +} + +function NotifyActionFields({ + orgId, + index, + control, + form +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [emailActiveIdx, setEmailActiveIdx] = useState(null); + const userIds = form.watch(`actions.${index}.userIds`) ?? []; + const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; + const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; + + return ( +
+ + {t("alertingNotifyUsers")} + + form.setValue(`actions.${index}.userIds`, ids) + } + /> + + + {t("alertingNotifyRoles")} + + form.setValue(`actions.${index}.roleIds`, ids) + } + /> + + ( + + {t("alertingNotifyEmails")} + + { + const next = + typeof updater === "function" + ? updater(emailTags) + : updater; + form.setValue( + `actions.${index}.emailTags`, + next + ); + }} + activeTagIndex={emailActiveIdx} + setActiveTagIndex={setEmailActiveIdx} + placeholder={t("alertingEmailPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> +
+ ); +} + +function SmsActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); + const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; + return ( + ( + + {t("alertingSmsNumbers")} + + { + const next = + typeof updater === "function" + ? updater(phoneTags) + : updater; + form.setValue( + `actions.${index}.phoneTags`, + next + ); + }} + activeTagIndex={phoneActiveIdx} + setActiveTagIndex={setPhoneActiveIdx} + placeholder={t("alertingSmsPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> + ); +} + +function WebhookActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + return ( +
+ ( + + URL + + + + + + )} + /> + ( + + {t("alertingWebhookMethod")} + + + + )} + /> + ( + + {t("alertingWebhookSecret")} + + + + + + )} + /> + +
+ ); +} + +function WebhookHeadersField({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const headers = + form.watch(`actions.${index}.headers` as const) ?? []; + return ( +
+ {t("alertingWebhookHeaders")} + {headers.map((_, hi) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} + +
+ ); +} + +function UserMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: string[]; + onChange: (v: string[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: users = [] } = useQuery(orgQueries.users({ orgId })); + const shown = useMemo(() => { + const qq = debounced.trim().toLowerCase(); + if (!qq) return users.slice(0, 200); + return users + .filter((u) => { + const label = getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }).toLowerCase(); + return ( + label.includes(qq) || + (u.email ?? "").toLowerCase().includes(qq) + ); + }) + .slice(0, 200); + }, [users, debounced]); + const toggle = (id: string) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectUsers") + : t("alertingUsersSelected", { count: value.length }); + return ( + + + + + + + + + {t("noResults")} + + {shown.map((u) => { + const uid = String(u.id); + return ( + toggle(uid)} + className="cursor-pointer" + > + + {getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + })} + + ); + })} + + + + + + ); +} + +function RoleMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectRoles") + : t("alertingRolesSelected", { count: value.length }); + return ( + + + + + + + + + {roles.map((r) => ( + toggle(r.roleId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + +export function AlertRuleSourceFields({ + orgId, + control +}: { + orgId: string; + control: Control; +}) { + const t = useTranslations(); + const { setValue, getValues } = useFormContext(); + const sourceType = useWatch({ control, name: "sourceType" }); + return ( +
+ ( + + {t("alertingSourceType")} + + + + )} + /> + {sourceType === "site" ? ( + ( + + {t("alertingPickSites")} + + + + )} + /> + ) : ( + ( + + {t("alertingPickResources")} + + + + )} + /> + )} +
+ ); +} + +export function AlertRuleTriggerFields({ + control +}: { + control: Control; +}) { + const t = useTranslations(); + const sourceType = useWatch({ control, name: "sourceType" }); + return ( + ( + + {t("alertingTrigger")} + + + + )} + /> + ); +} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx new file mode 100644 index 000000000..78d0fc8bb --- /dev/null +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -0,0 +1,715 @@ +"use client"; + +import { + ActionBlock, + AlertRuleSourceFields, + AlertRuleTriggerFields, + DropdownAddAction +} from "@app/components/alert-rule-editor/AlertRuleFields"; +import { SettingsContainer } from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { + buildFormSchema, + defaultFormValues, + formValuesToRule, + type AlertRuleFormAction, + type AlertRuleFormValues +} from "@app/lib/alertRuleForm"; +import { upsertRule } from "@app/lib/alertRulesLocalStorage"; +import { cn } from "@app/lib/cn"; +import { + Background, + Handle, + Position, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + type Edge, + type Node, + type NodeProps, + type NodeTypes +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ChevronLeft } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFieldArray, useForm, useWatch } from "react-hook-form"; +import { useTranslations } from "next-intl"; + +type AlertRuleT = ReturnType; + +export type AlertStepId = "source" | "trigger" | "actions"; + +type AlertStepNodeData = { + roleLabel: string; + title: string; + subtitle: string; + configured: boolean; + accent: string; + topBorderClass: string; +}; + +function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) { + if (v.sourceType === "site") { + if (v.siteIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummarySites", { count: v.siteIds.length }); + } + if (v.targetIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummaryHealthChecks", { count: v.targetIds.length }); +} + +function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { + switch (v.trigger) { + case "site_online": + return t("alertingTriggerSiteOnline"); + case "site_offline": + return t("alertingTriggerSiteOffline"); + case "health_check_healthy": + return t("alertingTriggerHcHealthy"); + case "health_check_unhealthy": + return t("alertingTriggerHcUnhealthy"); + default: + return v.trigger; + } +} + +function oneActionConfigured(a: AlertRuleFormAction): boolean { + if (a.type === "notify") { + return ( + a.userIds.length > 0 || + a.roleIds.length > 0 || + a.emailTags.length > 0 + ); + } + if (a.type === "sms") { + return a.phoneTags.length > 0; + } + try { + new URL(a.url.trim()); + return true; + } catch { + return false; + } +} + +function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string { + switch (a.type) { + case "notify": + return t("alertingActionNotify"); + case "sms": + return t("alertingActionSms"); + case "webhook": + return t("alertingActionWebhook"); + } +} + +function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + return t("alertingNodeNotConfigured"); + } + const parts: string[] = []; + if (a.userIds.length > 0) { + parts.push(t("alertingUsersSelected", { count: a.userIds.length })); + } + if (a.roleIds.length > 0) { + parts.push(t("alertingRolesSelected", { count: a.roleIds.length })); + } + if (a.emailTags.length > 0) { + parts.push( + `${t("alertingNotifyEmails")} (${a.emailTags.length})` + ); + } + return parts.join(" · "); + } + if (a.type === "sms") { + if (a.phoneTags.length === 0) { + return t("alertingNodeNotConfigured"); + } + return `${t("alertingSmsNumbers")}: ${a.phoneTags.length}`; + } + const url = a.url.trim(); + if (!url) { + return t("alertingNodeNotConfigured"); + } + try { + return new URL(url).hostname; + } catch { + return t("alertingNodeNotConfigured"); + } +} + +function stepConfigured( + step: "source" | "trigger", + v: AlertRuleFormValues +): boolean { + if (step === "source") { + return v.sourceType === "site" + ? v.siteIds.length > 0 + : v.targetIds.length > 0; + } + return Boolean(v.trigger); +} + +function buildActionStepNodeData( + index: number, + action: AlertRuleFormAction, + t: AlertRuleT +): AlertStepNodeData { + return { + roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`, + title: actionTypeLabel(action, t), + subtitle: summarizeOneAction(action, t), + configured: oneActionConfigured(action), + accent: "text-amber-600 dark:text-amber-400", + topBorderClass: "border-t-amber-500" + }; +} + +function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData { + return { + roleLabel: t("alertingNodeRoleAction"), + title: t("alertingSectionActions"), + subtitle: t("alertingNodeNotConfigured"), + configured: false, + accent: "text-amber-600 dark:text-amber-400", + topBorderClass: "border-t-amber-500" + }; +} + +const AlertStepNode = memo(function AlertStepNodeFn({ + data, + selected +}: NodeProps>) { + return ( +
+ + {data.configured && ( + + )} +

+ {data.roleLabel} +

+

{data.title}

+

+ {data.subtitle} +

+ +
+ ); +}); + +const nodeTypes: NodeTypes = { + alertStep: AlertStepNode +}; + +const ACTION_NODE_X_GAP = 280; +const ACTION_NODE_Y = 468; +const SOURCE_NODE_POS = { x: 120, y: 28 }; +const TRIGGER_NODE_POS = { x: 120, y: 248 }; + +function buildNodeData( + stepId: "source" | "trigger", + v: AlertRuleFormValues, + t: AlertRuleT +): AlertStepNodeData { + const accents: Record< + "source" | "trigger", + { accent: string; topBorderClass: string; role: string; title: string } + > = { + source: { + accent: "text-blue-600 dark:text-blue-400", + topBorderClass: "border-t-blue-500", + role: t("alertingNodeRoleSource"), + title: t("alertingSectionSource") + }, + trigger: { + accent: "text-emerald-600 dark:text-emerald-400", + topBorderClass: "border-t-emerald-500", + role: t("alertingNodeRoleTrigger"), + title: t("alertingSectionTrigger") + } + }; + const meta = accents[stepId]; + const subtitle = + stepId === "source" + ? summarizeSource(v, t) + : summarizeTrigger(v, t); + return { + roleLabel: meta.role, + title: meta.title, + subtitle, + configured: stepConfigured(stepId, v), + accent: meta.accent, + topBorderClass: meta.topBorderClass + }; +} + +type AlertRuleGraphEditorProps = { + orgId: string; + ruleId: string; + createdAt: string; + initialValues: AlertRuleFormValues; + isNew: boolean; +}; + +const FORM_ID = "alert-rule-graph-form"; + +export default function AlertRuleGraphEditor({ + orgId, + ruleId, + createdAt, + initialValues, + isNew +}: AlertRuleGraphEditorProps) { + const t = useTranslations(); + const router = useRouter(); + const schema = useMemo(() => buildFormSchema(t), [t]); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: initialValues ?? defaultFormValues() + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "actions" + }); + + const wName = useWatch({ control: form.control, name: "name" }) ?? ""; + const wEnabled = + useWatch({ control: form.control, name: "enabled" }) ?? true; + const wSourceType = + useWatch({ control: form.control, name: "sourceType" }) ?? "site"; + const wSiteIds = + useWatch({ control: form.control, name: "siteIds" }) ?? []; + const wTargetIds = + useWatch({ control: form.control, name: "targetIds" }) ?? []; + const wTrigger = + useWatch({ control: form.control, name: "trigger" }) ?? + "site_offline"; + const wActions = + useWatch({ control: form.control, name: "actions" }) ?? []; + + const flowValues: AlertRuleFormValues = useMemo( + () => ({ + name: wName, + enabled: wEnabled, + sourceType: wSourceType, + siteIds: wSiteIds, + targetIds: wTargetIds, + trigger: wTrigger, + actions: wActions + }), + [ + wName, + wEnabled, + wSourceType, + wSiteIds, + wTargetIds, + wTrigger, + wActions + ] + ); + + const [selectedStep, setSelectedStep] = useState("source"); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const nodesSyncKeyRef = useRef(""); + useEffect(() => { + const key = JSON.stringify({ flowValues, selectedStep }); + if (key === nodesSyncKeyRef.current) { + return; + } + nodesSyncKeyRef.current = key; + + const nActions = flowValues.actions.length; + const actionNodes: Node[] = + nActions === 0 + ? [ + { + id: "actions", + type: "alertStep", + position: { + x: TRIGGER_NODE_POS.x, + y: ACTION_NODE_Y + }, + data: buildActionsPlaceholderNodeData(t), + selected: + selectedStep === "actions" || + selectedStep.startsWith("action-") + } + ] + : flowValues.actions.map((action, i) => { + const totalWidth = + (nActions - 1) * ACTION_NODE_X_GAP; + const originX = + TRIGGER_NODE_POS.x - totalWidth / 2; + return { + id: `action-${i}`, + type: "alertStep", + position: { + x: originX + i * ACTION_NODE_X_GAP, + y: ACTION_NODE_Y + }, + data: buildActionStepNodeData(i, action, t), + selected: selectedStep === `action-${i}` + }; + }); + + setNodes([ + { + id: "source", + type: "alertStep", + position: SOURCE_NODE_POS, + data: buildNodeData("source", flowValues, t), + selected: selectedStep === "source" + }, + { + id: "trigger", + type: "alertStep", + position: TRIGGER_NODE_POS, + data: buildNodeData("trigger", flowValues, t), + selected: selectedStep === "trigger" + }, + ...actionNodes + ]); + + const nextEdges: Edge[] = [ + { + id: "e-src-trg", + source: "source", + target: "trigger", + animated: true + }, + ...(nActions === 0 + ? [ + { + id: "e-trg-act", + source: "trigger", + target: "actions", + animated: true + } as const + ] + : flowValues.actions.map((_, i) => ({ + id: `e-trg-act-${i}`, + source: "trigger", + target: `action-${i}`, + animated: true + }))) + ]; + setEdges(nextEdges); + }, [flowValues, selectedStep, t, setNodes, setEdges]); + + useEffect(() => { + if (selectedStep === "actions" && wActions.length > 0) { + setSelectedStep("action-0"); + } + }, [selectedStep, wActions.length]); + + useEffect(() => { + if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) { + setSelectedStep("actions"); + } + }, [wActions.length, selectedStep]); + + useEffect(() => { + const m = /^action-(\d+)$/.exec(selectedStep); + if (!m) { + return; + } + const i = Number(m[1], 10); + if (i >= wActions.length) { + setSelectedStep( + wActions.length > 0 + ? `action-${wActions.length - 1}` + : "actions" + ); + } + }, [wActions.length, selectedStep]); + + const onNodeClick = useCallback((_event: unknown, node: Node) => { + setSelectedStep(node.id); + }, []); + + const onSubmit = form.handleSubmit((values) => { + const next = formValuesToRule(values, ruleId, createdAt); + upsertRule(orgId, next); + toast({ title: t("alertingRuleSaved") }); + if (isNew) { + router.replace(`/${orgId}/settings/alerting/${ruleId}`); + } + }); + + const isActionsSidebar = + selectedStep === "actions" || selectedStep.startsWith("action-"); + + const sidebarTitle = isActionsSidebar + ? t("alertingConfigureActions") + : selectedStep === "source" + ? t("alertingConfigureSource") + : t("alertingConfigureTrigger"); + + return ( +
+ + + + +
+
+ + {isNew && ( + + {t("alertingDraftBadge")} + + )} +
+ ( + + + {t("name")} + + + + + + + )} + /> +
+ ( + + + {t("alertingRuleEnabled")} + + + + + + )} + /> + +
+
+
+
+ +
+ + + + {t("alertingGraphCanvasTitle")} + + + {t("alertingGraphCanvasDescription")} + + + +
+ + + + + +
+
+
+ + + + + {sidebarTitle} + + + {t("alertingSidebarHint")} + + + +
+ {selectedStep === "source" && ( + + )} + {selectedStep === "trigger" && ( + + )} + {isActionsSidebar && ( +
+
+ + {t( + "alertingSectionActions" + )} + + { + const newIndex = + fields.length; + if (type === "notify") { + append({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } else if ( + type === "sms" + ) { + append({ + type: "sms", + phoneTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { + key: "", + value: "" + } + ], + secret: "" + }); + } + setSelectedStep( + `action-${newIndex}` + ); + }} + /> +
+ {fields.map((f, index) => ( + + remove(index) + } + canRemove + /> + ))} +
+ )} +
+
+
+
+
+
+ + ); +} diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts new file mode 100644 index 000000000..c219f316b --- /dev/null +++ b/src/lib/alertRuleForm.ts @@ -0,0 +1,283 @@ +import type { Tag } from "@app/components/tags/tag-input"; +import { + type AlertRule, + type AlertTrigger, + isoNow, + type AlertAction as StoredAlertAction +} from "@app/lib/alertRulesLocalStorage"; +import { z } from "zod"; + +export const tagSchema = z.object({ + id: z.string(), + text: z.string() +}); + +export type AlertRuleFormAction = + | { + type: "notify"; + userIds: string[]; + roleIds: number[]; + emailTags: Tag[]; + } + | { type: "sms"; phoneTags: Tag[] } + | { + type: "webhook"; + url: string; + method: string; + headers: { key: string; value: string }[]; + secret: string; + }; + +export type AlertRuleFormValues = { + name: string; + enabled: boolean; + sourceType: "site" | "health_check"; + siteIds: number[]; + targetIds: number[]; + trigger: AlertTrigger; + actions: AlertRuleFormAction[]; +}; + +export function buildFormSchema(t: (k: string) => string) { + return z + .object({ + name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + enabled: z.boolean(), + sourceType: z.enum(["site", "health_check"]), + siteIds: z.array(z.number()), + targetIds: z.array(z.number()), + trigger: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" + ]), + actions: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("sms"), + phoneTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + secret: z.string() + }) + ]) + ) + }) + .superRefine((val, ctx) => { + if (val.actions.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorActionsMin"), + path: ["actions"] + }); + } + if (val.sourceType === "site" && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickSites"), + path: ["siteIds"] + }); + } + if ( + val.sourceType === "health_check" && + val.targetIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickHealthChecks"), + path: ["targetIds"] + }); + } + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline" + ]; + const hcTriggers: AlertTrigger[] = [ + "health_check_healthy", + "health_check_unhealthy" + ]; + if ( + val.sourceType === "site" && + !siteTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerSite"), + path: ["trigger"] + }); + } + if ( + val.sourceType === "health_check" && + !hcTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerHealth"), + path: ["trigger"] + }); + } + val.actions.forEach((a, i) => { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorNotifyRecipients"), + path: ["actions", i, "userIds"] + }); + } + } + if (a.type === "sms" && a.phoneTags.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorSmsPhones"), + path: ["actions", i, "phoneTags"] + }); + } + if (a.type === "webhook") { + try { + // eslint-disable-next-line no-new + new URL(a.url.trim()); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorWebhookUrl"), + path: ["actions", i, "url"] + }); + } + } + }); + }); +} + +export function defaultFormValues(): AlertRuleFormValues { + return { + name: "", + enabled: true, + sourceType: "site", + siteIds: [], + targetIds: [], + trigger: "site_offline", + actions: [ + { + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + } + ] + }; +} + +export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { + const actions: AlertRuleFormAction[] = rule.actions.map( + (a: StoredAlertAction) => { + if (a.type === "notify") { + return { + type: "notify", + userIds: a.userIds.map(String), + roleIds: [...a.roleIds], + emailTags: a.emails.map((e) => ({ id: e, text: e })) + }; + } + if (a.type === "sms") { + return { + type: "sms", + phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) + }; + } + return { + type: "webhook", + url: a.url, + method: a.method, + headers: + a.headers.length > 0 + ? a.headers.map((h) => ({ ...h })) + : [{ key: "", value: "" }], + secret: a.secret ?? "" + }; + } + ); + return { + name: rule.name, + enabled: rule.enabled, + sourceType: rule.source.type, + siteIds: + rule.source.type === "site" ? [...rule.source.siteIds] : [], + targetIds: + rule.source.type === "health_check" + ? [...rule.source.targetIds] + : [], + trigger: rule.trigger, + actions + }; +} + +export function formValuesToRule( + v: AlertRuleFormValues, + id: string, + createdAt: string +): AlertRule { + const source = + v.sourceType === "site" + ? { type: "site" as const, siteIds: v.siteIds } + : { + type: "health_check" as const, + targetIds: v.targetIds + }; + const actions = v.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify" as const, + userIds: a.userIds, + roleIds: a.roleIds, + emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean) + }; + } + if (a.type === "sms") { + return { + type: "sms" as const, + phoneNumbers: a.phoneTags + .map((tg) => tg.text.trim()) + .filter(Boolean) + }; + } + return { + type: "webhook" as const, + url: a.url.trim(), + method: a.method, + headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), + secret: a.secret.trim() || undefined + }; + }); + return { + id, + name: v.name.trim(), + enabled: v.enabled, + createdAt, + updatedAt: isoNow(), + source, + trigger: v.trigger, + actions + }; +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts index 2e26cee71..bccc35331 100644 --- a/src/lib/alertRulesLocalStorage.ts +++ b/src/lib/alertRulesLocalStorage.ts @@ -64,6 +64,10 @@ function storageKey(orgId: string) { return `${STORAGE_PREFIX}${orgId}`; } +export function getRule(orgId: string, ruleId: string): AlertRule | undefined { + return loadRules(orgId).find((r) => r.id === ruleId); +} + export function loadRules(orgId: string): AlertRule[] { if (typeof window === "undefined") { return []; From a4d8789c20593e48f902fae9a751996f8c1b72d1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 31 Mar 2026 21:13:23 +0200 Subject: [PATCH 022/176] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20move=20from=20r?= =?UTF-8?q?eact.forwardref=20to=20normal=20ref=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/tags/tag-input.tsx | 1319 ++++++++++++++--------------- 1 file changed, 645 insertions(+), 674 deletions(-) diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index e8cfa370a..36a173911 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -1,8 +1,8 @@ "use client"; -import React from "react"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; +import * as React from "react"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; import { type VariantProps } from "class-variance-authority"; // import { CommandInput } from '../ui/command'; import { TagPopover } from "./tag-popover"; @@ -103,201 +103,89 @@ export interface TagInputProps addOnPaste?: boolean; addTagsOnBlur?: boolean; generateTagId?: () => string; + ref?: React.Ref; } -const TagInput = React.forwardRef( - (props, ref) => { - const { - id, - placeholder, - tags, - setTags, - variant, - size, - shape, - enableAutocomplete, - autocompleteOptions, - maxTags, - delimiter = Delimiter.Comma, - onTagAdd, - onTagRemove, - allowDuplicates, - showCount, - validateTag, - placeholderWhenFull = "Max tags reached", - sortTags, - delimiterList, - truncate, - autocompleteFilter, - borderStyle, - textCase, - interaction, - animation, - textStyle, - minLength, - maxLength, - direction = "row", - onInputChange, - customTagRenderer, - onFocus, - onBlur, - onTagClick, - draggable = false, - inputFieldPosition = "bottom", - clearAll = false, - onClearAll, - usePopoverForTags = false, - inputProps = {}, - restrictTagsToAutocompleteOptions, - inlineTags = true, - addTagsOnBlur = false, - activeTagIndex, - setActiveTagIndex, - styleClasses = {}, - disabled = false, - usePortal = false, - addOnPaste = false, - generateTagId = uuid - } = props; +export function TagInput({ ref, ...props }: TagInputProps) { + const { + id, + placeholder, + tags, + setTags, + variant, + size, + shape, + enableAutocomplete, + autocompleteOptions, + maxTags, + delimiter = Delimiter.Comma, + onTagAdd, + onTagRemove, + allowDuplicates, + showCount, + validateTag, + placeholderWhenFull = "Max tags reached", + sortTags, + delimiterList, + truncate, + autocompleteFilter, + borderStyle, + textCase, + interaction, + animation, + textStyle, + minLength, + maxLength, + direction = "row", + onInputChange, + customTagRenderer, + onFocus, + onBlur, + onTagClick, + draggable = false, + inputFieldPosition = "bottom", + clearAll = false, + onClearAll, + usePopoverForTags = false, + inputProps = {}, + restrictTagsToAutocompleteOptions, + inlineTags = true, + addTagsOnBlur = false, + activeTagIndex, + setActiveTagIndex, + styleClasses = {}, + disabled = false, + usePortal = false, + addOnPaste = false, + generateTagId = uuid + } = props; - const [inputValue, setInputValue] = React.useState(""); - const [tagCount, setTagCount] = React.useState( - Math.max(0, tags.length) - ); - const inputRef = React.useRef(null); + const [inputValue, setInputValue] = React.useState(""); + const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); + const inputRef = React.useRef(null); - const t = useTranslations(); + const t = useTranslations(); - if ( - (maxTags !== undefined && maxTags < 0) || - (props.minTags !== undefined && props.minTags < 0) - ) { - console.warn(t("tagsWarnCannotBeLessThanZero")); - // error - return null; - } + if ( + (maxTags !== undefined && maxTags < 0) || + (props.minTags !== undefined && props.minTags < 0) + ) { + console.warn(t("tagsWarnCannotBeLessThanZero")); + // error + return null; + } - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - if (addOnPaste && newValue.includes(delimiter)) { - const splitValues = newValue - .split(delimiter) - .map((v) => v.trim()) - .filter((v) => v); - splitValues.forEach((value) => { - if (!value) return; // Skip empty strings from split + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (addOnPaste && newValue.includes(delimiter)) { + const splitValues = newValue + .split(delimiter) + .map((v) => v.trim()) + .filter((v) => v); + splitValues.forEach((value) => { + if (!value) return; // Skip empty strings from split - const newTagText = value.trim(); - - // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true - if ( - restrictTagsToAutocompleteOptions && - !autocompleteOptions?.some( - (option) => option.text === newTagText - ) - ) { - console.warn( - t("tagsWarnNotAllowedAutocompleteOptions") - ); - return; - } - - if (validateTag && !validateTag(newTagText)) { - console.warn(t("tagsWarnInvalid")); - return; - } - - if (minLength && newTagText.length < minLength) { - console.warn( - t("tagWarnTooShort", { tagText: newTagText }) - ); - return; - } - - if (maxLength && newTagText.length > maxLength) { - console.warn( - t("tagWarnTooLong", { tagText: newTagText }) - ); - return; - } - - const newTagId = generateTagId(); - - // Add tag if duplicates are allowed or tag does not already exist - if ( - allowDuplicates || - !tags.some((tag) => tag.text === newTagText) - ) { - if (maxTags === undefined || tags.length < maxTags) { - // Check for maxTags limit - const newTag = { id: newTagId, text: newTagText }; - setTags((prevTags) => [...prevTags, newTag]); - onTagAdd?.(newTagText); - } else { - console.warn(t("tagsWarnReachedMaxNumber")); - } - } else { - console.warn( - t("tagWarnDuplicate", { tagText: newTagText }) - ); - } - }); - setInputValue(""); - } else { - setInputValue(newValue); - } - onInputChange?.(newValue); - }; - - const handleInputFocus = ( - event: React.FocusEvent - ) => { - setActiveTagIndex(null); // Reset active tag index when the input field gains focus - onFocus?.(event); - }; - - const handleInputBlur = (event: React.FocusEvent) => { - if (addTagsOnBlur && inputValue.trim()) { - const newTagText = inputValue.trim(); - - if (validateTag && !validateTag(newTagText)) { - return; - } - - if (minLength && newTagText.length < minLength) { - console.warn(t("tagWarnTooShort")); - return; - } - - if (maxLength && newTagText.length > maxLength) { - console.warn(t("tagWarnTooLong")); - return; - } - - if ( - (allowDuplicates || - !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) - ) { - const newTagId = generateTagId(); - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); - setInputValue(""); - } - } - - onBlur?.(event); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if ( - delimiterList - ? delimiterList.includes(e.key) - : e.key === delimiter || e.key === Delimiter.Enter - ) { - e.preventDefault(); - const newTagText = inputValue.trim(); + const newTagText = value.trim(); // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true if ( @@ -306,189 +194,442 @@ const TagInput = React.forwardRef( (option) => option.text === newTagText ) ) { - // error + console.warn(t("tagsWarnNotAllowedAutocompleteOptions")); return; } if (validateTag && !validateTag(newTagText)) { + console.warn(t("tagsWarnInvalid")); return; } if (minLength && newTagText.length < minLength) { - console.warn(t("tagWarnTooShort")); - // error + console.warn(t("tagWarnTooShort", { tagText: newTagText })); return; } - // Validate maxLength if (maxLength && newTagText.length > maxLength) { - // error - console.warn(t("tagWarnTooLong")); + console.warn(t("tagWarnTooLong", { tagText: newTagText })); return; } const newTagId = generateTagId(); + // Add tag if duplicates are allowed or tag does not already exist if ( - newTagText && - (allowDuplicates || - !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) + allowDuplicates || + !tags.some((tag) => tag.text === newTagText) ) { - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); + if (maxTags === undefined || tags.length < maxTags) { + // Check for maxTags limit + const newTag = { id: newTagId, text: newTagText }; + setTags((prevTags) => [...prevTags, newTag]); + onTagAdd?.(newTagText); + } else { + console.warn(t("tagsWarnReachedMaxNumber")); + } + } else { + console.warn( + t("tagWarnDuplicate", { tagText: newTagText }) + ); } - setInputValue(""); - } else { - switch (e.key) { - case "Delete": - if (activeTagIndex !== null) { - e.preventDefault(); - const newTags = [...tags]; - newTags.splice(activeTagIndex, 1); - setTags(newTags); - setActiveTagIndex((prev) => - newTags.length === 0 - ? null - : prev! >= newTags.length - ? newTags.length - 1 - : prev - ); - setTagCount((prevTagCount) => prevTagCount - 1); - onTagRemove?.(tags[activeTagIndex].text); - } - break; - case "Backspace": - if (activeTagIndex !== null) { - e.preventDefault(); - const newTags = [...tags]; - newTags.splice(activeTagIndex, 1); - setTags(newTags); - setActiveTagIndex((prev) => - prev! === 0 ? null : prev! - 1 - ); - setTagCount((prevTagCount) => prevTagCount - 1); - onTagRemove?.(tags[activeTagIndex].text); - } - break; - case "ArrowRight": - e.preventDefault(); - if (activeTagIndex === null) { - setActiveTagIndex(0); - } else { - setActiveTagIndex((prev) => - prev! + 1 >= tags.length ? 0 : prev! + 1 - ); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (activeTagIndex === null) { - setActiveTagIndex(tags.length - 1); - } else { - setActiveTagIndex((prev) => - prev! === 0 ? tags.length - 1 : prev! - 1 - ); - } - break; - case "Home": - e.preventDefault(); - setActiveTagIndex(0); - break; - case "End": - e.preventDefault(); - setActiveTagIndex(tags.length - 1); - break; - } - } - }; - - const removeTag = (idToRemove: string) => { - setTags(tags.filter((tag) => tag.id !== idToRemove)); - onTagRemove?.( - tags.find((tag) => tag.id === idToRemove)?.text || "" - ); - setTagCount((prevTagCount) => prevTagCount - 1); - }; - - const onSortEnd = (oldIndex: number, newIndex: number) => { - setTags((currentTags) => { - const newTags = [...currentTags]; - const [removedTag] = newTags.splice(oldIndex, 1); - newTags.splice(newIndex, 0, removedTag); - - return newTags; }); - }; + setInputValue(""); + } else { + setInputValue(newValue); + } + onInputChange?.(newValue); + }; - const handleClearAll = () => { - if (!onClearAll) { - setActiveTagIndex(-1); - setTags([]); + const handleInputFocus = (event: React.FocusEvent) => { + setActiveTagIndex(null); // Reset active tag index when the input field gains focus + onFocus?.(event); + }; + + const handleInputBlur = (event: React.FocusEvent) => { + if (addTagsOnBlur && inputValue.trim()) { + const newTagText = inputValue.trim(); + + if (validateTag && !validateTag(newTagText)) { return; } - onClearAll?.(); - }; - // const filteredAutocompleteOptions = autocompleteFilter - // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) - // : autocompleteOptions; - const displayedTags = sortTags ? [...tags].sort() : tags; + if (minLength && newTagText.length < minLength) { + console.warn(t("tagWarnTooShort")); + return; + } - const truncatedTags = truncate - ? tags.map((tag) => ({ - id: tag.id, - text: - tag.text?.length > truncate - ? `${tag.text.substring(0, truncate)}...` - : tag.text - })) - : displayedTags; + if (maxLength && newTagText.length > maxLength) { + console.warn(t("tagWarnTooLong")); + return; + } - return ( -
0 ? "gap-3" : ""} ${ - inputFieldPosition === "bottom" - ? "flex-col" - : inputFieldPosition === "top" - ? "flex-col-reverse" - : "flex-row" - }`} - > - {!usePopoverForTags && - (!inlineTags ? ( - - ) : ( - !enableAutocomplete && ( -
+ if ( + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + const newTagId = generateTagId(); + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + setInputValue(""); + } + } + + onBlur?.(event); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + delimiterList + ? delimiterList.includes(e.key) + : e.key === delimiter || e.key === Delimiter.Enter + ) { + e.preventDefault(); + const newTagText = inputValue.trim(); + + // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true + if ( + restrictTagsToAutocompleteOptions && + !autocompleteOptions?.some( + (option) => option.text === newTagText + ) + ) { + // error + return; + } + + if (validateTag && !validateTag(newTagText)) { + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn(t("tagWarnTooShort")); + // error + return; + } + + // Validate maxLength + if (maxLength && newTagText.length > maxLength) { + // error + console.warn(t("tagWarnTooLong")); + return; + } + + const newTagId = generateTagId(); + + if ( + newTagText && + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + } + setInputValue(""); + } else { + switch (e.key) { + case "Delete": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + newTags.length === 0 + ? null + : prev! >= newTags.length + ? newTags.length - 1 + : prev + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "Backspace": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + prev! === 0 ? null : prev! - 1 + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "ArrowRight": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(0); + } else { + setActiveTagIndex((prev) => + prev! + 1 >= tags.length ? 0 : prev! + 1 + ); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(tags.length - 1); + } else { + setActiveTagIndex((prev) => + prev! === 0 ? tags.length - 1 : prev! - 1 + ); + } + break; + case "Home": + e.preventDefault(); + setActiveTagIndex(0); + break; + case "End": + e.preventDefault(); + setActiveTagIndex(tags.length - 1); + break; + } + } + }; + + const removeTag = (idToRemove: string) => { + setTags(tags.filter((tag) => tag.id !== idToRemove)); + onTagRemove?.(tags.find((tag) => tag.id === idToRemove)?.text || ""); + setTagCount((prevTagCount) => prevTagCount - 1); + }; + + const onSortEnd = (oldIndex: number, newIndex: number) => { + setTags((currentTags) => { + const newTags = [...currentTags]; + const [removedTag] = newTags.splice(oldIndex, 1); + newTags.splice(newIndex, 0, removedTag); + + return newTags; + }); + }; + + const handleClearAll = () => { + if (!onClearAll) { + setActiveTagIndex(-1); + setTags([]); + return; + } + onClearAll?.(); + }; + + // const filteredAutocompleteOptions = autocompleteFilter + // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) + // : autocompleteOptions; + const displayedTags = sortTags ? [...tags].sort() : tags; + + const truncatedTags = truncate + ? tags.map((tag) => ({ + id: tag.id, + text: + tag.text?.length > truncate + ? `${tag.text.substring(0, truncate)}...` + : tag.text + })) + : displayedTags; + + return ( +
0 ? "gap-3" : ""} ${ + inputFieldPosition === "bottom" + ? "flex-col" + : inputFieldPosition === "top" + ? "flex-col-reverse" + : "flex-row" + }`} + > + {!usePopoverForTags && + (!inlineTags ? ( + + ) : ( + !enableAutocomplete && ( +
+
+ + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> +
+
+ ) + ))} + {enableAutocomplete ? ( +
+ + {!usePopoverForTags ? ( + !inlineTags ? ( + // = maxTags ? placeholderWhenFull : placeholder} + // ref={inputRef} + // value={inputValue} + // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + // onChangeCapture={handleInputChange} + // onKeyDown={handleKeyDown} + // onFocus={handleInputFocus} + // onBlur={handleInputBlur} + // className={cn( + // 'w-full', + // // className, + // styleClasses?.input, + // )} + // /> + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : (
@@ -518,6 +659,22 @@ const TagInput = React.forwardRef( }} disabled={disabled} /> + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + inlineTags={inlineTags} + className={cn( + 'border-0 flex-1 w-fit h-5', + // className, + styleClasses?.input, + )} + /> */} ( } />
-
- ) - ))} - {enableAutocomplete ? ( -
- - {!usePopoverForTags ? ( - !inlineTags ? ( - // = maxTags ? placeholderWhenFull : placeholder} - // ref={inputRef} - // value={inputValue} - // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - // onChangeCapture={handleInputChange} - // onKeyDown={handleKeyDown} - // onFocus={handleInputFocus} - // onBlur={handleInputBlur} - // className={cn( - // 'w-full', - // // className, - // styleClasses?.input, - // )} - // /> - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - ) : ( -
- - {/* = maxTags ? placeholderWhenFull : placeholder} - ref={inputRef} - value={inputValue} - disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - onChangeCapture={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - inlineTags={inlineTags} - className={cn( - 'border-0 flex-1 w-fit h-5', - // className, - styleClasses?.input, - )} - /> */} - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete - ? "on" - : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> -
- ) - ) : ( - - {/* = maxTags ? placeholderWhenFull : placeholder} - ref={inputRef} - value={inputValue} - disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} - onChangeCapture={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - className={cn( - 'w-full', - // className, - styleClasses?.input, - )} - /> */} - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", - // className, - styleClasses?.input - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - - )} -
-
- ) : ( -
- {!usePopoverForTags ? ( - !inlineTags ? ( - = maxTags - ? placeholderWhenFull - : placeholder - } - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - styleClasses?.input, - "shadow-none inset-shadow-none" - // className - )} - autoComplete={ - enableAutocomplete ? "on" : "off" - } - list={ - enableAutocomplete - ? "autocomplete-options" - : undefined - } - disabled={ - disabled || - (maxTags !== undefined && - tags.length >= maxTags) - } - /> - ) : null + ) ) : ( ( }} disabled={disabled} > + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + className={cn( + 'w-full', + // className, + styleClasses?.input, + )} + /> */} ( onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + // className, + styleClasses?.input + )} autoComplete={ enableAutocomplete ? "on" : "off" } @@ -907,42 +787,133 @@ const TagInput = React.forwardRef( (maxTags !== undefined && tags.length >= maxTags) } - className={cn( - "border-0 w-full shadow-none inset-shadow-none", - styleClasses?.input - // className - )} /> )} -
- )} + +
+ ) : ( +
+ {!usePopoverForTags ? ( + !inlineTags ? ( + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + styleClasses?.input, + "shadow-none inset-shadow-none" + // className + )} + autoComplete={enableAutocomplete ? "on" : "off"} + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : null + ) : ( + + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + autoComplete={enableAutocomplete ? "on" : "off"} + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + className={cn( + "border-0 w-full shadow-none inset-shadow-none", + styleClasses?.input + // className + )} + /> + + )} +
+ )} - {showCount && maxTags && ( -
- - {`${tagCount}`}/{`${maxTags}`} - -
- )} - {clearAll && ( - - )} -
- ); - } -); - -TagInput.displayName = "TagInput"; + {showCount && maxTags && ( +
+ + {`${tagCount}`}/{`${maxTags}`} + +
+ )} + {clearAll && ( + + )} +
+ ); +} export function uuid() { return crypto.getRandomValues(new Uint32Array(1))[0].toString(); } - -export { TagInput }; From 543542713b0b17c93ee120d664c13225c7e427e4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 31 Mar 2026 22:44:18 +0200 Subject: [PATCH 023/176] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 2 +- src/components/machines-selector.tsx | 120 +++++++++++++----------- src/components/multi-select-tags.tsx | 77 +++++++++++++++ src/components/tags/tag-input.tsx | 6 +- 4 files changed, 146 insertions(+), 59 deletions(-) create mode 100644 src/components/multi-select-tags.tsx diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 2de907079..3f83205a7 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1063,7 +1063,7 @@ export function InternalResourceForm({ ] ) } - enableAutocomplete={true} + enableAutocomplete autocompleteOptions={ allRoles } diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 9c31a0bd3..99515135e 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -3,18 +3,9 @@ import type { ListClientsResponse } from "@server/routers/client"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "./ui/command"; -import { cn } from "@app/lib/cn"; -import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { MultiSelectTags } from "./multi-select-tags"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -57,52 +48,71 @@ export function MachinesSelector({ return allMachines; }, [machines, selectedMachines, debouncedValue]); - const selectedMachinesIds = new Set( - selectedMachines.map((m) => m.clientId) - ); + // const selectedMachinesIds = new Set( + // selectedMachines.map((m) => m.clientId) + // ); return ( - - - - {t("machineNotFound")} - - {machinesShown.map((m) => ( - { - let newMachineClients = []; - if (selectedMachinesIds.has(m.clientId)) { - newMachineClients = selectedMachines.filter( - (mc) => mc.clientId !== m.clientId - ); - } else { - newMachineClients = [ - ...selectedMachines, - m - ]; - } - onSelectMachines(newMachineClients); - }} - > - - {`${m.name}`} - - ))} - - - + ({ + ...m, + text: m.name, + id: m.clientId.toString() + }))} + onChange={(values) => { + onSelectMachines(values); + }} + options={machinesShown.map((m) => ({ + ...m, + id: m.clientId.toString(), + text: m.name + }))} + onSearch={setMachineSearchQuery} + searchQuery={machineSearchQuery} + /> + // + // + // + // {t("machineNotFound")} + // + // {machinesShown.map((m) => ( + // { + // let newMachineClients = []; + // if (selectedMachinesIds.has(m.clientId)) { + // newMachineClients = selectedMachines.filter( + // (mc) => mc.clientId !== m.clientId + // ); + // } else { + // newMachineClients = [ + // ...selectedMachines, + // m + // ]; + // } + // onSelectMachines(newMachineClients); + // }} + // > + // + // {`${m.name}`} + // + // ))} + // + // + // ); } diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select-tags.tsx new file mode 100644 index 000000000..2fb9b097d --- /dev/null +++ b/src/components/multi-select-tags.tsx @@ -0,0 +1,77 @@ +import type { Ref } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { cn } from "@app/lib/cn"; +import { CheckIcon } from "lucide-react"; + +export type TagValue = { text: string; id: string }; + +export type MultiSelectTagsProps = { + emptyPlaceholder: string; + searchPlaceholder: string; + searchQuery?: string; + options: Array; + value: Array; + onChange: (newValue: Array) => void; + onSearch: (query: string) => void; + ref?: Ref; +}; + +export function MultiSelectTags({ + emptyPlaceholder, + searchPlaceholder, + searchQuery, + value, + options, + onSearch, + onChange +}: MultiSelectTagsProps) { + const selectedValues = new Set(value.map((v) => v.id)); + return ( + + + + {emptyPlaceholder} + + {options.map((option) => ( + { + let newValues = []; + if (selectedValues.has(option.id)) { + newValues = value.filter( + (v) => v.id !== option.id + ); + } else { + newValues = [...value, option]; + } + onChange(newValues); + }} + > + + {`${option.text}`} + + ))} + + + + ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index 36a173911..fafd2144f 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -522,7 +522,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -692,7 +692,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -770,7 +770,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} From 1efd2af44b7122c6fa7846559b557aa49e9d3397 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 15:32:25 -0400 Subject: [PATCH 024/176] Sync acme certs into the database --- server/index.ts | 2 + server/lib/acmeCertSync.ts | 3 + server/private/lib/acmeCertSync.ts | 277 +++++++++++++++++++++++++++ server/private/lib/readConfigFile.ts | 13 +- 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 server/lib/acmeCertSync.ts create mode 100644 server/private/lib/acmeCertSync.ts diff --git a/server/index.ts b/server/index.ts index 0fc44c279..e3a6ba049 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { @@ -39,6 +40,7 @@ async function startServers() { initTelemetryClient(); initLogCleanupInterval(); + initAcmeCertSync(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/acmeCertSync.ts b/server/lib/acmeCertSync.ts new file mode 100644 index 000000000..d8fbd6368 --- /dev/null +++ b/server/lib/acmeCertSync.ts @@ -0,0 +1,3 @@ +export function initAcmeCertSync(): void { + // stub +} \ No newline at end of file diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts new file mode 100644 index 000000000..04d40809c --- /dev/null +++ b/server/private/lib/acmeCertSync.ts @@ -0,0 +1,277 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 fs from "fs"; +import crypto from "crypto"; +import { certificates, domains, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { encryptData, decryptData } from "@server/lib/encryption"; +import logger from "@server/logger"; +import config from "#private/lib/config"; + +interface AcmeCert { + domain: { main: string; sans?: string[] }; + certificate: string; + key: string; + Store: string; +} + +interface AcmeJson { + [resolver: string]: { + Certificates: AcmeCert[]; + }; +} + +function getEncryptionKey(): Buffer { + const keyHex = config.getRawPrivateConfig().server.encryption_key; + if (!keyHex) { + throw new Error("acmeCertSync: encryption key is not configured"); + } + return Buffer.from(keyHex, "hex"); +} + +async function findDomainId(certDomain: string): Promise { + // Strip wildcard prefix before lookup (*.example.com -> example.com) + const lookupDomain = certDomain.startsWith("*.") + ? certDomain.slice(2) + : certDomain; + + // 1. Exact baseDomain match (any domain type) + const exactMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where(eq(domains.baseDomain, lookupDomain)) + .limit(1); + + if (exactMatch.length > 0) { + return exactMatch[0].domainId; + } + + // 2. Walk up the domain hierarchy looking for a wildcard-type domain whose + // baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com" + // matches a wildcard domain with baseDomain "example.com". + const parts = lookupDomain.split("."); + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(i).join("."); + if (!candidate) continue; + + const wildcardMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where( + and( + eq(domains.baseDomain, candidate), + eq(domains.type, "wildcard") + ) + ) + .limit(1); + + if (wildcardMatch.length > 0) { + return wildcardMatch[0].domainId; + } + } + + return null; +} + +function extractFirstCert(pemBundle: string): string | null { + const match = pemBundle.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ + ); + return match ? match[0] : null; +} + +async function syncAcmeCerts( + acmeJsonPath: string, + resolver: string +): Promise { + let raw: string; + try { + raw = fs.readFileSync(acmeJsonPath, "utf8"); + } catch (err) { + logger.debug( + `acmeCertSync: could not read ${acmeJsonPath}: ${err}` + ); + return; + } + + let acmeJson: AcmeJson; + try { + acmeJson = JSON.parse(raw); + } catch (err) { + logger.debug(`acmeCertSync: could not parse acme.json: ${err}`); + return; + } + + const resolverData = acmeJson[resolver]; + if (!resolverData || !Array.isArray(resolverData.Certificates)) { + logger.debug( + `acmeCertSync: no certificates found for resolver "${resolver}"` + ); + return; + } + + const encryptionKey = getEncryptionKey(); + + for (const cert of resolverData.Certificates) { + const domain = cert.domain?.main; + + if (!domain) { + logger.debug( + `acmeCertSync: skipping cert with missing domain` + ); + continue; + } + + if (!cert.certificate || !cert.key) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - empty certificate or key field` + ); + continue; + } + + const certPem = Buffer.from(cert.certificate, "base64").toString( + "utf8" + ); + const keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + + if (!certPem.trim() || !keyPem.trim()) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode` + ); + continue; + } + + // Check if cert already exists in DB + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decryptData( + existing[0].certFile, + encryptionKey + ); + if (storedCertPem === certPem) { + logger.debug( + `acmeCertSync: cert for ${domain} is unchanged, skipping` + ); + continue; + } + } catch (err) { + // Decryption failure means we should proceed with the update + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + // Parse cert expiry from the first cert in the PEM bundle + let expiresAt: number | null = null; + const firstCertPem = extractFirstCert(certPem); + if (firstCertPem) { + try { + const x509 = new crypto.X509Certificate(firstCertPem); + expiresAt = Math.floor( + new Date(x509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + } + + const wildcard = domain.startsWith("*."); + const encryptedCert = encryptData(certPem, encryptionKey); + const encryptedKey = encryptData(keyPem, encryptionKey); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + logger.info( + `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + } else { + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + logger.info( + `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + } + } +} + +export function initAcmeCertSync(): void { + const privateConfig = config.getRawPrivateConfig(); + + if (!privateConfig.flags?.enable_acme_cert_sync) { + return; + } + + const acmeJsonPath = + privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; + const resolver = privateConfig.acme?.resolver ?? "letsencrypt"; + const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000; + + logger.info( + `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` + ); + + // Run immediately on init, then on the configured interval + syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + logger.error(`acmeCertSync: error during initial sync: ${err}`); + }); + + setInterval(() => { + syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + }, intervalMs); +} \ No newline at end of file diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 54260009b..a755e9fc3 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -95,10 +95,21 @@ export const privateConfigSchema = z.object({ .object({ enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), - use_org_only_idp: z.boolean().optional() + use_org_only_idp: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(false) }) .optional() .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + resolver: z.string().optional().default("letsencrypt"), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), branding: z .object({ app_name: z.string().optional(), From eb771ceda44638acabb89ec4990e5cf94f8fd9c6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:02:08 -0400 Subject: [PATCH 025/176] Add http to mode and put destinationPort back --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/blueprints/clientResources.ts | 2 ++ server/lib/blueprints/types.ts | 4 ++-- server/routers/siteResource/createSiteResource.ts | 7 ++++--- server/routers/siteResource/updateSiteResource.ts | 7 +++++-- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bde3e9aec..96c5b8ae6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -230,7 +230,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1fb04ef14..7dbbaf007 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -258,7 +258,7 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" + mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 80c691c63..4196c67ed 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -84,6 +84,7 @@ export async function updateClientResources( siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, @@ -223,6 +224,7 @@ export async function updateClientResources( name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 6ebc509b8..4a8dc272f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -325,11 +325,11 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr"]), + mode: z.enum(["host", "cidr", "http", "https"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + "destination-port": z.int().positive().optional(), destination: z.string().min(1), // enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1485a4192..f257e7b22 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,11 +36,11 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "port"]), + mode: z.enum(["host", "cidr", "port", "http", "https"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + destinationPort: z.int().positive().optional(), destination: z.string().min(1), enabled: z.boolean().default(true), alias: z @@ -163,7 +163,7 @@ export async function createSiteResource( mode, // protocol, // proxyPort, - // destinationPort, + destinationPort, destination, enabled, alias, @@ -295,6 +295,7 @@ export async function createSiteResource( name, mode: mode as "host" | "cidr", destination, + destinationPort, enabled, alias, aliasAddress, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 8f56ece0f..129375ee8 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,10 +51,10 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr"]).optional(), + mode: z.enum(["host", "cidr", "http", "https"]).optional(), // protocol: z.enum(["tcp", "udp"]).nullish(), // proxyPort: z.int().positive().nullish(), - // destinationPort: z.int().positive().nullish(), + destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), enabled: z.boolean().optional(), alias: z @@ -176,6 +176,7 @@ export async function updateSiteResource( niceId, mode, destination, + destinationPort, alias, enabled, userIds, @@ -347,6 +348,7 @@ export async function updateSiteResource( niceId, mode, destination, + destinationPort, enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString, @@ -450,6 +452,7 @@ export async function updateSiteResource( siteId: siteId, mode: mode, destination: destination, + destinationPort: destinationPort, enabled: enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, From 333ccb84382b4b8f18e484429d9d854a51258962 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:10:48 -0400 Subject: [PATCH 026/176] Restrict to make sure there is an alias --- .../siteResource/createSiteResource.ts | 22 ++++++++++++------- .../siteResource/updateSiteResource.ts | 21 ++++++++++++------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f257e7b22..ca7424bb6 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -62,15 +62,21 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host") { - // Check if it's a valid IP address using zod (v4 or v6) - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if ( + data.mode === "host" || + data.mode == "http" || + data.mode == "https" + ) { + if (data.mode == "host") { + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; + if (isValidIP) { + return true; + } } // Check if it's a valid domain (hostname pattern, TLD not required) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 129375ee8..de4ad3398 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -76,14 +76,21 @@ const updateSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" && data.destination) { - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if ( + (data.mode === "host" || + data.mode == "http" || + data.mode == "https") && + data.destination + ) { + if (data.mode == "host") { + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; + if (isValidIP) { + return true; + } } // Check if it's a valid domain (hostname pattern, TLD not required) From e4cbf088b4d56598dd28185626c2da95dd0575a9 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:23:24 -0400 Subject: [PATCH 027/176] Working on defining the schema to send down --- server/lib/ip.ts | 81 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 7f829bcef..c7d02dc1b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -582,6 +582,16 @@ export type SubnetProxyTargetV2 = { protocol: "tcp" | "udp"; }[]; resourceId?: number; + protocol?: "http" | "https"; // if set, this target only applies to the specified protocol + httpTargets?: HTTPTarget[]; + tlsCert?: string; + tlsKey?: string; +}; + +export type HTTPTarget = { + destAddr: string; // must be an IP or hostname + destPort: number; + scheme: "http" | "https"; }; export function generateSubnetProxyTargetV2( @@ -619,7 +629,7 @@ export function generateSubnetProxyTargetV2( destPrefix: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, + resourceId: siteResource.siteResourceId }; } @@ -631,7 +641,7 @@ export function generateSubnetProxyTargetV2( rewriteTo: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, + resourceId: siteResource.siteResourceId }; } } else if (siteResource.mode == "cidr") { @@ -640,7 +650,34 @@ export function generateSubnetProxyTargetV2( destPrefix: siteResource.destination, portRange, disableIcmp, + resourceId: siteResource.siteResourceId + }; + } else if (siteResource.mode == "http" || siteResource.mode == "https") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + } + + if (!siteResource.alias || !siteResource.aliasAddress) { + logger.debug( + `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address, skipping alias target generation.` + ); + return; + } + // also push a match for the alias address + target = { + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination, + portRange, + disableIcmp, resourceId: siteResource.siteResourceId, + protocol: siteResource.mode, // will be either http or https, + httpTargets: [], + tlsCert: "", + tlsKey: "" }; } @@ -670,33 +707,31 @@ export function generateSubnetProxyTargetV2( return target; } - /** * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * by expanding each source prefix into its own target entry. * @param targetV2 - The v2 target to convert * @returns Array of v1 SubnetProxyTarget objects */ - export function convertSubnetProxyTargetsV2ToV1( - targetsV2: SubnetProxyTargetV2[] - ): SubnetProxyTarget[] { - return targetsV2.flatMap((targetV2) => - targetV2.sourcePrefixes.map((sourcePrefix) => ({ - sourcePrefix, - destPrefix: targetV2.destPrefix, - ...(targetV2.disableIcmp !== undefined && { - disableIcmp: targetV2.disableIcmp - }), - ...(targetV2.rewriteTo !== undefined && { - rewriteTo: targetV2.rewriteTo - }), - ...(targetV2.portRange !== undefined && { - portRange: targetV2.portRange - }) - })) - ); - } - +export function convertSubnetProxyTargetsV2ToV1( + targetsV2: SubnetProxyTargetV2[] +): SubnetProxyTarget[] { + return targetsV2.flatMap((targetV2) => + targetV2.sourcePrefixes.map((sourcePrefix) => ({ + sourcePrefix, + destPrefix: targetV2.destPrefix, + ...(targetV2.disableIcmp !== undefined && { + disableIcmp: targetV2.disableIcmp + }), + ...(targetV2.rewriteTo !== undefined && { + rewriteTo: targetV2.rewriteTo + }), + ...(targetV2.portRange !== undefined && { + portRange: targetV2.portRange + }) + })) + ); +} // Custom schema for validating port range strings // Format: "80,443,8000-9000" or "*" for all ports, or empty string From d73796b92e92d26b205d4756a4519910e9fd37df Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:49:22 -0400 Subject: [PATCH 028/176] add new modes, port input, and domain picker --- messages/en-US.json | 8 + .../siteResource/createSiteResource.ts | 7 +- .../siteResource/listAllSiteResourcesByOrg.ts | 4 +- src/components/ClientResourcesTable.tsx | 19 +- .../CreateInternalResourceDialog.tsx | 7 +- src/components/EditInternalResourceDialog.tsx | 7 +- src/components/InternalResourceForm.tsx | 707 +++++++++++------- 7 files changed, 495 insertions(+), 264 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6..e5b073f6c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1817,6 +1817,8 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", @@ -1860,6 +1862,8 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", @@ -2661,6 +2665,10 @@ "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "createInternalResourceDialogHttpConfiguration": "HTTP configuration", + "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP configuration", + "editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index ca7424bb6..e1b97bdca 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,7 +36,7 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "port", "http", "https"]), + mode: z.enum(["host", "cidr", "http", "https"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), @@ -286,8 +286,7 @@ export async function createSiteResource( const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; - if (mode == "host") { - // we can only have an alias on a host + if (mode === "host" || mode === "http" || mode === "https") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -299,7 +298,7 @@ export async function createSiteResource( niceId, orgId, name, - mode: mode as "host" | "cidr", + mode, destination, destinationPort, enabled, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3320aa3b7..36bc6bee0 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr"]) + .enum(["host", "cidr", "http", "https"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr"], + enum: ["host", "cidr", "http", "https"], description: "Filter site resources by mode" }), sort_by: z diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5066f273d..b25409c44 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -46,7 +46,7 @@ export type InternalResourceRow = { siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr"; + mode: "host" | "cidr" | "http" | "https"; // protocol: string | null; // proxyPort: number | null; siteId: number; @@ -215,6 +215,14 @@ export default function ClientResourcesTable({ { value: "cidr", label: t("editInternalResourceDialogModeCidr") + }, + { + value: "http", + label: t("editInternalResourceDialogModeHttp") + }, + { + value: "https", + label: t("editInternalResourceDialogModeHttps") } ]} selectedValue={searchParams.get("mode") ?? undefined} @@ -227,10 +235,15 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - const modeLabels: Record<"host" | "cidr" | "port", string> = { + const modeLabels: Record< + "host" | "cidr" | "port" | "http" | "https", + string + > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort") + port: t("editInternalResourceDialogModePort"), + http: t("editInternalResourceDialogModeHttp"), + https: t("editInternalResourceDialogModeHttps") }; return {modeLabels[resourceRow.mode]}; } diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index d5ca61acc..d37acdc6e 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -50,7 +50,12 @@ export default function CreateInternalResourceDialog({ setIsSubmitting(true); try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || + data.mode === "http" || + data.mode === "https") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 690ad405d..2a4cd35fc 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -54,7 +54,12 @@ export default function EditInternalResourceDialog({ async function handleSubmit(values: InternalResourceFormValues) { try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || + data.mode === "http" || + data.mode === "https") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index a4a793753..fb31d27b8 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -45,6 +45,7 @@ import { z } from "zod"; import { SitesSelector, type Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; +import DomainPicker from "@app/components/DomainPicker"; // --- Helpers (shared) --- @@ -120,12 +121,14 @@ export const cleanForFQDN = (name: string): string => type Site = ListSitesResponse["sites"][0]; +export type InternalResourceMode = "host" | "cidr" | "http" | "https"; + export type InternalResourceData = { id: number; name: string; orgId: string; siteName: string; - mode: "host" | "cidr"; + mode: InternalResourceMode; siteId: number; niceId: string; destination: string; @@ -135,6 +138,10 @@ export type InternalResourceData = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + httpConfigSubdomain?: string | null; + httpConfigDomainId?: string | null; + httpConfigFullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); @@ -142,7 +149,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); export type InternalResourceFormValues = { name: string; siteId: number; - mode: "host" | "cidr"; + mode: InternalResourceMode; destination: string; alias?: string | null; niceId?: string; @@ -151,6 +158,10 @@ export type InternalResourceFormValues = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + httpConfigSubdomain?: string | null; + httpConfigDomainId?: string | null; + httpConfigFullDomain?: string | null; roles?: z.infer[]; users?: z.infer[]; clients?: z.infer[]; @@ -211,6 +222,14 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogModeCidr" : "editInternalResourceDialogModeCidr"; + const modeHttpKey = + variant === "create" + ? "createInternalResourceDialogModeHttp" + : "editInternalResourceDialogModeHttp"; + const modeHttpsKey = + variant === "create" + ? "createInternalResourceDialogModeHttps" + : "editInternalResourceDialogModeHttps"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" @@ -223,6 +242,18 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; + const httpHttpsPortLabelKey = + variant === "create" + ? "createInternalResourceDialogModePort" + : "editInternalResourceDialogModePort"; + const httpConfigurationTitleKey = + variant === "create" + ? "createInternalResourceDialogHttpConfiguration" + : "editInternalResourceDialogHttpConfiguration"; + const httpConfigurationDescriptionKey = + variant === "create" + ? "createInternalResourceDialogHttpConfigurationDescription" + : "editInternalResourceDialogHttpConfigurationDescription"; const formSchema = z.object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), @@ -230,7 +261,7 @@ export function InternalResourceForm({ .number() .int() .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), - mode: z.enum(["host", "cidr"]), + mode: z.enum(["host", "cidr", "http", "https"]), destination: z .string() .min( @@ -240,6 +271,10 @@ export function InternalResourceForm({ : undefined ), alias: z.string().nullish(), + httpHttpsPort: z.number().int().min(1).max(65535).optional().nullable(), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish(), niceId: z .string() .min(1) @@ -394,6 +429,10 @@ export function InternalResourceForm({ disableIcmp: resource.disableIcmp ?? false, authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + httpConfigSubdomain: resource.httpConfigSubdomain ?? null, + httpConfigDomainId: resource.httpConfigDomainId ?? null, + httpConfigFullDomain: resource.httpConfigFullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -405,6 +444,10 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, + httpHttpsPort: null, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -425,6 +468,10 @@ export function InternalResourceForm({ }); const mode = form.watch("mode"); + const httpConfigSubdomain = form.watch("httpConfigSubdomain"); + const httpConfigDomainId = form.watch("httpConfigDomainId"); + const httpConfigFullDomain = form.watch("httpConfigFullDomain"); + const isHttpOrHttps = mode === "http" || mode === "https"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); @@ -448,6 +495,10 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, + httpHttpsPort: null, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -475,6 +526,10 @@ export function InternalResourceForm({ mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + httpConfigSubdomain: resource.httpConfigSubdomain ?? null, + httpConfigDomainId: resource.httpConfigDomainId ?? null, + httpConfigFullDomain: resource.httpConfigFullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -701,6 +756,12 @@ export function InternalResourceForm({ {t(modeCidrKey)} + + {t(modeHttpKey)} + + + {t(modeHttpsKey)} + @@ -731,7 +792,7 @@ export function InternalResourceForm({ )} />
- {mode !== "cidr" && ( + {mode === "host" && (
)} + {(mode === "http" || mode === "https") && ( +
+ ( + + + {t( + httpHttpsPortLabelKey + )} + + + { + const raw = + e.target + .value; + if ( + raw === "" + ) { + field.onChange( + null + ); + return; + } + const n = + Number(raw); + field.onChange( + Number.isFinite( + n + ) + ? n + : null + ); + }} + /> + + + + )} + /> +
+ )} -
-
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" + {isHttpOrHttps ? ( +
+
+ +
+ {t(httpConfigurationDescriptionKey)} +
+
+ { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } + form.setValue( + "httpConfigSubdomain", + res.subdomain ?? null + ); + form.setValue( + "httpConfigDomainId", + res.domainId + ); + form.setValue( + "httpConfigFullDomain", + res.fullDomain + ); + }} + /> +
+ ) : ( +
+
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
-
-
-
- - {t("editInternalResourceDialogTcp")} - -
-
- ( - -
- - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t("editInternalResourceDialogUdp")} - -
-
- ( - -
- - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t("editInternalResourceDialogIcmp")} - -
-
- ( - -
- - + + {t("editInternalResourceDialogTcp")} + +
+
+ ( + +
+ + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t("editInternalResourceDialogUdp")} + +
+
+ ( + +
+ + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t( + "editInternalResourceDialogIcmp" + )} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t("blocked") + : t("allowed")} + +
+ +
+ )} + /> +
-
+ )}
From a730f4da1d77e4b5db92bfabcf3cb045bcc89d47 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 17:54:08 -0400 Subject: [PATCH 029/176] dont show wildcard in domain picker --- messages/en-US.json | 1 + src/components/DomainPicker.tsx | 29 ++++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e5b073f6c..7fd13b583 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2120,6 +2120,7 @@ "domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", + "domainPickerManual": "Manual", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 44446763b..afb273b5c 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -509,9 +509,11 @@ export default function DomainPicker({ {selectedBaseDomain.domain} - {selectedBaseDomain.verified && ( - - )} + {selectedBaseDomain.verified && + selectedBaseDomain.domainType !== + "wildcard" && ( + + )}
) : ( t("domainPickerSelectBaseDomain") @@ -574,14 +576,23 @@ export default function DomainPicker({ } - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified + {orgDomain.type === + "wildcard" ? t( - "domainPickerVerified" + "domainPickerManual" ) - : t( - "domainPickerUnverified" + : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + )}
From c027c8958b57475c10d92db87fe87a58bf968694 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:54:07 -0400 Subject: [PATCH 030/176] Add scheme --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/ip.ts | 21 ++++++++++---- .../siteResource/createSiteResource.ts | 29 ++----------------- .../siteResource/updateSiteResource.ts | 5 +++- 5 files changed, 25 insertions(+), 34 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 96c5b8ae6..8966bc0e4 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -231,7 +231,7 @@ export const siteResources = pgTable("siteResources", { niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" - protocol: varchar("protocol"), // only for port mode + scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 7dbbaf007..6205d0179 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -259,7 +259,7 @@ export const siteResources = sqliteTable("siteResources", { niceId: text("niceId").notNull(), name: text("name").notNull(), mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" - protocol: text("protocol"), // only for port mode + scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: text("destination").notNull(), // ip, cidr, hostname diff --git a/server/lib/ip.ts b/server/lib/ip.ts index c7d02dc1b..96ea04873 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -660,9 +660,14 @@ export function generateSubnetProxyTargetV2( destination = `${destination}/32`; } - if (!siteResource.alias || !siteResource.aliasAddress) { + if ( + !siteResource.alias || + !siteResource.aliasAddress || + !siteResource.destinationPort || + !siteResource.scheme + ) { logger.debug( - `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address, skipping alias target generation.` + `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address or destinationPort, skipping alias target generation.` ); return; } @@ -675,9 +680,15 @@ export function generateSubnetProxyTargetV2( disableIcmp, resourceId: siteResource.siteResourceId, protocol: siteResource.mode, // will be either http or https, - httpTargets: [], - tlsCert: "", - tlsKey: "" + httpTargets: [ + { + destAddr: siteResource.destination, + destPort: siteResource.destinationPort, + scheme: siteResource.scheme + } + ], + // tlsCert: "", + // tlsKey: "" }; } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e1b97bdca..437643be4 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -38,7 +38,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "http", "https"]), siteId: z.int(), - // protocol: z.enum(["tcp", "udp"]).optional(), + scheme: z.enum(["http", "https"]).optional(), // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), destination: z.string().min(1), @@ -167,7 +167,7 @@ export async function createSiteResource( name, siteId, mode, - // protocol, + scheme, // proxyPort, destinationPort, destination, @@ -232,30 +232,6 @@ export async function createSiteResource( ); } - // // check if resource with same protocol and proxy port already exists (only for port mode) - // if (mode === "port" && protocol && proxyPort) { - // const [existingResource] = await db - // .select() - // .from(siteResources) - // .where( - // and( - // eq(siteResources.siteId, siteId), - // eq(siteResources.orgId, orgId), - // eq(siteResources.protocol, protocol), - // eq(siteResources.proxyPort, proxyPort) - // ) - // ) - // .limit(1); - // if (existingResource && existingResource.siteResourceId) { - // return next( - // createHttpError( - // HttpCode.CONFLICT, - // "A resource with the same protocol and proxy port already exists" - // ) - // ); - // } - // } - // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db @@ -300,6 +276,7 @@ export async function createSiteResource( name, mode, destination, + scheme, destinationPort, enabled, alias, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index de4ad3398..22e57383c 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -52,7 +52,7 @@ const updateSiteResourceSchema = z .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr", "http", "https"]).optional(), - // protocol: z.enum(["tcp", "udp"]).nullish(), + scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), @@ -182,6 +182,7 @@ export async function updateSiteResource( siteId, // because it can change niceId, mode, + scheme, destination, destinationPort, alias, @@ -354,6 +355,7 @@ export async function updateSiteResource( siteId, niceId, mode, + scheme, destination, destinationPort, enabled, @@ -458,6 +460,7 @@ export async function updateSiteResource( name: name, siteId: siteId, mode: mode, + scheme, destination: destination, destinationPort: destinationPort, enabled: enabled, From a74378e1d39de58c76ad94249c52b3ea5e47d9ec Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 18:17:08 -0400 Subject: [PATCH 031/176] show domain and destination with port in table --- server/routers/resource/getUserResources.ts | 8 +- .../siteResource/listAllSiteResourcesByOrg.ts | 2 +- .../settings/resources/client/page.tsx | 2 +- src/components/ClientResourcesTable.tsx | 79 ++++++++++++++++--- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 9afd6b4f3..b6469fa77 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -144,7 +144,7 @@ export async function getUserResources( name: string; destination: string; mode: string; - protocol: string | null; + scheme: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; @@ -156,7 +156,7 @@ export async function getUserResources( name: siteResources.name, destination: siteResources.destination, mode: siteResources.mode, - protocol: siteResources.protocol, + scheme: siteResources.scheme, enabled: siteResources.enabled, alias: siteResources.alias, aliasAddress: siteResources.aliasAddress @@ -240,7 +240,7 @@ export async function getUserResources( name: siteResource.name, destination: siteResource.destination, mode: siteResource.mode, - protocol: siteResource.protocol, + protocol: siteResource.scheme, enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, @@ -289,7 +289,7 @@ export type GetUserResourcesResponse = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }>; }; }; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 36bc6bee0..7376fd6ec 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -88,7 +88,7 @@ function querySiteResourcesBase() { niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, - protocol: siteResources.protocol, + scheme: siteResources.scheme, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, destination: siteResources.destination, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f0f582f0f..95477949d 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -67,7 +67,7 @@ export default async function ClientResourcesPage( // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, destination: siteResource.destination, - // destinationPort: siteResource.destinationPort, + httpHttpsPort: siteResource.destinationPort ?? null, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, siteNiceId: siteResource.siteNiceId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b25409c44..20b968ea3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -52,7 +52,7 @@ export type InternalResourceRow = { siteId: number; siteNiceId: string; destination: string; - // destinationPort: number | null; + httpHttpsPort: number | null; alias: string | null; aliasAddress: string | null; niceId: string; @@ -63,6 +63,42 @@ export type InternalResourceRow = { authDaemonPort?: number | null; }; +function resolveHttpHttpsDisplayPort( + mode: "http" | "https", + httpHttpsPort: number | null +): number { + if (httpHttpsPort != null) { + return httpHttpsPort; + } + return mode === "https" ? 443 : 80; +} + +function formatDestinationDisplay(row: InternalResourceRow): string { + const { mode, destination, httpHttpsPort } = row; + if (mode !== "http" && mode !== "https") { + return destination; + } + const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); + const hostPart = + destination.includes(":") && !destination.startsWith("[") + ? `[${destination}]` + : destination; + return `${hostPart}:${port}`; +} + +function formatHttpHttpsAliasUrl(mode: "http" | "https", alias: string): string { + return `${mode}://${alias}`; +} + +function isSafeUrlForLink(href: string): boolean { + try { + void new URL(href); + return true; + } catch { + return false; + } +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -256,11 +292,12 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; + const display = formatDestinationDisplay(resourceRow); return ( ); } @@ -273,15 +310,33 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - return resourceRow.mode === "host" && resourceRow.alias ? ( - - ) : ( - - - ); + if (resourceRow.mode === "host" && resourceRow.alias) { + return ( + + ); + } + if ( + (resourceRow.mode === "http" || + resourceRow.mode === "https") && + resourceRow.alias + ) { + const url = formatHttpHttpsAliasUrl( + resourceRow.mode, + resourceRow.alias + ); + return ( + + ); + } + return -; } }, { From 584a8e7d1d84773c0a2f52b5c12e1e11bbfe4a68 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 20:52:37 -0400 Subject: [PATCH 032/176] Generate certs and add placeholder screen --- messages/en-US.json | 2 + .../private/lib/traefik/getTraefikConfig.ts | 157 +++++++++++++++++- src/app/private-maintenance-screen/page.tsx | 32 ++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/app/private-maintenance-screen/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7fd13b583..40b66fc6e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2708,6 +2708,8 @@ "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", + "privateMaintenanceScreenTitle": "Private Placeholder Screen", + "privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index adc3d965b..2487574e8 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -33,7 +33,7 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { orgs, resources, sites, Target, targets } from "@server/db"; +import { orgs, resources, sites, siteResources, Target, targets } from "@server/db"; import { sanitize, encodePath, @@ -267,6 +267,33 @@ export async function getTraefikConfig( }); }); + // Query siteResources in http/https mode that have aliases - needed for cert generation + const siteResourcesWithAliases = await db + .select({ + siteResourceId: siteResources.siteResourceId, + alias: siteResources.alias, + mode: siteResources.mode + }) + .from(siteResources) + .innerJoin(sites, eq(sites.siteId, siteResources.siteId)) + .where( + and( + eq(siteResources.enabled, true), + isNotNull(siteResources.alias), + inArray(siteResources.mode, ["http", "https"]), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` + ) + ), + inArray(sites.type, siteTypes) + ) + ); + let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for @@ -276,6 +303,12 @@ export async function getTraefikConfig( domains.add(resource.fullDomain); } } + // Include siteResource aliases so pangolin-dns also fetches certs for them + for (const sr of siteResourcesWithAliases) { + if (sr.alias) { + domains.add(sr.alias); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -867,6 +900,128 @@ export async function getTraefikConfig( } } + // Add Traefik routes for siteResource aliases in http/https mode so that + // Traefik generates TLS certificates for those domains even when no + // matching resource exists yet. + if (siteResourcesWithAliases.length > 0) { + // Build a set of domains already covered by normal resources + const existingFullDomains = new Set(); + for (const resource of resourcesMap.values()) { + if (resource.fullDomain) { + existingFullDomains.add(resource.fullDomain); + } + } + + for (const sr of siteResourcesWithAliases) { + if (!sr.alias) continue; + + // Skip if this alias is already handled by a resource router + if (existingFullDomains.has(sr.alias)) continue; + + const alias = sr.alias; + const srKey = `site-resource-cert-${sr.siteResourceId}`; + const siteResourceServiceName = `${srKey}-service`; + const siteResourceRouterName = `${srKey}-router`; + const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + if (!config_output.http.services) { + config_output.http.services = {}; + } + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // Service pointing at the internal maintenance/Next.js page + config_output.http.services[siteResourceServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${maintenanceHost}:${maintenancePort}` + } + ], + passHostHeader: true + } + }; + + // Middleware that rewrites any path to /maintenance-screen + config_output.http.middlewares[ + siteResourceRewriteMiddlewareName + ] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; + + // HTTP -> HTTPS redirect so the ACME challenge can be served + config_output.http.routers[ + `${siteResourceRouterName}-redirect` + ] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: siteResourceServiceName, + rule: `Host(\`${alias}\`)`, + priority: 100 + }; + + // Determine TLS / cert-resolver configuration + let tls: any = {}; + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + const domainParts = alias.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + // pangolin-dns: only add route if we already have a valid cert + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === alias + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for siteResource alias: ${alias}` + ); + continue; + } + } + + // HTTPS router — presence of this entry triggers cert generation + config_output.http.routers[siteResourceRouterName] = { + entryPoints: [ + config.getRawConfig().traefik.https_entrypoint + ], + service: siteResourceServiceName, + middlewares: [siteResourceRewriteMiddlewareName], + rule: `Host(\`${alias}\`)`, + priority: 100, + tls + }; + } + } + if (generateLoginPageRouters) { const exitNodeLoginPages = await db .select({ diff --git a/src/app/private-maintenance-screen/page.tsx b/src/app/private-maintenance-screen/page.tsx new file mode 100644 index 000000000..21417b6f4 --- /dev/null +++ b/src/app/private-maintenance-screen/page.tsx @@ -0,0 +1,32 @@ +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Private Placeholder" +}; + +export default async function MaintenanceScreen() { + const t = await getTranslations(); + + let title = t("privateMaintenanceScreenTitle"); + let message = t("privateMaintenanceScreenMessage"); + + return ( +
+ + + {title} + + {message} + +
+ ); +} From 510931e7d693292bdc57fd2833a0a5b250a386dd Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 21:02:20 -0400 Subject: [PATCH 033/176] Add ssl to schema --- server/db/pg/schema/schema.ts | 11 ++++++++--- server/db/sqlite/schema/schema.ts | 7 +++++-- server/routers/siteResource/createSiteResource.ts | 10 ++++++---- server/routers/siteResource/updateSiteResource.ts | 9 ++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 8966bc0e4..4885eec98 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -101,7 +103,9 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - status: varchar("status").$type<"pending" | "approved">().default("approved") + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }); export const resources = pgTable("resources", { @@ -230,7 +234,8 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" + ssl: boolean("ssl").notNull().default(false), + mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6205d0179..7b31460f6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -258,7 +260,8 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").$type<"host" | "cidr" | "http" | "https">().notNull(), // "host" | "cidr" | "http" | "https" + ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), + mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 437643be4..99db6810e 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -36,7 +36,8 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "https"]), + mode: z.enum(["host", "cidr", "http"]), + ssl: z.boolean().optional(), // only used for http mode siteId: z.int(), scheme: z.enum(["http", "https"]).optional(), // proxyPort: z.int().positive().optional(), @@ -64,8 +65,7 @@ const createSiteResourceSchema = z (data) => { if ( data.mode === "host" || - data.mode == "http" || - data.mode == "https" + data.mode == "http" ) { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) @@ -172,6 +172,7 @@ export async function createSiteResource( destinationPort, destination, enabled, + ssl, alias, userIds, roleIds, @@ -262,7 +263,7 @@ export async function createSiteResource( const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; - if (mode === "host" || mode === "http" || mode === "https") { + if (mode === "host" || mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -275,6 +276,7 @@ export async function createSiteResource( orgId, name, mode, + ssl, destination, scheme, destinationPort, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 22e57383c..bb0239478 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,7 +51,8 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http", "https"]).optional(), + mode: z.enum(["host", "cidr", "http"]).optional(), + ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(), @@ -78,8 +79,7 @@ const updateSiteResourceSchema = z (data) => { if ( (data.mode === "host" || - data.mode == "http" || - data.mode == "https") && + data.mode == "http") && data.destination ) { if (data.mode == "host") { @@ -186,6 +186,7 @@ export async function updateSiteResource( destination, destinationPort, alias, + ssl, enabled, userIds, roleIds, @@ -356,6 +357,7 @@ export async function updateSiteResource( niceId, mode, scheme, + ssl, destination, destinationPort, enabled, @@ -461,6 +463,7 @@ export async function updateSiteResource( siteId: siteId, mode: mode, scheme, + ssl, destination: destination, destinationPort: destinationPort, enabled: enabled, From 79751c208d8c6566a11a5ef2e2c16c3d66472bee Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:24:39 -0400 Subject: [PATCH 034/176] basic ui working --- messages/en-US.json | 9 + server/lib/blueprints/clientResources.ts | 27 +- server/lib/ip.ts | 9 +- .../private/lib/traefik/getTraefikConfig.ts | 7 +- .../siteResource/createSiteResource.ts | 15 + .../siteResource/listAllSiteResourcesByOrg.ts | 9 +- .../siteResource/updateSiteResource.ts | 17 + .../settings/resources/client/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 39 +-- .../CreateInternalResourceDialog.tsx | 55 ++- src/components/EditInternalResourceDialog.tsx | 9 +- src/components/InternalResourceForm.tsx | 322 ++++++++++++------ 12 files changed, 365 insertions(+), 167 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 40b66fc6e..ba22ea0d1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1819,6 +1819,9 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Scheme", + "editInternalResourceDialogEnableSsl": "Enable SSL", + "editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", @@ -1864,11 +1867,17 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Scheme", + "createInternalResourceDialogScheme": "Scheme", + "createInternalResourceDialogEnableSsl": "Enable SSL", + "createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", + "internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources", + "internalResourceHttpPortRequired": "Destination port is required for HTTP resources", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 4196c67ed..281f4f7dd 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -16,6 +16,20 @@ import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): { + mode: "host" | "cidr" | "http"; + ssl: boolean; + scheme: "http" | "https" | null; +} { + if (mode === "https") { + return { mode: "http", ssl: true, scheme: "https" }; + } + if (mode === "http") { + return { mode: "http", ssl: false, scheme: "http" }; + } + return { mode, ssl: false, scheme: null }; +} + export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; @@ -76,13 +90,16 @@ export async function updateClientResources( } if (existingResource) { + const mappedMode = siteResourceModeForDb(resourceData.mode); // Update existing resource const [updatedResource] = await trx .update(siteResources) .set({ name: resourceData.name || resourceNiceId, siteId: site.siteId, - mode: resourceData.mode, + mode: mappedMode.mode, + ssl: mappedMode.ssl, + scheme: mappedMode.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now @@ -208,9 +225,9 @@ export async function updateClientResources( oldSiteResource: existingResource }); } else { + const mappedMode = siteResourceModeForDb(resourceData.mode); let aliasAddress: string | null = null; - if (resourceData.mode == "host") { - // we can only have an alias on a host + if (mappedMode.mode === "host" || mappedMode.mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } @@ -222,7 +239,9 @@ export async function updateClientResources( siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, - mode: resourceData.mode, + mode: mappedMode.mode, + ssl: mappedMode.ssl, + scheme: mappedMode.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 96ea04873..b4be4285f 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -652,7 +652,7 @@ export function generateSubnetProxyTargetV2( disableIcmp, resourceId: siteResource.siteResourceId }; - } else if (siteResource.mode == "http" || siteResource.mode == "https") { + } else if (siteResource.mode == "http") { let destination = siteResource.destination; // check if this is a valid ip const ipSchema = z.union([z.ipv4(), z.ipv6()]); @@ -667,10 +667,11 @@ export function generateSubnetProxyTargetV2( !siteResource.scheme ) { logger.debug( - `Site resource ${siteResource.siteResourceId} is in HTTP/HTTPS mode but is missing alias or alias address or destinationPort, skipping alias target generation.` + `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.` ); return; } + const publicProtocol = siteResource.ssl ? "https" : "http"; // also push a match for the alias address target = { sourcePrefixes: [], @@ -679,14 +680,14 @@ export function generateSubnetProxyTargetV2( portRange, disableIcmp, resourceId: siteResource.siteResourceId, - protocol: siteResource.mode, // will be either http or https, + protocol: publicProtocol, httpTargets: [ { destAddr: siteResource.destination, destPort: siteResource.destinationPort, scheme: siteResource.scheme } - ], + ] // tlsCert: "", // tlsKey: "" }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 2487574e8..e82f0bdc7 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -267,7 +267,7 @@ export async function getTraefikConfig( }); }); - // Query siteResources in http/https mode that have aliases - needed for cert generation + // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge const siteResourcesWithAliases = await db .select({ siteResourceId: siteResources.siteResourceId, @@ -280,7 +280,8 @@ export async function getTraefikConfig( and( eq(siteResources.enabled, true), isNotNull(siteResources.alias), - inArray(siteResources.mode, ["http", "https"]), + eq(siteResources.mode, "http"), + eq(siteResources.ssl, true), or( eq(sites.exitNodeId, exitNodeId), and( @@ -900,7 +901,7 @@ export async function getTraefikConfig( } } - // Add Traefik routes for siteResource aliases in http/https mode so that + // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that // Traefik generates TLS certificates for those domains even when no // matching resource exists yet. if (siteResourcesWithAliases.length > 0) { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 99db6810e..6fbe50d59 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -111,6 +111,21 @@ const createSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.destinationPort !== undefined && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } ); export type CreateSiteResourceBody = z.infer; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 7376fd6ec..896dc77f5 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr", "http", "https"]) + .enum(["host", "cidr", "http"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr", "http", "https"], + enum: ["host", "cidr", "http"], description: "Filter site resources by mode" }), sort_by: z @@ -88,6 +88,7 @@ function querySiteResourcesBase() { niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, + ssl: siteResources.ssl, scheme: siteResources.scheme, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, @@ -193,7 +194,9 @@ export async function listAllSiteResourcesByOrg( const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( - querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") + querySiteResourcesBase() + .where(and(...conditions)) + .as("filtered_site_resources") ); const [siteResourcesList, totalCount] = await Promise.all([ diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index bb0239478..81283e353 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -125,6 +125,23 @@ const updateSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } ); export type UpdateSiteResourceBody = z.infer; diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 95477949d..46dfeb9cc 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -56,13 +56,25 @@ export default async function ClientResourcesPage( const internalResourceRows: InternalResourceRow[] = siteResources.map( (siteResource) => { + const rawMode = siteResource.mode as string | undefined; + const normalizedMode = + rawMode === "https" + ? ("http" as const) + : rawMode === "host" || rawMode === "cidr" || rawMode === "http" + ? rawMode + : ("host" as const); return { id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, - mode: siteResource.mode || ("port" as any), + mode: normalizedMode, + scheme: + siteResource.scheme ?? + (rawMode === "https" ? ("https" as const) : null), + ssl: + siteResource.ssl === true || rawMode === "https", // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 20b968ea3..6adce8fd9 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -46,7 +46,9 @@ export type InternalResourceRow = { siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr" | "http" | "https"; + mode: "host" | "cidr" | "http"; + scheme: "http" | "https" | null; + ssl: boolean; // protocol: string | null; // proxyPort: number | null; siteId: number; @@ -64,30 +66,27 @@ export type InternalResourceRow = { }; function resolveHttpHttpsDisplayPort( - mode: "http" | "https", + mode: "http", httpHttpsPort: number | null ): number { if (httpHttpsPort != null) { return httpHttpsPort; } - return mode === "https" ? 443 : 80; + return 80; } function formatDestinationDisplay(row: InternalResourceRow): string { - const { mode, destination, httpHttpsPort } = row; - if (mode !== "http" && mode !== "https") { + const { mode, destination, httpHttpsPort, scheme } = row; + if (mode !== "http") { return destination; } const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); + const downstreamScheme = scheme ?? "http"; const hostPart = destination.includes(":") && !destination.startsWith("[") ? `[${destination}]` : destination; - return `${hostPart}:${port}`; -} - -function formatHttpHttpsAliasUrl(mode: "http" | "https", alias: string): string { - return `${mode}://${alias}`; + return `${downstreamScheme}://${hostPart}:${port}`; } function isSafeUrlForLink(href: string): boolean { @@ -255,10 +254,6 @@ export default function ClientResourcesTable({ { value: "http", label: t("editInternalResourceDialogModeHttp") - }, - { - value: "https", - label: t("editInternalResourceDialogModeHttps") } ]} selectedValue={searchParams.get("mode") ?? undefined} @@ -272,14 +267,13 @@ export default function ClientResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; const modeLabels: Record< - "host" | "cidr" | "port" | "http" | "https", + "host" | "cidr" | "port" | "http", string > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), port: t("editInternalResourceDialogModePort"), - http: t("editInternalResourceDialogModeHttp"), - https: t("editInternalResourceDialogModeHttps") + http: t("editInternalResourceDialogModeHttp") }; return {modeLabels[resourceRow.mode]}; } @@ -319,15 +313,8 @@ export default function ClientResourcesTable({ /> ); } - if ( - (resourceRow.mode === "http" || - resourceRow.mode === "https") && - resourceRow.alias - ) { - const url = formatHttpHttpsAliasUrl( - resourceRow.mode, - resourceRow.alias - ); + if (resourceRow.mode === "http" && resourceRow.alias) { + const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`; return ( parseInt(r.id)) : [], + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && + data.authDaemonPort != null && { + authDaemonPort: data.authDaemonPort + }), + roleIds: data.roles + ? data.roles.map((r) => parseInt(r.id)) + : [], userIds: data.users ? data.users.map((u) => u.id) : [], - clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] + clientIds: data.clients + ? data.clients.map((c) => parseInt(c.id)) + : [] } ); toast({ title: t("createInternalResourceDialogSuccess"), - description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ), variant: "default" }); setOpen(false); @@ -98,7 +117,9 @@ export default function CreateInternalResourceDialog({ title: t("createInternalResourceDialogError"), description: formatAxiosError( error, - t("createInternalResourceDialogFailedToCreateInternalResource") + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) ), variant: "destructive" }); @@ -111,9 +132,13 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResource")} + - {t("createInternalResourceDialogCreateClientResourceDescription")} + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} @@ -128,7 +153,11 @@ export default function CreateInternalResourceDialog({ - diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 2a4cd35fc..5f20dd458 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -55,9 +55,7 @@ export default function EditInternalResourceDialog({ try { let data = { ...values }; if ( - (data.mode === "host" || - data.mode === "http" || - data.mode === "https") && + (data.mode === "host" || data.mode === "http") && isHostname(data.destination) ) { const currentAlias = data.alias?.trim() || ""; @@ -76,6 +74,11 @@ export default function EditInternalResourceDialog({ mode: data.mode, niceId: data.niceId, destination: data.destination, + ...(data.mode === "http" && { + scheme: data.scheme, + ssl: data.ssl ?? false, + destinationPort: data.httpHttpsPort ?? null + }), alias: data.alias && typeof data.alias === "string" && diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index fb31d27b8..9e1390f09 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -46,6 +46,7 @@ import { SitesSelector, type Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; +import { SwitchInput } from "@app/components/SwitchInput"; // --- Helpers (shared) --- @@ -121,7 +122,7 @@ export const cleanForFQDN = (name: string): string => type Site = ListSitesResponse["sites"][0]; -export type InternalResourceMode = "host" | "cidr" | "http" | "https"; +export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { id: number; @@ -139,6 +140,8 @@ export type InternalResourceData = { authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; + scheme?: "http" | "https" | null; + ssl?: boolean; httpConfigSubdomain?: string | null; httpConfigDomainId?: string | null; httpConfigFullDomain?: string | null; @@ -159,6 +162,8 @@ export type InternalResourceFormValues = { authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; httpHttpsPort?: number | null; + scheme?: "http" | "https"; + ssl?: boolean; httpConfigSubdomain?: string | null; httpConfigDomainId?: string | null; httpConfigFullDomain?: string | null; @@ -226,10 +231,18 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogModeHttp" : "editInternalResourceDialogModeHttp"; - const modeHttpsKey = + const schemeLabelKey = variant === "create" - ? "createInternalResourceDialogModeHttps" - : "editInternalResourceDialogModeHttps"; + ? "createInternalResourceDialogScheme" + : "editInternalResourceDialogScheme"; + const enableSslLabelKey = + variant === "create" + ? "createInternalResourceDialogEnableSsl" + : "editInternalResourceDialogEnableSsl"; + const enableSslDescriptionKey = + variant === "create" + ? "createInternalResourceDialogEnableSslDescription" + : "editInternalResourceDialogEnableSslDescription"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" @@ -255,48 +268,78 @@ export function InternalResourceForm({ ? "createInternalResourceDialogHttpConfigurationDescription" : "editInternalResourceDialogHttpConfigurationDescription"; - const formSchema = z.object({ - name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteId: z - .number() - .int() - .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), - mode: z.enum(["host", "cidr", "http", "https"]), - destination: z - .string() - .min( - 1, - destinationRequiredKey - ? { message: t(destinationRequiredKey) } - : undefined - ), - alias: z.string().nullish(), - httpHttpsPort: z.number().int().min(1).max(65535).optional().nullable(), - httpConfigSubdomain: z.string().nullish(), - httpConfigDomainId: z.string().nullish(), - httpConfigFullDomain: z.string().nullish(), - niceId: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/) - .optional(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), - authDaemonPort: z.number().int().positive().optional().nullable(), - roles: z.array(tagSchema).optional(), - users: z.array(tagSchema).optional(), - clients: z - .array( - z.object({ - clientId: z.number(), - name: z.string() - }) - ) - .optional() - }); + const formSchema = z + .object({ + name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), + siteId: z + .number() + .int() + .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), + mode: z.enum(["host", "cidr", "http"]), + destination: z + .string() + .min( + 1, + destinationRequiredKey + ? { message: t(destinationRequiredKey) } + : undefined + ), + alias: z.string().nullish(), + httpHttpsPort: z + .number() + .int() + .min(1) + .max(65535) + .optional() + .nullable(), + scheme: z.enum(["http", "https"]).optional(), + ssl: z.boolean().optional(), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish(), + niceId: z + .string() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/) + .optional(), + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), + disableIcmp: z.boolean().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), + authDaemonPort: z.number().int().positive().optional().nullable(), + roles: z.array(tagSchema).optional(), + users: z.array(tagSchema).optional(), + clients: z + .array( + z.object({ + clientId: z.number(), + name: z.string() + }) + ) + .optional() + }) + .superRefine((data, ctx) => { + if (data.mode !== "http") return; + if (!data.scheme) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceDownstreamSchemeRequired"), + path: ["scheme"] + }); + } + if ( + data.httpHttpsPort == null || + !Number.isFinite(data.httpHttpsPort) || + data.httpHttpsPort < 1 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceHttpPortRequired"), + path: ["httpHttpsPort"] + }); + } + }); type FormData = z.infer; @@ -430,6 +473,8 @@ export function InternalResourceForm({ authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null, @@ -445,6 +490,8 @@ export function InternalResourceForm({ destination: "", alias: null, httpHttpsPort: null, + scheme: "http", + ssl: false, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -471,7 +518,7 @@ export function InternalResourceForm({ const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); - const isHttpOrHttps = mode === "http" || mode === "https"; + const isHttpMode = mode === "http"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); @@ -496,6 +543,8 @@ export function InternalResourceForm({ destination: "", alias: null, httpHttpsPort: null, + scheme: "http", + ssl: false, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -527,6 +576,8 @@ export function InternalResourceForm({ destination: resource.destination ?? "", alias: resource.alias ?? null, httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, httpConfigSubdomain: resource.httpConfigSubdomain ?? null, httpConfigDomainId: resource.httpConfigDomainId ?? null, httpConfigFullDomain: resource.httpConfigFullDomain ?? null, @@ -681,6 +732,37 @@ export function InternalResourceForm({ )} /> + ( + + {t(modeLabelKey)} + + + + )} + />
+ {mode === "http" && ( +
+ ( + + + {t(schemeLabelKey)} + + + + + )} + /> +
+ )}
- ( - - - {t(modeLabelKey)} - - - - - )} - /> -
-
- + @@ -793,7 +868,7 @@ export function InternalResourceForm({ />
{mode === "host" && ( -
+
)} - {(mode === "http" || mode === "https") && ( -
+ {mode === "http" && ( +
- {isHttpOrHttps ? ( + {isHttpMode ? (
+ ( + + + + + + )} + />
From 73482c2a059e212a6c03c89937629b5a7bdc5087 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:38:04 -0400 Subject: [PATCH 035/176] disable ssh access tab on http mode --- messages/en-US.json | 2 +- src/components/InternalResourceForm.tsx | 186 ++++++++++++++---------- 2 files changed, 108 insertions(+), 80 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ba22ea0d1..e4bcbd623 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2673,7 +2673,7 @@ "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", - "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 9e1390f09..c6da0eb0e 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1,6 +1,10 @@ "use client"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { StrategySelect } from "@app/components/StrategySelect"; import { Tag, TagInput } from "@app/components/tags/tag-input"; @@ -687,82 +691,6 @@ export function InternalResourceForm({ )} /> )} - ( - - {t("site")} - - - - - - - - { - setSelectedSite(site); - field.onChange(site.siteId); - }} - /> - - - - - )} - /> - ( - - {t(modeLabelKey)} - - - - )} - />
+
+
+ ( + + {t("site")} + + + + + + + + { + setSelectedSite( + site + ); + field.onChange( + site.siteId + ); + }} + /> + + + + + )} + /> +
+
+ { + const modeOptions: OptionSelectOption[] = + [ + { + value: "host", + label: t(modeHostKey) + }, + { + value: "cidr", + label: t(modeCidrKey) + }, + { + value: "http", + label: t(modeHttpKey) + } + ]; + return ( + + + {t(modeLabelKey)} + + + options={modeOptions} + value={field.value} + onChange={ + field.onChange + } + cols={3} + /> + + + ); + }} + /> +
+
- {/* SSH Access tab */} - {!disableEnterpriseFeatures && mode !== "cidr" && ( + {/* SSH Access tab (host mode only) */} + {!disableEnterpriseFeatures && mode === "host" && (
From 8a47d69d0dac3cef54b03ccccb8de50cb85edefc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 9 Apr 2026 22:48:43 -0400 Subject: [PATCH 036/176] fix domain picker --- src/components/DomainPicker.tsx | 15 +++--- src/components/InternalResourceForm.tsx | 65 +++++++++++++------------ 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index afb273b5c..28bcd0029 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -163,15 +163,18 @@ export default function DomainPicker({ domainId: firstOrExistingDomain.domainId }; + const base = firstOrExistingDomain.baseDomain; + const sub = + firstOrExistingDomain.type !== "cname" + ? defaultSubdomain?.trim() || undefined + : undefined; + onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", - subdomain: - firstOrExistingDomain.type !== "cname" - ? defaultSubdomain || undefined - : undefined, - fullDomain: firstOrExistingDomain.baseDomain, - baseDomain: firstOrExistingDomain.baseDomain + subdomain: sub, + fullDomain: sub ? `${sub}.${base}` : base, + baseDomain: base }); } } diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index c6da0eb0e..f7254d6b7 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -495,7 +495,7 @@ export function InternalResourceForm({ alias: null, httpHttpsPort: null, scheme: "http", - ssl: false, + ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -548,7 +548,7 @@ export function InternalResourceForm({ alias: null, httpHttpsPort: null, scheme: "http", - ssl: false, + ssl: true, httpConfigSubdomain: null, httpConfigDomainId: null, httpConfigFullDomain: null, @@ -732,7 +732,9 @@ export function InternalResourceForm({ name="siteId" render={({ field }) => ( - {t("site")} + + {t("site")} + @@ -888,7 +890,10 @@ export function InternalResourceForm({ {t(destinationLabelKey)} - + @@ -947,16 +952,16 @@ export function InternalResourceForm({ const raw = e.target .value; - if (raw === "") { + if ( + raw === "" + ) { field.onChange( null ); return; } const n = - Number( - raw - ); + Number(raw); field.onChange( Number.isFinite( n @@ -986,29 +991,6 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)}
- ( - - - - - - )} - /> + ( + + + + + + )} + />
) : (
From a19f0acfb92da14351847210c928570619ac1845 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Apr 2026 17:21:54 -0400 Subject: [PATCH 037/176] Working --- server/lib/ip.ts | 34 ++++++++++++++++--- server/lib/rebuildClientAssociations.ts | 8 ++--- server/routers/newt/buildConfiguration.ts | 2 +- .../siteResource/updateSiteResource.ts | 4 +-- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 96ea04873..fce15692c 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -5,6 +5,7 @@ import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; +import { getValidCertificatesForDomains } from "#private/lib/certificates"; interface IPRange { start: bigint; @@ -594,14 +595,14 @@ export type HTTPTarget = { scheme: "http" | "https"; }; -export function generateSubnetProxyTargetV2( +export async function generateSubnetProxyTargetV2( siteResource: SiteResource, clients: { clientId: number; pubKey: string | null; subnet: string | null; }[] -): SubnetProxyTargetV2 | undefined { +): Promise { if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` @@ -672,6 +673,30 @@ export function generateSubnetProxyTargetV2( return; } // also push a match for the alias address + let tlsCert: string | undefined; + let tlsKey: string | undefined; + + if (siteResource.ssl && siteResource.alias) { + try { + const certs = await getValidCertificatesForDomains( + new Set([siteResource.alias]), + true + ); + if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { + tlsCert = certs[0].certFile; + tlsKey = certs[0].keyFile; + } else { + logger.warn( + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.alias}` + ); + } + } catch (err) { + logger.error( + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.alias}: ${err}` + ); + } + } + target = { sourcePrefixes: [], destPrefix: `${siteResource.aliasAddress}/32`, @@ -679,7 +704,7 @@ export function generateSubnetProxyTargetV2( portRange, disableIcmp, resourceId: siteResource.siteResourceId, - protocol: siteResource.mode, // will be either http or https, + protocol: siteResource.ssl ? "https" : "http", httpTargets: [ { destAddr: siteResource.destination, @@ -687,8 +712,7 @@ export function generateSubnetProxyTargetV2( scheme: siteResource.scheme } ], - // tlsCert: "", - // tlsKey: "" + ...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {}) }; } diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8459ce249..7c69ff71c 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -661,7 +661,7 @@ async function handleSubnetProxyTargetUpdates( ); if (addedClients.length > 0) { - const targetToAdd = generateSubnetProxyTargetV2( + const targetToAdd = await generateSubnetProxyTargetV2( siteResource, addedClients ); @@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates( ); if (removedClients.length > 0) { - const targetToRemove = generateSubnetProxyTargetV2( + const targetToRemove = await generateSubnetProxyTargetV2( siteResource, removedClients ); @@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const target = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1241,7 +1241,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const target = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 35d52816e..5e79804b7 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient( ) ); - const resourceTarget = generateSubnetProxyTargetV2( + const resourceTarget = await generateSubnetProxyTargetV2( resource, resourceClients ); diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index bb0239478..89949e9a8 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -634,11 +634,11 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if destination changed if (destinationChanged || portRangesChanged) { - const oldTarget = generateSubnetProxyTargetV2( + const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients ); - const newTarget = generateSubnetProxyTargetV2( + const newTarget = await generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); From fc4633db918bf93cc998833c8faad34868572008 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 17:19:18 -0700 Subject: [PATCH 038/176] Add domain component to the site resource --- server/db/pg/schema/schema.ts | 7 ++- server/db/sqlite/schema/schema.ts | 7 ++- .../siteResource/createSiteResource.ts | 56 +++++++++++++++---- .../siteResource/updateSiteResource.ts | 43 ++++++++++++-- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4885eec98..aac86c1b9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -249,7 +249,12 @@ export const siteResources = pgTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain") }); export const clientSiteResources = pgTable("clientSiteResources", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 7b31460f6..e58601dc3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -277,7 +277,12 @@ export const siteResources = sqliteTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: text("authDaemonMode") .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: text("subdomain"), + fullDomain: text("fullDomain"), }); export const clientSiteResources = sqliteTable("clientSiteResources", { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 6fbe50d59..d51bf54db 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -28,6 +28,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -58,15 +59,14 @@ const createSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org + subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org }) .strict() .refine( (data) => { - if ( - data.mode === "host" || - data.mode == "http" - ) { + if (data.mode === "host" || data.mode == "http") { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z @@ -196,7 +196,9 @@ export async function createSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; // Verify the site exists and belongs to the org @@ -248,15 +250,47 @@ export async function createSiteResource( ); } + if (domainId && alias) { + // throw an error because we can only have one or the other + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Alias and domain cannot both be set. Please choose one or the other." + ) + ); + } + + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + let finalAlias = alias ? alias.trim() : null; + if (domainId && subdomain) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + } + // make sure the alias is unique within the org if provided - if (alias) { + if (finalAlias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, alias.trim()) + eq(siteResources.alias, finalAlias.trim()) ) ) .limit(1); @@ -296,11 +330,13 @@ export async function createSiteResource( scheme, destinationPort, enabled, - alias, + alias: finalAlias, aliasAddress, tcpPortRangeString, udpPortRangeString, - disableIcmp + disableIcmp, + domainId, + subdomain: finalSubdomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 5b1fac861..b66792c75 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -14,6 +14,7 @@ import { userSiteResources } from "@server/db"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { generateAliasConfig, generateRemoteSubnets, @@ -72,7 +73,9 @@ const updateSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().nullish(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), + subdomain: z.string().optional() }) .strict() .refine( @@ -212,7 +215,9 @@ export async function updateSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; const [site] = await db @@ -302,15 +307,37 @@ export async function updateSiteResource( } } + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + let finalAlias = alias ? alias.trim() : null; + if (domainId && subdomain) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + org.orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + } + // make sure the alias is unique within the org if provided - if (alias) { + if (finalAlias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), - eq(siteResources.alias, alias.trim()), + eq(siteResources.alias, finalAlias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) @@ -378,10 +405,12 @@ export async function updateSiteResource( destination, destinationPort, enabled, - alias: alias && alias.trim() ? alias : null, + alias: finalAlias, tcpPortRangeString, udpPortRangeString, disableIcmp, + domainId, + subdomain: finalSubdomain, ...sshPamSet }) .where( @@ -484,10 +513,12 @@ export async function updateSiteResource( destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: alias && alias.trim() ? alias : null, + alias: finalAlias, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, + domainId, + subdomain: finalSubdomain, ...sshPamSet }) .where( From 5803da48935e01aea90c76999dc44c9afff87f93 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 21:09:12 -0700 Subject: [PATCH 039/176] Crud working --- server/lib/blueprints/clientResources.ts | 113 +++++++++++++++++- server/lib/blueprints/proxyResources.ts | 2 +- server/lib/blueprints/types.ts | 37 +++++- server/lib/ip.ts | 5 +- .../routers/olm/handleOlmRegisterMessage.ts | 1 - .../siteResource/createSiteResource.ts | 28 +++-- .../siteResource/listAllSiteResourcesByOrg.ts | 3 + .../siteResource/updateSiteResource.ts | 33 +++-- .../settings/resources/client/page.tsx | 5 +- src/components/ClientResourcesTable.tsx | 7 +- .../CreateInternalResourceDialog.tsx | 40 ++++--- src/components/EditInternalResourceDialog.tsx | 34 +++--- src/components/InternalResourceForm.tsx | 20 ++-- 13 files changed, 258 insertions(+), 70 deletions(-) diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 281f4f7dd..40c09dd10 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -1,6 +1,8 @@ import { clients, clientSiteResources, + domains, + orgDomains, roles, roleSiteResources, SiteResource, @@ -11,10 +13,83 @@ import { userSiteResources } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray, or } from "drizzle-orm"; +import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; + +async function getDomainForSiteResource( + siteResourceId: number | undefined, + fullDomain: string, + orgId: string, + trx: Transaction +): Promise<{ subdomain: string | null; domainId: string }> { + const [fullDomainExists] = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, fullDomain), + eq(siteResources.orgId, orgId), + siteResourceId + ? ne(siteResources.siteResourceId, siteResourceId) + : isNotNull(siteResources.siteResourceId) + ) + ) + .limit(1); + + if (fullDomainExists) { + throw new Error( + `Site resource already exists with domain: ${fullDomain} in org ${orgId}` + ); + } + + const possibleDomains = await trx + .select() + .from(domains) + .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) + .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) + .execute(); + + if (possibleDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const validDomains = possibleDomains.filter((domain) => { + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { + return ( + fullDomain === domain.domains.baseDomain || + fullDomain.endsWith(`.${domain.domains.baseDomain}`) + ); + } else if (domain.domains.type == "cname") { + return fullDomain === domain.domains.baseDomain; + } + }); + + if (validDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const domainSelection = validDomains[0].domains; + const baseDomain = domainSelection.baseDomain; + + let subdomain: string | null = null; + if (fullDomain !== baseDomain) { + subdomain = fullDomain.replace(`.${baseDomain}`, ""); + } + + await createCertificate(domainSelection.domainId, fullDomain, trx); + + return { + subdomain, + domainId: domainSelection.domainId + }; +} function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): { mode: "host" | "cidr" | "http"; @@ -91,6 +166,19 @@ export async function updateClientResources( if (existingResource) { const mappedMode = siteResourceModeForDb(resourceData.mode); + + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + existingResource.siteResourceId, + resourceData["full-domain"], + orgId, + trx + ); + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) @@ -107,7 +195,10 @@ export async function updateClientResources( alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .where( eq( @@ -118,7 +209,6 @@ export async function updateClientResources( .returning(); const siteResourceId = existingResource.siteResourceId; - const orgId = existingResource.orgId; await trx .delete(clientSiteResources) @@ -231,6 +321,18 @@ export async function updateClientResources( aliasAddress = await getNextAvailableAliasAddress(orgId); } + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && mappedMode.mode === "http") { + domainInfo = await getDomainForSiteResource( + undefined, + resourceData["full-domain"], + orgId, + trx + ); + } + // Create new resource const [newResource] = await trx .insert(siteResources) @@ -250,7 +352,10 @@ export async function updateClientResources( aliasAddress: aliasAddress, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + udpPortRangeString: resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .returning(); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index e16da2ea5..4d78e946d 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1100,7 +1100,7 @@ function checkIfTargetChanged( return false; } -async function getDomain( +export async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 4a8dc272f..7939e6e24 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -325,7 +325,7 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "https"]), + mode: z.enum(["host", "cidr", "http"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), @@ -335,6 +335,8 @@ export const ClientResourceSchema = z "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), + "full-domain": z.string().optional(), + ssl: z.boolean().optional(), alias: z .string() .regex( @@ -477,6 +479,39 @@ export const ConfigSchema = z }); } + // Enforce the full-domain uniqueness across client-resources in the same stack + const clientFullDomainMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + if (!clientFullDomainMap.has(fullDomain)) { + clientFullDomainMap.set(fullDomain, []); + } + clientFullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + const clientFullDomainDuplicates = Array.from( + clientFullDomainMap.entries() + ) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([fullDomain, resourceKeys]) => + `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + if (clientFullDomainDuplicates.length !== 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["client-resources"], + message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}` + }); + } + // Enforce proxy-port uniqueness within proxy-resources per protocol const protocolPortMap = new Map(); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 252abc1e1..6f04b8170 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -478,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources - .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") + .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .map((sr) => ({ - alias: sr.alias, + alias: sr.alias || sr.fullDomain, aliasAddress: sr.aliasAddress })); } @@ -672,7 +672,6 @@ export async function generateSubnetProxyTargetV2( ); return; } - const publicProtocol = siteResource.ssl ? "https" : "http"; // also push a match for the alias address let tlsCert: string | undefined; let tlsKey: string | undefined; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 01495de3b..a4a62973d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { OlmErrorCodes, sendOlmError } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d51bf54db..f871990fa 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -66,7 +66,7 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" || data.mode == "http") { + if (data.mode === "host") { if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z @@ -262,7 +262,6 @@ export async function createSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -279,18 +278,32 @@ export async function createSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), - eq(siteResources.alias, finalAlias.trim()) + eq(siteResources.alias, alias.trim()) ) ) .limit(1); @@ -330,13 +343,14 @@ export async function createSiteResource( scheme, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, - subdomain: finalSubdomain + subdomain: finalSubdomain, + fullDomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 896dc77f5..3495d9767 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -101,6 +101,9 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, + subdomain: siteResources.subdomain, + domainId: siteResources.domainId, + fullDomain: siteResources.fullDomain, siteName: sites.name, siteNiceId: sites.niceId, siteAddress: sites.address diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index b66792c75..ef72ebd84 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -81,11 +81,9 @@ const updateSiteResourceSchema = z .refine( (data) => { if ( - (data.mode === "host" || - data.mode == "http") && + data.mode === "host" && data.destination ) { - if (data.mode == "host") { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere @@ -94,7 +92,6 @@ const updateSiteResourceSchema = z if (isValidIP) { return true; } - } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = @@ -309,7 +306,6 @@ export async function updateSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - let finalAlias = alias ? alias.trim() : null; if (domainId && subdomain) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -326,18 +322,32 @@ export async function updateSiteResource( fullDomain = domainResult.fullDomain; finalSubdomain = domainResult.subdomain; - finalAlias = fullDomain; // we will use the full domain as the alias for uniqueness checks and routing + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } } // make sure the alias is unique within the org if provided - if (finalAlias) { + if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), - eq(siteResources.alias, finalAlias.trim()), + eq(siteResources.alias, alias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) @@ -405,12 +415,13 @@ export async function updateSiteResource( destination, destinationPort, enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString, udpPortRangeString, disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( @@ -507,18 +518,20 @@ export async function updateSiteResource( .set({ name: name, siteId: siteId, + niceId: niceId, mode: mode, scheme, ssl, destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: finalAlias, + alias: alias ? alias.trim() : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, domainId, subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 46dfeb9cc..537124ad1 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -88,7 +88,10 @@ export default async function ClientResourcesPage( udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, - authDaemonPort: siteResource.authDaemonPort ?? null + authDaemonPort: siteResource.authDaemonPort ?? null, + subdomain: siteResource.subdomain ?? null, + domainId: siteResource.domainId ?? null, + fullDomain: siteResource.fullDomain ?? null }; } ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 6adce8fd9..c531d506d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -63,6 +63,9 @@ export type InternalResourceRow = { disableIcmp: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; function resolveHttpHttpsDisplayPort( @@ -313,8 +316,8 @@ export default function ClientResourcesTable({ /> ); } - if (resourceRow.mode === "http" && resourceRow.alias) { - const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`; + if (resourceRow.mode === "http") { + const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`; return ( parseInt(r.id)) : [], diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 5f20dd458..8e8795a0d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -77,22 +77,28 @@ export default function EditInternalResourceDialog({ ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, - destinationPort: data.httpHttpsPort ?? null + destinationPort: data.httpHttpsPort ?? null, + domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, + subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined }), - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode + ...(data.mode === "host" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && { + authDaemonPort: data.authDaemonPort || null + }) }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null + ...((data.mode === "host" || data.mode === "cidr") && { + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index f7254d6b7..d669c3b15 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -146,9 +146,9 @@ export type InternalResourceData = { httpHttpsPort?: number | null; scheme?: "http" | "https" | null; ssl?: boolean; - httpConfigSubdomain?: string | null; - httpConfigDomainId?: string | null; - httpConfigFullDomain?: string | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); @@ -479,9 +479,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -582,9 +582,9 @@ export function InternalResourceForm({ httpHttpsPort: resource.httpHttpsPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.httpConfigSubdomain ?? null, - httpConfigDomainId: resource.httpConfigDomainId ?? null, - httpConfigFullDomain: resource.httpConfigFullDomain ?? null, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -1023,7 +1023,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", null ); - form.setValue("alias", null); return; } form.setValue( @@ -1038,7 +1037,6 @@ export function InternalResourceForm({ "httpConfigFullDomain", res.fullDomain ); - form.setValue("alias", res.fullDomain); }} /> Date: Sat, 11 Apr 2026 21:56:39 -0700 Subject: [PATCH 040/176] Add logging --- .../routers/newt/handleRequestLogMessage.ts | 166 ++++++++++++++++++ server/private/routers/newt/index.ts | 1 + server/private/routers/ws/messageHandlers.ts | 3 +- .../routers/newt/handleRequestLogMessage.ts | 9 + server/routers/newt/index.ts | 1 + 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/newt/handleRequestLogMessage.ts create mode 100644 server/routers/newt/handleRequestLogMessage.ts diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..c11c98950 --- /dev/null +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,166 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { sites, Newt, orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +const zlibInflate = promisify(inflate); + +interface HTTPRequestLogData { + requestId: string; + resourceId: number; // siteResourceId + timestamp: string; // ISO 8601 + method: string; + scheme: string; // "http" or "https" + host: string; + path: string; + rawQuery?: string; + userAgent?: string; + sourceAddr: string; // ip:port + tls: boolean; +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressRequestLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed request log data is not an array"); + } + + return parsed; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Request log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Request log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Request log message missing compressed data"); + return; + } + + // Look up the org for this site and check retention settings + const [site] = await db + .select({ + orgId: sites.orgId, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Request log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + if (site.settingsLogRetentionDaysRequest === 0) { + logger.debug( + `Request log retention is disabled for org ${orgId}, skipping` + ); + return; + } + + let entries: HTTPRequestLogData[]; + try { + entries = await decompressRequestLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress request log data:", error); + return; + } + + if (entries.length === 0) { + return; + } + + logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + + for (const entry of entries) { + if ( + !entry.requestId || + !entry.resourceId || + !entry.method || + !entry.scheme || + !entry.host || + !entry.path || + !entry.sourceAddr + ) { + logger.debug( + `Skipping request log entry with missing required fields: ${JSON.stringify(entry)}` + ); + continue; + } + + const originalRequestURL = + entry.scheme + + "://" + + entry.host + + entry.path + + (entry.rawQuery ? "?" + entry.rawQuery : ""); + + await logRequestAudit( + { + action: true, + reason: 100, + resourceId: entry.resourceId, + orgId + }, + { + path: entry.path, + originalRequestURL, + scheme: entry.scheme, + host: entry.host, + method: entry.method, + tls: entry.tls, + requestIp: entry.sourceAddr + } + ); + } + + logger.debug( + `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` + ); +}; \ No newline at end of file diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts index 256d19cb7..14ba88bea 100644 --- a/server/private/routers/newt/index.ts +++ b/server/private/routers/newt/index.ts @@ -12,3 +12,4 @@ */ export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index 5021cb966..abef4a66b 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -18,12 +18,13 @@ import { } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; -import { handleConnectionLogMessage } from "#private/routers/newt"; +import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, + "newt/request-log": handleRequestLogMessage, }; if (build != "saas") { diff --git a/server/routers/newt/handleRequestLogMessage.ts b/server/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..190020ad1 --- /dev/null +++ b/server/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,9 @@ +import { MessageHandler } from "@server/routers/ws"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + return; +}; \ No newline at end of file diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 33b5caf7c..fa228cd93 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -9,4 +9,5 @@ export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; export * from "./registerNewt"; From 0cf385b718becb5e95b167d0f0047ad8cb29fc31 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 12:15:29 -0700 Subject: [PATCH 041/176] CRUD and newt mode http mostly working --- messages/en-US.json | 1 + server/lib/ip.ts | 12 +++--- .../routers/newt/handleRequestLogMessage.ts | 4 +- server/routers/badger/logRequestAudit.ts | 1 + server/routers/newt/handleGetConfigMessage.ts | 2 +- .../siteResource/createSiteResource.ts | 2 +- .../siteResource/updateSiteResource.ts | 40 +++++++++++-------- .../[orgId]/settings/logs/request/page.tsx | 3 ++ 8 files changed, 38 insertions(+), 27 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e4bcbd623..3a86af49b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2436,6 +2436,7 @@ "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Connected Client", "resourceBlocked": "Resource Blocked", "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 6f04b8170..13d35834b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -662,10 +662,10 @@ export async function generateSubnetProxyTargetV2( } if ( - !siteResource.alias || !siteResource.aliasAddress || !siteResource.destinationPort || - !siteResource.scheme + !siteResource.scheme || + !siteResource.fullDomain ) { logger.debug( `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.` @@ -676,10 +676,10 @@ export async function generateSubnetProxyTargetV2( let tlsCert: string | undefined; let tlsKey: string | undefined; - if (siteResource.ssl && siteResource.alias) { + if (siteResource.ssl && siteResource.fullDomain) { try { const certs = await getValidCertificatesForDomains( - new Set([siteResource.alias]), + new Set([siteResource.fullDomain]), true ); if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { @@ -687,12 +687,12 @@ export async function generateSubnetProxyTargetV2( tlsKey = certs[0].keyFile; } else { logger.warn( - `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.alias}` + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}` ); } } catch (err) { logger.error( - `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.alias}: ${err}` + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}` ); } } diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts index c11c98950..6cbb18b72 100644 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -144,7 +144,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { await logRequestAudit( { action: true, - reason: 100, + reason: 108, resourceId: entry.resourceId, orgId }, @@ -163,4 +163,4 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { logger.debug( `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` ); -}; \ No newline at end of file +}; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 92d01332e..db4c17939 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -18,6 +18,7 @@ Reasons: 105 - Valid Password 106 - Valid email 107 - Valid SSO +108 - Connected Client 201 - Resource Not Found 202 - Resource Blocked diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 9c67f53ee..7d82e96af 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { logger.warn( - `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` + `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); return; } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index f871990fa..ec2eda527 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -262,7 +262,7 @@ export async function createSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - if (domainId && subdomain) { + if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index ef72ebd84..6e253d0e3 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -80,18 +80,15 @@ const updateSiteResourceSchema = z .strict() .refine( (data) => { - if ( - data.mode === "host" && - data.destination - ) { - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if (data.mode === "host" && data.destination) { + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; - } + if (isValidIP) { + return true; + } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = @@ -306,7 +303,7 @@ export async function updateSiteResource( let fullDomain: string | null = null; let finalSubdomain: string | null = null; - if (domainId && subdomain) { + if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, @@ -324,12 +321,16 @@ export async function updateSiteResource( finalSubdomain = domainResult.subdomain; // make sure the full domain is unique - const existingResource = await db + const [existingDomain] = await db .select() .from(siteResources) .where(eq(siteResources.fullDomain, fullDomain)); - if (existingResource.length > 0) { + if ( + existingDomain && + existingDomain.siteResourceId !== + existingSiteResource.siteResourceId + ) { return next( createHttpError( HttpCode.CONFLICT, @@ -666,9 +667,14 @@ export async function handleMessagingForUpdatedSiteResource( const destinationChanged = existingSiteResource && existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; const aliasChanged = existingSiteResource && - existingSiteResource.alias !== updatedSiteResource.alias; + (existingSiteResource.alias !== updatedSiteResource.alias || + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias const portRangesChanged = existingSiteResource && (existingSiteResource.tcpPortRangeString !== @@ -680,7 +686,7 @@ export async function handleMessagingForUpdatedSiteResource( // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - if (destinationChanged || aliasChanged || portRangesChanged) { + if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) { const [newt] = await trx .select() .from(newts) @@ -694,7 +700,7 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged) { + if (destinationChanged || portRangesChanged || destinationPortChanged) { const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 4a1fe3cd9..061995811 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -360,6 +360,7 @@ export default function GeneralPage() { // 105 - Valid Password // 106 - Valid email // 107 - Valid SSO + // 108 - Connected Client // 201 - Resource Not Found // 202 - Resource Blocked @@ -377,6 +378,7 @@ export default function GeneralPage() { 105: t("validPassword"), 106: t("validEmail"), 107: t("validSSO"), + 108: t("connectedClient"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), @@ -634,6 +636,7 @@ export default function GeneralPage() { { value: "105", label: t("validPassword") }, { value: "106", label: t("validEmail") }, { value: "107", label: t("validSSO") }, + { value: "108", label: t("connectedClient") }, { value: "201", label: t("resourceNotFound") }, { value: "202", label: t("resourceBlocked") }, { value: "203", label: t("droppedByRule") }, From 1564c4bee71f29b7998621727a4ca3dd3e9e26ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:17:45 -0700 Subject: [PATCH 042/176] add multi site selector for ha on private resources --- messages/en-US.json | 3 +- src/app/[orgId]/settings/logs/access/page.tsx | 6 +- .../[orgId]/settings/logs/connection/page.tsx | 16 +-- .../[orgId]/settings/logs/request/page.tsx | 6 +- .../settings/resources/client/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 128 ++++++++++++++++-- .../CreateInternalResourceDialog.tsx | 2 +- src/components/EditInternalResourceDialog.tsx | 10 +- src/components/InternalResourceForm.tsx | 123 ++++++++++++----- src/components/LogDataTable.tsx | 6 +- src/components/multi-site-selector.tsx | 117 ++++++++++++++++ src/components/ui/checkbox.tsx | 4 +- 12 files changed, 356 insertions(+), 79 deletions(-) create mode 100644 src/components/multi-site-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e4bcbd623..03cdc3ddb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1837,6 +1837,7 @@ "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -2673,7 +2674,7 @@ "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", - "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", + "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index a0f1b5386..826e11c17 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -471,11 +471,7 @@ export default function GeneralPage() { : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` } > - diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index e15708f8e..6eaedff5a 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -451,11 +451,7 @@ export default function ConnectionLogsPage() { - @@ -497,11 +493,7 @@ export default function ConnectionLogsPage() { - diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 537124ad1..4d3b48c6c 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -60,21 +60,29 @@ export default async function ClientResourcesPage( const normalizedMode = rawMode === "https" ? ("http" as const) - : rawMode === "host" || rawMode === "cidr" || rawMode === "http" + : rawMode === "host" || + rawMode === "cidr" || + rawMode === "http" ? rawMode : ("host" as const); return { id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, + sites: [ + { + siteId: siteResource.siteId, + siteName: siteResource.siteName, + siteNiceId: siteResource.siteNiceId + } + ], siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, mode: normalizedMode, scheme: siteResource.scheme ?? (rawMode === "https" ? ("https" as const) : null), - ssl: - siteResource.ssl === true || rawMode === "https", + ssl: siteResource.ssl === true || rawMode === "https", // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c531d506d..0f7122c7d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -38,11 +38,23 @@ import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; + +export type InternalResourceSiteRow = { + siteId: number; + siteName: string; + siteNiceId: string; +}; export type InternalResourceRow = { id: number; name: string; orgId: string; + sites: InternalResourceSiteRow[]; siteName: string; siteAddress: string | null; // mode: "host" | "cidr" | "port"; @@ -101,6 +113,102 @@ function isSafeUrlForLink(href: string): boolean { } } +const MAX_SITE_LINKS = 3; + +function ClientResourceSiteLinks({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + if (sites.length === 0) { + return -; + } + const visible = sites.slice(0, MAX_SITE_LINKS); + const overflow = sites.slice(MAX_SITE_LINKS); + + return ( +
+ {visible.map((site) => ( + + + + ))} + {overflow.length > 0 ? ( + + ) : null} +
+ ); +} + +function OverflowSitesPopover({ + orgId, + sites +}: { + orgId: string; + sites: InternalResourceSiteRow[]; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > +
    + {sites.map((site) => ( +
  • + + + +
  • + ))} +
+
+
+ ); +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -223,20 +331,18 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", - friendlyName: t("site"), - header: () => {t("site")}, + id: "sites", + accessorFn: (row) => + row.sites.map((s) => s.siteName).join(", ") || row.siteName, + friendlyName: t("sites"), + header: () => {t("sites")}, cell: ({ row }) => { const resourceRow = row.original; return ( - - - + ); } }, diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 177571dff..1ad7b3632 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -68,7 +68,7 @@ export default function CreateInternalResourceDialog({ `/org/${orgId}/site-resource`, { name: data.name, - siteId: data.siteId, + siteId: data.siteIds[0], mode: data.mode, destination: data.destination, enabled: true, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 8e8795a0d..e7bdfb795 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -70,7 +70,7 @@ export default function EditInternalResourceDialog({ await api.post(`/site-resource/${resource.id}`, { name: data.name, - siteId: data.siteId, + siteId: data.siteIds[0], mode: data.mode, niceId: data.niceId, destination: data.destination, @@ -78,8 +78,12 @@ export default function EditInternalResourceDialog({ scheme: data.scheme, ssl: data.ssl ?? false, destinationPort: data.httpHttpsPort ?? null, - domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, - subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined }), ...(data.mode === "host" && { alias: diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index d669c3b15..6bc807046 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -46,7 +46,11 @@ import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { SitesSelector, type Selectedsite } from "./site-selector"; +import { + MultiSitesSelector, + formatMultiSitesSelectorLabel +} from "./multi-site-selector"; +import type { Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; @@ -153,9 +157,32 @@ export type InternalResourceData = { const tagSchema = z.object({ id: z.string(), text: z.string() }); +function buildSelectedSitesForResource( + resource: InternalResourceData, + catalog: Site[] +): Selectedsite[] { + const fromCatalog = catalog.find((s) => s.siteId === resource.siteId); + if (fromCatalog) { + return [ + { + name: fromCatalog.name, + siteId: fromCatalog.siteId, + type: fromCatalog.type + } + ]; + } + return [ + { + name: resource.siteName, + siteId: resource.siteId, + type: "newt" + } + ]; +} + export type InternalResourceFormValues = { name: string; - siteId: number; + siteIds: number[]; mode: InternalResourceMode; destination: string; alias?: string | null; @@ -272,13 +299,14 @@ export function InternalResourceForm({ ? "createInternalResourceDialogHttpConfigurationDescription" : "editInternalResourceDialogHttpConfigurationDescription"; + const siteIdsSchema = siteRequiredKey + ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) + : z.array(z.number().int().positive()).min(1); + const formSchema = z .object({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteId: z - .number() - .int() - .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), + siteIds: siteIdsSchema, mode: z.enum(["host", "cidr", "http"]), destination: z .string() @@ -467,7 +495,7 @@ export function InternalResourceForm({ variant === "edit" && resource ? { name: resource.name, - siteId: resource.siteId, + siteIds: [resource.siteId], mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -489,7 +517,7 @@ export function InternalResourceForm({ } : { name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -509,8 +537,18 @@ export function InternalResourceForm({ clients: [] }; - const [selectedSite, setSelectedSite] = useState( - availableSites[0] + const [selectedSites, setSelectedSites] = useState(() => + variant === "edit" && resource + ? buildSelectedSitesForResource(resource, sites) + : availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] ); const form = useForm({ @@ -542,7 +580,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: availableSites[0] ? [availableSites[0].siteId] : [], mode: "host", destination: "", alias: null, @@ -561,12 +599,23 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + availableSites[0] + ? [ + { + name: availableSites[0].name, + siteId: availableSites[0].siteId, + type: availableSites[0].type + } + ] + : [] + ); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open]); + }, [variant, open, form, sites]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -575,7 +624,7 @@ export function InternalResourceForm({ if (resourceChanged) { form.reset({ name: resource.name, - siteId: resource.siteId, + siteIds: [resource.siteId], mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -594,6 +643,9 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites( + buildSelectedSitesForResource(resource, sites) + ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -615,7 +667,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form]); + }, [variant, resource, form, sites]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { @@ -651,8 +703,10 @@ export function InternalResourceForm({
{ + const siteIds = values.siteIds; onSubmit({ ...values, + siteIds, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name @@ -729,11 +783,11 @@ export function InternalResourceForm({
( - {t("site")} + {t("sites")} @@ -743,40 +797,41 @@ export function InternalResourceForm({ role="combobox" className={cn( "w-full justify-between", - !field.value && + selectedSites.length === + 0 && "text-muted-foreground" )} > - {field.value - ? availableSites.find( - (s) => - s.siteId === - field.value - )?.name - : t( - "selectSite" - )} + + {formatMultiSitesSelectorLabel( + selectedSites, + t + )} + - { - setSelectedSite( - site + setSelectedSites( + sites ); field.onChange( - site.siteId + sites.map( + (s) => + s.siteId + ) ); }} /> diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 3a53a859f..14e87ff75 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -405,7 +405,11 @@ export function LogDataTable({ onClick={() => !disabled && onExport() } - disabled={isExporting || disabled || isExportDisabled} + disabled={ + isExporting || + disabled || + isExportDisabled + } > {isExporting ? ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx new file mode 100644 index 000000000..407e3b3e1 --- /dev/null +++ b/src/components/multi-site-selector.tsx @@ -0,0 +1,117 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { Checkbox } from "./ui/checkbox"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; +import type { Selectedsite } from "./site-selector"; + +export type MultiSitesSelectorProps = { + orgId: string; + selectedSites: Selectedsite[]; + onSelectionChange: (sites: Selectedsite[]) => void; + filterTypes?: string[]; +}; + +export function formatMultiSitesSelectorLabel( + selectedSites: Selectedsite[], + t: (key: string, values?: { count: number }) => string +): string { + if (selectedSites.length === 0) { + return t("selectSites"); + } + if (selectedSites.length === 1) { + return selectedSites[0]!.name; + } + return t("multiSitesSelectorSitesCount", { + count: selectedSites.length + }); +} + +export function MultiSitesSelector({ + orgId, + selectedSites, + onSelectionChange, + filterTypes +}: MultiSitesSelectorProps) { + const t = useTranslations(); + const [siteSearchQuery, setSiteSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(siteSearchQuery, 150); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 10 + }) + ); + + const sitesShown = useMemo(() => { + const base = filterTypes + ? sites.filter((s) => filterTypes.includes(s.type)) + : [...sites]; + if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) { + const selectedNotInBase = selectedSites.filter( + (sel) => !base.some((s) => s.siteId === sel.siteId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, sites, selectedSites, filterTypes]); + + const selectedIds = useMemo( + () => new Set(selectedSites.map((s) => s.siteId)), + [selectedSites] + ); + + const toggleSite = (site: Selectedsite) => { + if (selectedIds.has(site.siteId)) { + onSelectionChange( + selectedSites.filter((s) => s.siteId !== site.siteId) + ); + } else { + onSelectionChange([...selectedSites, site]); + } + }; + + return ( + + setSiteSearchQuery(v)} + /> + + {t("siteNotFound")} + + {sitesShown.map((site) => ( + { + toggleSite(site); + }} + > + {}} + aria-hidden + tabIndex={-1} + /> + {site.name} + + ))} + + + + ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 261655bb0..5cffd8978 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -43,8 +43,8 @@ const Checkbox = React.forwardRef< className={cn(checkboxVariants({ variant }), className)} {...props} > - - + + )); From b5e239d1adb24e494e1892254457182c736a755f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 12:24:52 -0700 Subject: [PATCH 043/176] adjust button size --- src/components/ClientResourcesTable.tsx | 4 ++-- src/components/PendingSitesTable.tsx | 4 ++-- src/components/ShareLinksTable.tsx | 4 ++-- src/components/SitesTable.tsx | 4 ++-- src/components/UserDevicesTable.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 0f7122c7d..4822f358e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -143,7 +143,7 @@ function ClientResourceSiteLinks({ {site.siteName} - + ))} @@ -198,7 +198,7 @@ function OverflowSitesPopover({ {site.siteName} - + diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 12abcf7c4..f4156603e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -352,9 +352,9 @@ export default function PendingSitesTable({ - ); diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index efac77df3..333cee03f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -144,9 +144,9 @@ export default function ShareLinksTable({ - ); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index cc02e5d37..4f459ffc1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -362,9 +362,9 @@ export default function SitesTable({ - ); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 52f2d1384..58a5ba402 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -373,12 +373,12 @@ export default function UserDevicesTable({ - ) : ( From 0cbcc0c29c0f38b1f0a01c56e0d7ae3569e3f5d3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Apr 2026 14:58:55 -0700 Subject: [PATCH 044/176] remove extra sites query --- .../siteResource/listAllSiteResourcesByOrg.ts | 4 +- .../settings/resources/client/page.tsx | 3 +- src/components/ClientResourcesTable.tsx | 168 +++++++++--------- .../CreateInternalResourceDialog.tsx | 3 - src/components/EditInternalResourceDialog.tsx | 3 - src/components/InternalResourceForm.tsx | 49 +---- 6 files changed, 97 insertions(+), 133 deletions(-) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3495d9767..de9083c2c 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -76,6 +76,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteName: string; siteNiceId: string; siteAddress: string | null; + siteOnline: boolean; })[]; }>; @@ -106,7 +107,8 @@ function querySiteResourcesBase() { fullDomain: siteResources.fullDomain, siteName: sites.name, siteNiceId: sites.niceId, - siteAddress: sites.address + siteAddress: sites.address, + siteOnline: sites.online }) .from(siteResources) .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 4d3b48c6c..f63563cc9 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -73,7 +73,8 @@ export default async function ClientResourcesPage( { siteId: siteResource.siteId, siteName: siteResource.siteName, - siteNiceId: siteResource.siteNiceId + siteNiceId: siteResource.siteNiceId, + online: siteResource.siteOnline } ], siteName: siteResource.siteName, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 4822f358e..fc1a6a6f3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -20,7 +20,7 @@ import { ArrowDown01Icon, ArrowUp10Icon, ArrowUpDown, - ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -38,16 +38,13 @@ import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; export type InternalResourceSiteRow = { siteId: number; siteName: string; siteNiceId: string; + online: boolean; }; export type InternalResourceRow = { @@ -113,99 +110,106 @@ function isSafeUrlForLink(href: string): boolean { } } -const MAX_SITE_LINKS = 3; +type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; -function ClientResourceSiteLinks({ - orgId, - sites -}: { - orgId: string; - sites: InternalResourceSiteRow[]; -}) { - if (sites.length === 0) { - return -; +function aggregateSitesStatus( + resourceSites: InternalResourceSiteRow[] +): AggregateSitesStatus { + if (resourceSites.length === 0) { + return "allOffline"; } - const visible = sites.slice(0, MAX_SITE_LINKS); - const overflow = sites.slice(MAX_SITE_LINKS); - - return ( -
- {visible.map((site) => ( - - - - ))} - {overflow.length > 0 ? ( - - ) : null} -
- ); + const onlineCount = resourceSites.filter((rs) => rs.online).length; + if (onlineCount === resourceSites.length) return "allOnline"; + if (onlineCount > 0) return "partial"; + return "allOffline"; } -function OverflowSitesPopover({ +function aggregateStatusDotClass(status: AggregateSitesStatus): string { + switch (status) { + case "allOnline": + return "bg-green-500"; + case "partial": + return "bg-yellow-500"; + case "allOffline": + default: + return "bg-gray-500"; + } +} + +function ClientResourceSitesStatusCell({ orgId, - sites + resourceSites }: { orgId: string; - sites: InternalResourceSiteRow[]; + resourceSites: InternalResourceSiteRow[]; }) { - const [open, setOpen] = useState(false); + const t = useTranslations(); + + if (resourceSites.length === 0) { + return -; + } + + const aggregate = aggregateSitesStatus(resourceSites); + const countLabel = t("multiSitesSelectorSitesCount", { + count: resourceSites.length + }); return ( - - + + - - setOpen(true)} - onMouseLeave={() => setOpen(false)} - > -
    - {sites.map((site) => ( -
  • + + + {resourceSites.map((site) => { + const isOnline = site.online; + return ( + - +
+ + {isOnline ? t("online") : t("offline")} + - - ))} - - - + + ); + })} + + ); } @@ -243,8 +247,6 @@ export default function ClientResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); - const [isRefreshing, startTransition] = useTransition(); const refreshData = () => { @@ -339,9 +341,9 @@ export default function ClientResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; return ( - ); } @@ -599,7 +601,6 @@ export default function ClientResourcesTable({ setOpen={setIsEditDialogOpen} resource={editingResource} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { @@ -614,7 +615,6 @@ export default function ClientResourcesTable({ open={isCreateDialogOpen} setOpen={setIsCreateDialogOpen} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 1ad7b3632..c0483e35d 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -31,7 +31,6 @@ type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -39,7 +38,6 @@ export default function CreateInternalResourceDialog({ open, setOpen, orgId, - sites, onSuccess }: CreateInternalResourceDialogProps) { const t = useTranslations(); @@ -155,7 +153,6 @@ export default function CreateInternalResourceDialog({ void; resource: InternalResourceData; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -43,7 +42,6 @@ export default function EditInternalResourceDialog({ setOpen, resource, orgId, - sites, onSuccess }: EditInternalResourceDialogProps) { const t = useTranslations(); @@ -174,7 +172,6 @@ export default function EditInternalResourceDialog({ variant="edit" open={open} resource={resource} - sites={sites} orgId={orgId} siteResourceId={resource.id} formId="edit-internal-resource-form" diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 6bc807046..0d98fb30b 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -159,18 +159,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( resource: InternalResourceData, - catalog: Site[] ): Selectedsite[] { - const fromCatalog = catalog.find((s) => s.siteId === resource.siteId); - if (fromCatalog) { - return [ - { - name: fromCatalog.name, - siteId: fromCatalog.siteId, - type: fromCatalog.type - } - ]; - } return [ { name: resource.siteName, @@ -207,7 +196,6 @@ type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; - sites: Site[]; orgId: string; siteResourceId?: number; formId: string; @@ -218,7 +206,6 @@ export function InternalResourceForm({ variant, resource, open, - sites, orgId, siteResourceId, formId, @@ -375,8 +362,6 @@ export function InternalResourceForm({ type FormData = z.infer; - const availableSites = sites.filter((s) => s.type === "newt"); - const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); @@ -517,7 +502,7 @@ export function InternalResourceForm({ } : { name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -539,16 +524,8 @@ export function InternalResourceForm({ const [selectedSites, setSelectedSites] = useState(() => variant === "edit" && resource - ? buildSelectedSitesForResource(resource, sites) - : availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] + ? buildSelectedSitesForResource(resource) + : [] ); const form = useForm({ @@ -580,7 +557,7 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteIds: availableSites[0] ? [availableSites[0].siteId] : [], + siteIds: [], mode: "host", destination: "", alias: null, @@ -599,23 +576,13 @@ export function InternalResourceForm({ users: [], clients: [] }); - setSelectedSites( - availableSites[0] - ? [ - { - name: availableSites[0].name, - siteId: availableSites[0].siteId, - type: availableSites[0].type - } - ] - : [] - ); + setSelectedSites([]); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open, form, sites]); + }, [variant, open, form]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -644,7 +611,7 @@ export function InternalResourceForm({ clients: [] }); setSelectedSites( - buildSelectedSitesForResource(resource, sites) + buildSelectedSitesForResource(resource) ); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) @@ -667,7 +634,7 @@ export function InternalResourceForm({ previousResourceId.current = resource.id; } } - }, [variant, resource, form, sites]); + }, [variant, resource, form]); // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data useEffect(() => { From 789b991c569faa7e5513ef3c115fa22540e7c76f Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 15:08:17 -0700 Subject: [PATCH 045/176] Logging and http working --- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + .../newt/handleConnectionLogMessage.ts | 16 +- .../routers/newt/handleRequestLogMessage.ts | 80 +++++++++- .../routers/auditLogs/queryRequestAuditLog.ts | 149 +++++++++++++----- server/routers/auditLogs/types.ts | 1 + server/routers/badger/logRequestAudit.ts | 3 + .../[orgId]/settings/logs/request/page.tsx | 6 +- 8 files changed, 212 insertions(+), 45 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index aac86c1b9..308a69fc7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1004,6 +1004,7 @@ export const requestAuditLog = pgTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e58601dc3..fe192014b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1104,6 +1104,7 @@ export const requestAuditLog = sqliteTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), diff --git a/server/private/routers/newt/handleConnectionLogMessage.ts b/server/private/routers/newt/handleConnectionLogMessage.ts index e980f85c9..60a810ee6 100644 --- a/server/private/routers/newt/handleConnectionLogMessage.ts +++ b/server/private/routers/newt/handleConnectionLogMessage.ts @@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { return; } - // Look up the org for this site + // Look up the org for this site and check retention settings const [site] = await db - .select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) + .select({ + orgId: sites.orgId, + orgSubnet: orgs.subnet, + settingsLogRetentionDaysConnection: + orgs.settingsLogRetentionDaysConnection + }) .from(sites) .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) .where(eq(sites.siteId, newt.siteId)); @@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { const orgId = site.orgId; + if (site.settingsLogRetentionDaysConnection === 0) { + logger.debug( + `Connection log retention is disabled for org ${orgId}, skipping` + ); + return; + } + // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can // reconstruct the exact subnet string stored on each client record. const cidrSuffix = site.orgSubnet?.includes("/") diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts index 6cbb18b72..42f1baf2c 100644 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -13,12 +13,13 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { sites, Newt, orgs } from "@server/db"; -import { eq } from "drizzle-orm"; +import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { inflate } from "zlib"; import { promisify } from "util"; import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; +import { getCountryCodeForIp } from "@server/lib/geoip"; export async function flushRequestLogToDb(): Promise { return; @@ -81,6 +82,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { const [site] = await db .select({ orgId: sites.orgId, + orgSubnet: orgs.subnet, settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest }) @@ -118,6 +120,61 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + // Build a map from sourceIp → external endpoint string by joining clients + // with clientSitesAssociationsCache. The endpoint is the real-world IP:port + // of the client device and is used for GeoIP lookup. + const ipToEndpoint = new Map(); + + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + if (cidrSuffix) { + const uniqueSourceAddrs = new Set(); + for (const entry of entries) { + if (entry.sourceAddr) { + uniqueSourceAddrs.add(entry.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + }); + + const matchedClients = await db + .select({ + subnet: clients.subnet, + endpoint: clientSitesAssociationsCache.endpoint + }) + .from(clients) + .innerJoin( + clientSitesAssociationsCache, + and( + eq( + clientSitesAssociationsCache.clientId, + clients.clientId + ), + eq(clientSitesAssociationsCache.siteId, newt.siteId) + ) + ) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + if (c.endpoint) { + const ip = c.subnet.split("/")[0]; + ipToEndpoint.set(ip, c.endpoint); + } + } + } + } + for (const entry of entries) { if ( !entry.requestId || @@ -141,12 +198,27 @@ export const handleRequestLogMessage: MessageHandler = async (context) => { entry.path + (entry.rawQuery ? "?" + entry.rawQuery : ""); + // Resolve the client's external endpoint for GeoIP lookup. + // sourceAddr is the WireGuard IP (possibly ip:port), so strip the port. + const sourceIp = entry.sourceAddr.includes(":") + ? entry.sourceAddr.split(":")[0] + : entry.sourceAddr; + const endpoint = ipToEndpoint.get(sourceIp); + let location: string | undefined; + if (endpoint) { + const endpointIp = endpoint.includes(":") + ? endpoint.split(":")[0] + : endpoint; + location = await getCountryCodeForIp(endpointIp); + } + await logRequestAudit( { action: true, reason: 108, - resourceId: entry.resourceId, - orgId + siteResourceId: entry.resourceId, + orgId, + location }, { path: entry.path, diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 176a9e5d3..000ec9815 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,8 +1,8 @@ -import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; +import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -92,7 +92,10 @@ function getWhere(data: Q) { lt(requestAuditLog.timestamp, data.timeEnd), eq(requestAuditLog.orgId, data.orgId), data.resourceId - ? eq(requestAuditLog.resourceId, data.resourceId) + ? or( + eq(requestAuditLog.resourceId, data.resourceId), + eq(requestAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined, @@ -110,15 +113,16 @@ export function queryRequest(data: Q) { return primaryLogsDb .select({ id: requestAuditLog.id, - timestamp: requestAuditLog.timestamp, - orgId: requestAuditLog.orgId, - action: requestAuditLog.action, - reason: requestAuditLog.reason, - actorType: requestAuditLog.actorType, - actor: requestAuditLog.actor, - actorId: requestAuditLog.actorId, - resourceId: requestAuditLog.resourceId, - ip: requestAuditLog.ip, + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + siteResourceId: requestAuditLog.siteResourceId, + ip: requestAuditLog.ip, location: requestAuditLog.location, userAgent: requestAuditLog.userAgent, metadata: requestAuditLog.metadata, @@ -137,37 +141,73 @@ export function queryRequest(data: Q) { } async function enrichWithResourceDetails(logs: Awaited>) { - // If logs database is the same as main database, we can do a join - // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); - if (resourceIds.length === 0) { + const siteResourceIds = logs + .filter(log => log.resourceId == null && log.siteResourceId != null) + .map(log => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0 && siteResourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } - // Fetch resource details from main database - const resourceDetails = await primaryDb - .select({ - resourceId: resources.resourceId, - name: resources.name, - niceId: resources.niceId - }) - .from(resources) - .where(inArray(resources.resourceId, resourceIds)); + const resourceMap = new Map(); - // Create a map for quick lookup - const resourceMap = new Map( - resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) - ); + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); + } + } + + const siteResourceMap = new Map(); + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of siteResourceDetails) { + siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); + } + } // Enrich logs with resource details - return logs.map(log => ({ - ...log, - resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, - resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null - })); + return logs.map(log => { + if (log.resourceId != null) { + const details = resourceMap.get(log.resourceId); + return { + ...log, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } else if (log.siteResourceId != null) { + const details = siteResourceMap.get(log.siteResourceId); + return { + ...log, + resourceId: log.siteResourceId, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } + return { ...log, resourceName: null, resourceNiceId: null }; + }); } export function countRequestQuery(data: Q) { @@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes( uniqueLocations, uniqueHosts, uniquePaths, - uniqueResources + uniqueResources, + uniqueSiteResources ] = await Promise.all([ primaryLogsDb .selectDistinct({ actor: requestAuditLog.actor }) @@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes( }) .from(requestAuditLog) .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ + id: requestAuditLog.siteResourceId + }) + .from(requestAuditLog) + .where(and(baseConditions, isNull(requestAuditLog.resourceId))) .limit(DISTINCT_LIMIT + 1) ]); @@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes( .map(row => row.id) .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map(row => row.id) + .filter((id): id is number => id !== null); + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { @@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes( .from(resources) .where(inArray(resources.resourceId, resourceIds)); - resourcesWithNames = resourceDetails.map(r => ({ - id: r.resourceId, - name: r.name - })); + resourcesWithNames = [ + ...resourcesWithNames, + ...resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })) + ]; + } + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...siteResourceDetails.map(r => ({ + id: r.siteResourceId, + name: r.name + })) + ]; } return { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 4c278cba5..972eebfe3 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = { actor: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceNiceId: string | null; resourceName: string | null; ip: string | null; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index db4c17939..884fb7ae4 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -39,6 +39,7 @@ const auditLogBuffer: Array<{ metadata: any; action: boolean; resourceId?: number; + siteResourceId?: number; reason: number; location?: string; originalRequestURL: string; @@ -187,6 +188,7 @@ export async function logRequestAudit( action: boolean; reason: number; resourceId?: number; + siteResourceId?: number; orgId?: string; location?: string; user?: { username: string; userId: string }; @@ -263,6 +265,7 @@ export async function logRequestAudit( metadata: sanitizeString(metadata), action: data.action, resourceId: data.resourceId, + siteResourceId: data.siteResourceId, reason: data.reason, location: sanitizeString(data.location), originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 061995811..c57914c8a 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -512,7 +512,11 @@ export default function GeneralPage() { cell: ({ row }) => { return ( e.stopPropagation()} >
+ {isHttpMode && ( + + )} + {isHttpMode ? (
@@ -991,6 +1005,7 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)}
+
+
From 9b271950d243228168f2b7e4d81f14594337889e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 17:31:51 -0700 Subject: [PATCH 047/176] Push down certs when they are detected --- server/private/lib/acmeCertSync.ts | 169 ++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 052488f0f..cd3e5478f 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -13,12 +13,24 @@ import fs from "fs"; import crypto from "crypto"; -import { certificates, domains, db } from "@server/db"; +import { + certificates, + clients, + clientSiteResourcesAssociationsCache, + db, + domains, + newts, + SiteResource, + siteResources +} from "@server/db"; import { and, eq } from "drizzle-orm"; import { encrypt, decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import privateConfig from "#private/lib/config"; import config from "@server/lib/config"; +import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; +import { updateTargets } from "@server/routers/client/targets"; +import cache from "#private/lib/cache"; interface AcmeCert { domain: { main: string; sans?: string[] }; @@ -33,6 +45,138 @@ interface AcmeJson { }; } +async function pushCertUpdateToAffectedNewts( + domain: string, + domainId: string | null, + oldCertPem: string | null, + oldKeyPem: string | null +): Promise { + // Find all SSL-enabled HTTP site resources that use this cert's domain + let affectedResources: SiteResource[] = []; + + if (domainId) { + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.domainId, domainId), + eq(siteResources.ssl, true) + ) + ); + } else { + // Fallback: match by exact fullDomain when no domainId is available + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, domain), + eq(siteResources.ssl, true) + ) + ); + } + + if (affectedResources.length === 0) { + logger.debug( + `acmeCertSync: no affected site resources for cert domain "${domain}"` + ); + return; + } + + logger.info( + `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` + ); + + for (const resource of affectedResources) { + try { + // Get the newt for this site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, resource.siteId)) + .limit(1); + + if (!newt) { + logger.debug( + `acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}` + ); + continue; + } + + // Get all clients with access to this resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); + + if (resourceClients.length === 0) { + logger.debug( + `acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data + if (resource.fullDomain) { + await cache.del(`cert:${resource.fullDomain}`); + } + + // Generate the new target (will read the freshly updated cert from DB) + const newTarget = await generateSubnetProxyTargetV2( + resource, + resourceClients + ); + + if (!newTarget) { + logger.debug( + `acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Construct the old target — same routing shape but with the previous cert/key. + // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the + // semantics correct so the update message accurately reflects what changed. + const oldTarget: SubnetProxyTargetV2 = { + ...newTarget, + tlsCert: oldCertPem ?? undefined, + tlsKey: oldKeyPem ?? undefined + }; + + await updateTargets( + newt.newtId, + { oldTargets: [oldTarget], newTargets: [newTarget] }, + newt.version + ); + + logger.info( + `acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}` + ); + } catch (err) { + logger.error( + `acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}` + ); + } + } +} + async function findDomainId(certDomain: string): Promise { // Strip wildcard prefix before lookup (*.example.com -> example.com) const lookupDomain = certDomain.startsWith("*.") @@ -148,6 +292,9 @@ async function syncAcmeCerts( .where(eq(certificates.domain, domain)) .limit(1); + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + if (existing.length > 0 && existing[0].certFile) { try { const storedCertPem = decrypt( @@ -160,6 +307,21 @@ async function syncAcmeCerts( ); continue; } + // Cert has changed; capture old values so we can send a correct + // update message to the newt after the DB write. + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } } catch (err) { // Decryption failure means we should proceed with the update logger.debug( @@ -215,6 +377,8 @@ async function syncAcmeCerts( logger.info( `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); + + await pushCertUpdateToAffectedNewts(domain, domainId, oldCertPem, oldKeyPem); } else { await db.insert(certificates).values({ domain, @@ -231,6 +395,9 @@ async function syncAcmeCerts( logger.info( `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); + + // For a brand-new cert, push to any SSL resources that were waiting for it + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); } } } From aa41a63430fdbe03a9281d77cc092b7c9425318c Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 17:50:27 -0700 Subject: [PATCH 048/176] Dont run the acme in saas or when we control dns --- server/private/lib/acmeCertSync.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index cd3e5478f..aff3efaec 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -31,6 +31,7 @@ import config from "@server/lib/config"; import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; import { updateTargets } from "@server/routers/client/targets"; import cache from "#private/lib/cache"; +import { build } from "@server/build"; interface AcmeCert { domain: { main: string; sans?: string[] }; @@ -403,9 +404,20 @@ async function syncAcmeCerts( } export function initAcmeCertSync(): void { + if (build == "saas") { + logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`); + return; + } + const privateConfigData = privateConfig.getRawPrivateConfig(); if (!privateConfigData.flags?.enable_acme_cert_sync) { + logger.debug(`acmeCertSync: ACME cert sync is disabled by config flag, skipping`); + return; + } + + if (!privateConfigData.flags.use_pangolin_dns) { + logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be enabled, skipping`); return; } From 676eacc9cf690cce4eddc3501d1c0eed3741ff51 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 16:06:23 -0700 Subject: [PATCH 049/176] Invert logic for pangolin dns --- server/private/lib/acmeCertSync.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 9bedc6a3e..9e6856ae2 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -434,8 +434,8 @@ export function initAcmeCertSync(): void { return; } - if (!privateConfigData.flags.use_pangolin_dns) { - logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be enabled, skipping`); + if (privateConfigData.flags.use_pangolin_dns) { + logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`); return; } From 173a81ead8f8165b87cbf3c8cea05d880c989483 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 16:22:22 -0700 Subject: [PATCH 050/176] Fixing up the crud for multiple sites --- .../siteResource/createSiteResource.ts | 3 +- .../siteResource/listAllSiteResourcesByOrg.ts | 58 +++++++++++++++---- .../siteResource/updateSiteResource.ts | 16 +++-- src/components/ClientResourcesTable.tsx | 6 +- .../CreateInternalResourceDialog.tsx | 2 +- src/components/EditInternalResourceDialog.tsx | 5 +- src/components/InternalResourceForm.tsx | 20 +++---- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index da5355c9e..9a7d632fd 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -222,8 +222,7 @@ export async function createSiteResource( const sitesToAssign = await db .select() .from(sites) - .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))) - .limit(1); + .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))); if (sitesToAssign.length !== siteIds.length) { return next( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index aa1fe7043..8750e7516 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,4 +1,4 @@ -import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; +import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -81,6 +81,40 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ })[]; }>; +/** + * Returns an aggregation expression compatible with both SQLite and PostgreSQL. + * - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch + * - PostgreSQL: array_agg(col) → returns a native array + */ +function aggCol(column: any) { + if (DB_TYPE === "sqlite") { + return sql`json_group_array(${column})`; + } + return sql`array_agg(${column})`; +} + +/** + * For SQLite the aggregated columns come back as JSON strings; parse them into + * proper arrays. For PostgreSQL the driver already returns native arrays, so + * the row is returned unchanged. + */ +function transformSiteResourceRow(row: any) { + if (DB_TYPE !== "sqlite") { + return row; + } + return { + ...row, + siteNames: JSON.parse(row.siteNames) as string[], + siteNiceIds: JSON.parse(row.siteNiceIds) as string[], + siteIds: JSON.parse(row.siteIds) as number[], + siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[], + // SQLite stores booleans as 0/1 integers + siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map( + (v) => v === 1 + ) as boolean[] + }; +} + function querySiteResourcesBase() { return db .select({ @@ -107,19 +141,21 @@ function querySiteResourcesBase() { fullDomain: siteResources.fullDomain, networkId: siteResources.networkId, defaultNetworkId: siteResources.defaultNetworkId, - siteNames: sql`array_agg(${sites.name})`, - siteNiceIds: sql`array_agg(${sites.niceId})`, - siteIds: sql`array_agg(${sites.siteId})`, - siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`, - siteOnlines: sql`array_agg(${sites.online})` + siteNames: aggCol(sites.name), + siteNiceIds: aggCol(sites.niceId), + siteIds: aggCol(sites.siteId), + siteAddresses: aggCol<(string | null)[]>(sites.address), + siteOnlines: aggCol(sites.online) }) .from(siteResources) - .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } - registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", @@ -210,7 +246,7 @@ export async function listAllSiteResourcesByOrg( .as("filtered_site_resources") ); - const [siteResourcesList, totalCount] = await Promise.all([ + const [siteResourcesRaw, totalCount] = await Promise.all([ baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) @@ -224,6 +260,8 @@ export async function listAllSiteResourcesByOrg( countQuery ]); + const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow); + return response(res, { data: { siteResources: siteResourcesList, @@ -247,4 +285,4 @@ export async function listAllSiteResourcesByOrg( ) ); } -} +} \ No newline at end of file diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 24b9f45b2..40e0feef9 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -280,8 +280,7 @@ export async function updateSiteResource( inArray(sites.siteId, siteIds), eq(sites.orgId, existingSiteResource.orgId) ) - ) - .limit(1); + ); if (sitesToAssign.length !== siteIds.length) { return next( @@ -727,7 +726,12 @@ export async function handleMessagingForUpdatedSiteResource( // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) { + if ( + destinationChanged || + aliasChanged || + portRangesChanged || + destinationPortChanged + ) { for (const site of sites) { const [newt] = await trx .select() @@ -742,7 +746,11 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged || destinationPortChanged) { + if ( + destinationChanged || + portRangesChanged || + destinationPortChanged + ) { const oldTarget = await generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 4fd7f44fe..c32208321 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -653,11 +653,7 @@ export default function ClientResourcesTable({ { // Delay refresh to allow modal to close smoothly diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index b90cae8a6..b9c978b3f 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -67,7 +67,7 @@ export default function CreateInternalResourceDialog({ `/org/${orgId}/site-resource`, { name: data.name, - siteId: data.siteIds[0], + siteIds: data.siteIds, mode: data.mode, destination: data.destination, enabled: true, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 7d1c7e8aa..859981f7d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { resourceQueries } from "@app/lib/queries"; -import { ListSitesResponse } from "@server/routers/site"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; @@ -27,8 +26,6 @@ import { isHostname } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type EditInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; @@ -69,7 +66,7 @@ export default function EditInternalResourceDialog({ await api.post(`/site-resource/${resource.id}`, { name: data.name, - siteId: data.siteIds[0], + siteIds: data.siteIds, mode: data.mode, niceId: data.niceId, destination: data.destination, diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 11abd8919..13d24b6b0 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -136,9 +136,9 @@ export type InternalResourceData = { id: number; name: string; orgId: string; - siteName: string; + siteNames: string[]; mode: InternalResourceMode; - siteId: number; + siteIds: number[]; niceId: string; destination: string; alias?: string | null; @@ -160,13 +160,11 @@ const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( resource: InternalResourceData, ): Selectedsite[] { - return [ - { - name: resource.siteName, - siteId: resource.siteId, - type: "newt" - } - ]; + return resource.siteIds.map((siteId, idx) => ({ + name: resource.siteNames[idx] ?? "", + siteId, + type: "newt" as const + })); } export type InternalResourceFormValues = { @@ -483,7 +481,7 @@ export function InternalResourceForm({ variant === "edit" && resource ? { name: resource.name, - siteIds: [resource.siteId], + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -594,7 +592,7 @@ export function InternalResourceForm({ if (resourceChanged) { form.reset({ name: resource.name, - siteIds: [resource.siteId], + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, From 30fd48a14adb28fb7a55893a7e22b629cf968782 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:17:28 -0700 Subject: [PATCH 051/176] HA site crud working --- .../routers/siteResource/updateSiteResource.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 40e0feef9..c48066b4a 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -466,6 +466,23 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq( + siteNetworks.networkId, + updatedSiteResource.networkId! + ) + ); + + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + } + const [adminRole] = await trx .select() .from(roles) From 7a40084bf47f6f52a1d84ff2c2968e87e4b5dbce Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:21:34 -0700 Subject: [PATCH 052/176] Rename for better understanding --- ...andleGetConfigMessage.ts => handleNewtGetConfigMessage.ts} | 2 +- server/routers/newt/index.ts | 2 +- server/routers/ws/messageHandlers.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename server/routers/newt/{handleGetConfigMessage.ts => handleNewtGetConfigMessage.ts} (98%) diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts similarity index 98% rename from server/routers/newt/handleGetConfigMessage.ts rename to server/routers/newt/handleNewtGetConfigMessage.ts index 7d82e96af..e76819ed1 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; -export const handleGetConfigMessage: MessageHandler = async (context) => { +export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index fa228cd93..fe6998722 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -2,7 +2,7 @@ export * from "./createNewt"; export * from "./getNewtToken"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; -export * from "./handleGetConfigMessage"; +export * from "./handleNewtGetConfigMessage"; export * from "./handleSocketMessages"; export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 143e4d516..2dc09eedc 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -2,7 +2,7 @@ import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, - handleGetConfigMessage, + handleNewtGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, handleNewtPingRequestMessage, @@ -37,7 +37,7 @@ export const messageHandlers: Record = { "newt/disconnecting": handleNewtDisconnectingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, - "newt/wg/get-config": handleGetConfigMessage, + "newt/wg/get-config": handleNewtGetConfigMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, From 3996e14e70bbc8f774bd924af288c6ffe7a218b4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:56:51 -0700 Subject: [PATCH 053/176] Add comment --- server/routers/newt/handleNewtGetConfigMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index e76819ed1..787151a5a 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -113,7 +113,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { exitNode ); - const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format return { message: { From 1b9a395432c8d44519e3fe828b8ffbed4ce4ed5a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:56:55 -0700 Subject: [PATCH 054/176] Add logging for debugging --- server/lib/rebuildClientAssociations.ts | 80 ++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 04b16beb8..a570f0f8b 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -21,7 +21,6 @@ import { import { and, eq, inArray, ne } from "drizzle-orm"; import { - addPeer as newtAddPeer, deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { @@ -35,7 +34,6 @@ import { generateRemoteSubnets, generateSubnetProxyTargetV2, parseEndpoint, - formatEndpoint } from "@server/lib/ip"; import { addPeerData, @@ -61,6 +59,10 @@ export async function getClientSiteResourceAccess( .then((rows) => rows.map((row) => row.sites)) : []; + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` + ); + if (sitesList.length === 0) { logger.warn( `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` @@ -144,6 +146,10 @@ export async function getClientSiteResourceAccess( const mergedAllClients = Array.from(allClientsMap.values()); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})` + ); + return { sitesList, mergedAllClients, @@ -161,9 +167,17 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` + ); + const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]` + ); + /////////// process the client-siteResource associations /////////// // get all of the clients associated with other resources in the same network, @@ -223,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource( (row) => row.clientId ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` + ); + // Get full client details for existing resource clients (needed for sending delete messages) const existingResourceClients = existingClientSiteResourceIds.length > 0 @@ -242,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource( (clientId) => !existingClientSiteResourceIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]` + ); + const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( (clientId) => ({ clientId, @@ -250,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource( ); if (clientSiteResourcesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)` + ); await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert` + ); } const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( (clientId) => !mergedAllClientIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]` + ); + if (clientSiteResourcesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)` + ); await trx .delete(clientSiteResourcesAssociationsCache) .where( @@ -279,9 +318,17 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-site associations /////////// + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` + ); + for (const site of sitesList) { const siteId = site.siteId; + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` + ); + const existingClientSites = await trx .select({ clientId: clientSitesAssociationsCache.clientId @@ -293,6 +340,10 @@ export async function rebuildClientAssociationsFromSiteResource( (row) => row.clientId ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` + ); + // Get full client details for existing clients (needed for sending delete messages) const existingClients = existingClientSiteIds.length > 0 @@ -308,6 +359,10 @@ export async function rebuildClientAssociationsFromSiteResource( const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` + ); + const clientSitesToAdd = mergedAllClientIds.filter( (clientId) => !existingClientSiteIds.includes(clientId) && @@ -319,11 +374,25 @@ export async function rebuildClientAssociationsFromSiteResource( siteId })); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` + ); + if (clientSitesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` + ); await trx .insert(clientSitesAssociationsCache) .values(clientSitesToInsert) .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` + ); } // Now remove any client-site associations that should no longer exist @@ -333,7 +402,14 @@ export async function rebuildClientAssociationsFromSiteResource( !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` + ); + if (clientSitesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + ); await trx .delete(clientSitesAssociationsCache) .where( From 49ae5eecb65b676463808b47eab816c32436c58c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 21:56:35 -0700 Subject: [PATCH 055/176] Filter only approved sites --- src/components/CreateInternalResourceDialog.tsx | 3 --- src/components/InternalResourceForm.tsx | 3 --- src/components/resource-target-address-item.tsx | 15 +++++++-------- src/lib/queries.ts | 3 ++- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index b9c978b3f..4d2bc0916 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -25,8 +24,6 @@ import { type InternalResourceFormValues } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 13d24b6b0..e8574b29e 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -38,7 +38,6 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { ListSitesResponse } from "@server/routers/site"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; @@ -128,8 +127,6 @@ export const cleanForFQDN = (name: string): string => // --- Types --- -type Site = ListSitesResponse["sites"][0]; - export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 851b64b54..c801844ce 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -12,14 +12,6 @@ import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { ContainersSelector } from "./ContainersSelector"; import { Button } from "./ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "./ui/command"; import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; @@ -212,6 +204,12 @@ export function ResourceTargetAddressItem({ proxyTarget.port === 0 ? "" : proxyTarget.port } className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs" + type="number" + onKeyDown={(e) => { + if (["e", "E", "+", "-", "."].includes(e.key)) { + e.preventDefault(); + } + }} onBlur={(e) => { const value = parseInt(e.target.value, 10); if (!isNaN(value) && value > 0) { @@ -227,6 +225,7 @@ export function ResourceTargetAddressItem({ } }} /> + ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2fd34e8ac..d7822d6cf 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -155,7 +155,8 @@ export const orgQueries = { queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - pageSize: perPage.toString() + pageSize: perPage.toString(), + status: "approved" }); if (query?.trim()) { From 33182bcf8578f7d44b9154fa4c802a3f627e0f64 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 14 Apr 2026 21:43:16 -0700 Subject: [PATCH 056/176] Add init alert schema --- server/db/pg/schema/privateSchema.ts | 70 +++++++++++++++++++++++- server/db/sqlite/schema/privateSchema.ts | 58 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 4122fb5b5..d0bd05fb5 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -16,11 +16,13 @@ import { domains, orgs, targets, + roles, users, exitNodes, sessions, clients, siteResources, + targetHealthCheck, sites } from "./schema"; @@ -425,7 +427,9 @@ export const eventStreamingDestinations = pgTable( orgId: varchar("orgId", { length: 255 }) .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), + sendConnectionLogs: boolean("sendConnectionLogs") + .notNull() + .default(false), sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), sendActionLogs: boolean("sendActionLogs").notNull().default(false), sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), @@ -447,7 +451,9 @@ export const eventStreamingCursors = pgTable( onDelete: "cascade" }), logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection" - lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0), + lastSentId: bigint("lastSentId", { mode: "number" }) + .notNull() + .default(0), lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent }, (table) => [ @@ -458,6 +464,66 @@ export const eventStreamingCursors = pgTable( ] ); +export const alertRules = pgTable("alertRules", { + alertRuleId: serial("alertRuleId").primaryKey(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + // Single field encodes both source and trigger - no redundancy + eventType: varchar("eventType", { length: 100 }) + .$type< + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy" + >() + .notNull(), + // Nullable depending on eventType + siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId").references( + () => targetHealthCheck.targetHealthCheckId, + { onDelete: "cascade" } + ), + enabled: boolean("enabled").notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() +}); + +// Separating channels by type avoids the mixed-shape problem entirely +export const alertEmailActions = pgTable("alertEmailActions", { + emailActionId: serial("emailActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable +}); + +export const alertEmailRecipients = pgTable("alertEmailRecipients", { + recipientId: serial("recipientId").primaryKey(), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + // At least one of these should be set - enforced at app level + userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), + roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + email: varchar("email", { length: 255 }) // external emails not tied to a user +}); + +export const alertWebhookActions = pgTable("alertWebhookActions", { + webhookActionId: serial("webhookActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + secret: varchar("secret", { length: 255 }), // for HMAC signature validation + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index c1aa084a2..180a337c1 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,9 +13,11 @@ import { domains, exitNodes, orgs, + roles, sessions, siteResources, sites, + targetHealthCheck, users } from "./schema"; @@ -455,6 +457,62 @@ export const eventStreamingCursors = sqliteTable( ] ); +export const alertRules = sqliteTable("alertRules", { + alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + eventType: text("eventType") + .$type< + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy" + >() + .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId").references( + () => targetHealthCheck.targetHealthCheckId, + { onDelete: "cascade" } + ), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + lastTriggeredAt: integer("lastTriggeredAt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() +}); + +export const alertEmailActions = sqliteTable("alertEmailActions", { + emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + +export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { + recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), + roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + email: text("email") +}); + +export const alertWebhookActions = sqliteTable("alertWebhookActions", { + webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + secret: text("secret"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; From 7d50703c269f8a5e2f1949feab7ea1f5bacf0db8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 14 Apr 2026 21:58:36 -0700 Subject: [PATCH 057/176] First pass --- server/db/pg/schema/privateSchema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 2 +- server/emails/templates/AlertNotification.tsx | 140 +++++++++ .../lib/alerts/events/healthCheckEvents.ts | 91 ++++++ .../private/lib/alerts/events/siteEvents.ts | 91 ++++++ server/private/lib/alerts/index.ts | 19 ++ server/private/lib/alerts/processAlerts.ts | 266 ++++++++++++++++++ server/private/lib/alerts/sendAlertEmail.ts | 87 ++++++ server/private/lib/alerts/sendAlertWebhook.ts | 132 +++++++++ server/private/lib/alerts/types.ts | 59 ++++ 10 files changed, 887 insertions(+), 2 deletions(-) create mode 100644 server/emails/templates/AlertNotification.tsx create mode 100644 server/private/lib/alerts/events/healthCheckEvents.ts create mode 100644 server/private/lib/alerts/events/siteEvents.ts create mode 100644 server/private/lib/alerts/index.ts create mode 100644 server/private/lib/alerts/processAlerts.ts create mode 100644 server/private/lib/alerts/sendAlertEmail.ts create mode 100644 server/private/lib/alerts/sendAlertWebhook.ts create mode 100644 server/private/lib/alerts/types.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d0bd05fb5..011373065 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -519,7 +519,7 @@ export const alertWebhookActions = pgTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - secret: varchar("secret", { length: 255 }), // for HMAC signature validation + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: boolean("enabled").notNull().default(true), lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 180a337c1..e0a3aed6f 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -508,7 +508,7 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - secret: text("secret"), + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), lastSentAt: integer("lastSentAt") }); diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx new file mode 100644 index 000000000..c01a99f3e --- /dev/null +++ b/server/emails/templates/AlertNotification.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +interface Props { + eventType: AlertEventType; + orgId: string; + data: Record; +} + +function getEventMeta(eventType: AlertEventType): { + heading: string; + previewText: string; + summary: string; + statusLabel: string; + statusColor: string; +} { + switch (eventType) { + case "site_online": + return { + heading: "Site Back Online", + previewText: "A site in your organization is back online.", + summary: + "Good news – a site in your organization has come back online and is now reachable.", + statusLabel: "Online", + statusColor: "#16a34a" + }; + case "site_offline": + return { + heading: "Site Offline", + previewText: "A site in your organization has gone offline.", + summary: + "A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.", + statusLabel: "Offline", + statusColor: "#dc2626" + }; + case "health_check_healthy": + return { + heading: "Health Check Recovered", + previewText: + "A health check in your organization is now healthy.", + summary: + "A health check in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "health_check_not_healthy": + return { + heading: "Health Check Failing", + previewText: + "A health check in your organization is not healthy.", + summary: + "A health check in your organization is currently failing. Please review the details below and take action if needed.", + statusLabel: "Not Healthy", + statusColor: "#dc2626" + }; + } +} + +function formatDataItems( + data: Record +): { label: string; value: React.ReactNode }[] { + return Object.entries(data) + .filter(([key]) => key !== "orgId") + .map(([key, value]) => ({ + label: key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (s) => s.toUpperCase()) + .trim(), + value: String(value ?? "—") + })); +} + +export const AlertNotification = ({ eventType, orgId, data }: Props) => { + const meta = getEventMeta(eventType); + const dataItems = formatDataItems(data); + + const allItems: { label: string; value: React.ReactNode }[] = [ + { label: "Organization", value: orgId }, + { label: "Status", value: ( + + {meta.statusLabel} + + )}, + { label: "Time", value: new Date().toUTCString() }, + ...dataItems + ]; + + return ( + + + {meta.previewText} + + + + + + {meta.heading} + + Hi there, + + {meta.summary} + + + + + Log in to your dashboard to view more details and + manage your alert rules. + + + + + + + + + + ); +}; + +export default AlertNotification; \ No newline at end of file diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts new file mode 100644 index 000000000..0adb2441b --- /dev/null +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +/** + * Fire a `health_check_not_healthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckNotHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_not_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts new file mode 100644 index 000000000..7074542cf --- /dev/null +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `site_online` alert for the given site. + * + * Call this after the site has been confirmed reachable / connected so that + * any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOnlineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "site_online", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} + +/** + * Fire a `site_offline` alert for the given site. + * + * Call this after the site has been detected as unreachable / disconnected so + * that any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOfflineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "site_offline", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts new file mode 100644 index 000000000..e529533e6 --- /dev/null +++ b/server/private/lib/alerts/index.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 "./types"; +export * from "./processAlerts"; +export * from "./sendAlertWebhook"; +export * from "./sendAlertEmail"; +export * from "./events/siteEvents"; +export * from "./events/healthCheckEvents"; \ No newline at end of file diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts new file mode 100644 index 000000000..1a6fd242f --- /dev/null +++ b/server/private/lib/alerts/processAlerts.ts @@ -0,0 +1,266 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { and, eq, isNull, or } from "drizzle-orm"; +import { db } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions, + userOrgRoles, + users +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { AlertContext, WebhookAlertConfig } from "./types"; +import { sendAlertWebhook } from "./sendAlertWebhook"; +import { sendAlertEmail } from "./sendAlertEmail"; + +/** + * Core alert processing pipeline. + * + * Given an `AlertContext`, this function: + * 1. Finds all enabled `alertRules` whose `eventType` matches and whose + * `siteId` / `healthCheckId` matches (or is null, meaning "all"). + * 2. Applies per-rule cooldown gating. + * 3. Dispatches emails and webhook POSTs for every attached action. + * 4. Updates `lastTriggeredAt` and `lastSentAt` timestamps. + */ +export async function processAlerts(context: AlertContext): Promise { + const now = Date.now(); + + // ------------------------------------------------------------------ + // 1. Find matching alert rules + // ------------------------------------------------------------------ + const siteCondition = + context.siteId != null + ? or( + eq(alertRules.siteId, context.siteId), + isNull(alertRules.siteId) + ) + : isNull(alertRules.siteId); + + const healthCheckCondition = + context.healthCheckId != null + ? or( + eq(alertRules.healthCheckId, context.healthCheckId), + isNull(alertRules.healthCheckId) + ) + : isNull(alertRules.healthCheckId); + + const rules = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.orgId, context.orgId), + eq(alertRules.eventType, context.eventType), + eq(alertRules.enabled, true), + // Apply the right scope filter based on event type + context.siteId != null ? siteCondition : healthCheckCondition + ) + ); + + if (rules.length === 0) { + logger.debug( + `processAlerts: no matching rules for event "${context.eventType}" in org "${context.orgId}"` + ); + return; + } + + for (const rule of rules) { + try { + await processRule(rule, context, now); + } catch (err) { + logger.error( + `processAlerts: error processing rule ${rule.alertRuleId} for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Per-rule processing +// --------------------------------------------------------------------------- + +async function processRule( + rule: typeof alertRules.$inferSelect, + context: AlertContext, + now: number +): Promise { + // ------------------------------------------------------------------ + // 2. Cooldown check + // ------------------------------------------------------------------ + if ( + rule.lastTriggeredAt != null && + now - rule.lastTriggeredAt < rule.cooldownSeconds * 1000 + ) { + const remainingSeconds = Math.ceil( + (rule.cooldownSeconds * 1000 - (now - rule.lastTriggeredAt)) / 1000 + ); + logger.debug( + `processAlerts: rule ${rule.alertRuleId} is in cooldown – ${remainingSeconds}s remaining` + ); + return; + } + + // ------------------------------------------------------------------ + // 3. Mark rule as triggered (optimistic update – before sending so we + // don't re-trigger if the send is slow) + // ------------------------------------------------------------------ + await db + .update(alertRules) + .set({ lastTriggeredAt: now }) + .where(eq(alertRules.alertRuleId, rule.alertRuleId)); + + // ------------------------------------------------------------------ + // 4. Process email actions + // ------------------------------------------------------------------ + const emailActions = await db + .select() + .from(alertEmailActions) + .where( + and( + eq(alertEmailActions.alertRuleId, rule.alertRuleId), + eq(alertEmailActions.enabled, true) + ) + ); + + for (const action of emailActions) { + try { + const recipients = await resolveEmailRecipients(action.emailActionId); + if (recipients.length > 0) { + await sendAlertEmail(recipients, context); + await db + .update(alertEmailActions) + .set({ lastSentAt: now }) + .where( + eq(alertEmailActions.emailActionId, action.emailActionId) + ); + } + } catch (err) { + logger.error( + `processAlerts: failed to send alert email for action ${action.emailActionId}`, + err + ); + } + } + + // ------------------------------------------------------------------ + // 5. Process webhook actions + // ------------------------------------------------------------------ + const webhookActions = await db + .select() + .from(alertWebhookActions) + .where( + and( + eq(alertWebhookActions.alertRuleId, rule.alertRuleId), + eq(alertWebhookActions.enabled, true) + ) + ); + + const serverSecret = config.getRawConfig().server.secret!; + + for (const action of webhookActions) { + try { + let webhookConfig: WebhookAlertConfig = { authType: "none" }; + + if (action.config) { + try { + const decrypted = decrypt(action.config, serverSecret); + webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch (err) { + logger.error( + `processAlerts: failed to decrypt webhook config for action ${action.webhookActionId}`, + err + ); + continue; + } + } + + await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + await db + .update(alertWebhookActions) + .set({ lastSentAt: now }) + .where( + eq( + alertWebhookActions.webhookActionId, + action.webhookActionId + ) + ); + } catch (err) { + logger.error( + `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Email recipient resolution +// --------------------------------------------------------------------------- + +/** + * Resolves all email addresses for a given `emailActionId`. + * + * Recipients may be: + * - Direct users (by `userId`) + * - All users in a role (by `roleId`, resolved via `userOrgRoles`) + * - Direct external email addresses + */ +async function resolveEmailRecipients(emailActionId: number): Promise { + const rows = await db + .select() + .from(alertEmailRecipients) + .where(eq(alertEmailRecipients.emailActionId, emailActionId)); + + const emailSet = new Set(); + + for (const row of rows) { + if (row.email) { + emailSet.add(row.email); + } + + if (row.userId) { + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.userId, row.userId)) + .limit(1); + if (user?.email) { + emailSet.add(user.email); + } + } + + if (row.roleId) { + // Find all users with this role via userOrgRoles + const roleUsers = await db + .select({ email: users.email }) + .from(userOrgRoles) + .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + .where(eq(userOrgRoles.roleId, Number(row.roleId))); + + for (const u of roleUsers) { + if (u.email) { + emailSet.add(u.email); + } + } + } + } + + return Array.from(emailSet); +} \ No newline at end of file diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts new file mode 100644 index 000000000..e119b5eb7 --- /dev/null +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -0,0 +1,87 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { sendEmail } from "@server/emails"; +import AlertNotification from "@server/emails/templates/AlertNotification"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { AlertContext } from "./types"; + +/** + * Sends an alert notification email to every address in `recipients`. + * + * Each recipient receives an individual email (no BCC list) so that delivery + * failures for one address do not affect the others. Failures per recipient + * are logged and swallowed – the caller only sees an error if something goes + * wrong before the send loop. + */ +export async function sendAlertEmail( + recipients: string[], + context: AlertContext +): Promise { + if (recipients.length === 0) { + return; + } + + const from = config.getNoReplyEmail(); + const subject = buildSubject(context); + + for (const to of recipients) { + try { + await sendEmail( + AlertNotification({ + eventType: context.eventType, + orgId: context.orgId, + data: context.data + }), + { + from, + to, + subject + } + ); + logger.debug( + `Alert email sent to "${to}" for event "${context.eventType}"` + ); + } catch (err) { + logger.error( + `sendAlertEmail: failed to send alert email to "${to}" for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildSubject(context: AlertContext): string { + switch (context.eventType) { + case "site_online": + return "[Alert] Site Back Online"; + case "site_offline": + return "[Alert] Site Offline"; + case "health_check_healthy": + return "[Alert] Health Check Recovered"; + case "health_check_not_healthy": + return "[Alert] Health Check Failing"; + default: { + // Exhaustiveness fallback – should never be reached with a + // well-typed caller, but keeps runtime behaviour predictable. + const _exhaustive: never = context.eventType; + void _exhaustive; + return "[Alert] Event Notification"; + } + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts new file mode 100644 index 000000000..38d3c514a --- /dev/null +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -0,0 +1,132 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 logger from "@server/logger"; +import { AlertContext, WebhookAlertConfig } from "./types"; + +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Sends a single webhook POST for an alert event. + * + * The payload shape is: + * ```json + * { + * "event": "site_online", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "data": { ... } + * } + * ``` + * + * Authentication headers are applied according to `config.authType`, + * mirroring the same strategies supported by HttpLogDestination: + * none | bearer | basic | custom. + */ +export async function sendAlertWebhook( + url: string, + webhookConfig: WebhookAlertConfig, + context: AlertContext +): Promise { + const payload = { + event: context.eventType, + timestamp: new Date().toISOString(), + data: { + orgId: context.orgId, + ...context.data + } + }; + + const body = JSON.stringify(payload); + const headers = buildHeaders(webhookConfig); + + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers, + body, + signal: controller.signal + }); + } catch (err: unknown) { + const isAbort = err instanceof Error && err.name === "AbortError"; + if (isAbort) { + throw new Error( + `Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Alert webhook: request to "${url}" failed – ${msg}`); + } finally { + clearTimeout(timeoutHandle); + } + + if (!response.ok) { + let snippet = ""; + try { + const text = await response.text(); + snippet = text.slice(0, 300); + } catch { + // best-effort + } + throw new Error( + `Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` + + (snippet ? ` – ${snippet}` : "") + ); + } + + logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`); +} + +// --------------------------------------------------------------------------- +// Header construction (mirrors HttpLogDestination.buildHeaders) +// --------------------------------------------------------------------------- + +function buildHeaders(webhookConfig: WebhookAlertConfig): Record { + const headers: Record = { + "Content-Type": "application/json" + }; + + switch (webhookConfig.authType) { + case "bearer": { + const token = webhookConfig.bearerToken?.trim(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + break; + } + case "basic": { + const creds = webhookConfig.basicCredentials?.trim(); + if (creds) { + const encoded = Buffer.from(creds).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } + break; + } + case "custom": { + const name = webhookConfig.customHeaderName?.trim(); + const value = webhookConfig.customHeaderValue ?? ""; + if (name) { + headers[name] = value; + } + break; + } + case "none": + default: + break; + } + + return headers; +} \ No newline at end of file diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..d0e91ea8a --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,59 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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. + */ + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; +} + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} \ No newline at end of file From cf741a6f873ed535a3864ac437872dc1a47da772 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:26:34 -0700 Subject: [PATCH 058/176] Add stub --- server/lib/alerts/events/healthCheckEvents.ts | 19 +++++++++++++++++++ server/lib/alerts/events/siteEvents.ts | 19 +++++++++++++++++++ server/lib/alerts/index.ts | 2 ++ 3 files changed, 40 insertions(+) create mode 100644 server/lib/alerts/events/healthCheckEvents.ts create mode 100644 server/lib/alerts/events/siteEvents.ts create mode 100644 server/lib/alerts/index.ts diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts new file mode 100644 index 000000000..dacb5287a --- /dev/null +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -0,0 +1,19 @@ +// stub + +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + return; +} + +export async function fireHealthCheckNotHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + return; +} diff --git a/server/lib/alerts/events/siteEvents.ts b/server/lib/alerts/events/siteEvents.ts new file mode 100644 index 000000000..8426fa9c2 --- /dev/null +++ b/server/lib/alerts/events/siteEvents.ts @@ -0,0 +1,19 @@ +// stub + +export async function fireSiteOnlineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + return; +} + +export async function fireSiteOfflineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + return; +} diff --git a/server/lib/alerts/index.ts b/server/lib/alerts/index.ts new file mode 100644 index 000000000..017603253 --- /dev/null +++ b/server/lib/alerts/index.ts @@ -0,0 +1,2 @@ +export * from "./events/siteEvents"; +export * from "./events/healthCheckEvents"; From 87a554b6ef392f1c790ee3f93d771c05d5aa8897 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:33:55 -0700 Subject: [PATCH 059/176] Add crud --- server/auth/actions.ts | 7 +- .../routers/alertRule/createAlertRule.ts | 196 ++++++++++++++++++ .../routers/alertRule/deleteAlertRule.ts | 100 +++++++++ .../private/routers/alertRule/getAlertRule.ts | 187 +++++++++++++++++ server/private/routers/alertRule/index.ts | 18 ++ .../routers/alertRule/listAlertRules.ts | 139 +++++++++++++ .../routers/alertRule/updateAlertRule.ts | 160 ++++++++++++++ server/private/routers/external.ts | 43 ++++ 8 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/alertRule/createAlertRule.ts create mode 100644 server/private/routers/alertRule/deleteAlertRule.ts create mode 100644 server/private/routers/alertRule/getAlertRule.ts create mode 100644 server/private/routers/alertRule/index.ts create mode 100644 server/private/routers/alertRule/listAlertRules.ts create mode 100644 server/private/routers/alertRule/updateAlertRule.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 213dab9d3..40777676c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -144,7 +144,12 @@ export enum ActionsEnum { createEventStreamingDestination = "createEventStreamingDestination", updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", - listEventStreamingDestinations = "listEventStreamingDestinations" + listEventStreamingDestinations = "listEventStreamingDestinations", + createAlertRule = "createAlertRule", + updateAlertRule = "updateAlertRule", + deleteAlertRule = "deleteAlertRule", + listAlertRules = "listAlertRules", + getAlertRule = "getAlertRule" } export async function checkUserActionPermission( diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts new file mode 100644 index 000000000..2c1ef42e3 --- /dev/null +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -0,0 +1,196 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const recipientSchema = z + .strictObject({ + userId: z.string().optional(), + roleId: z.string().optional(), + email: z.string().email().optional() + }) + .refine((r) => r.userId || r.roleId || r.email, { + message: "Each recipient must have at least one of userId, roleId, or email" + }); + +const emailActionSchema = z.strictObject({ + enabled: z.boolean().optional().default(true), + recipients: z.array(recipientSchema).min(1) +}); + +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + eventType: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]), + siteId: z.number().int().optional(), + healthCheckId: z.number().int().optional(), + enabled: z.boolean().optional().default(true), + cooldownSeconds: z.number().int().nonnegative().optional().default(300), + emailAction: emailActionSchema.optional(), + webhookActions: z.array(webhookActionSchema).optional() +}); + +export type CreateAlertRuleResponse = { + alertRuleId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/alert-rule", + description: "Create an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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, + eventType, + siteId, + healthCheckId, + enabled, + cooldownSeconds, + emailAction, + webhookActions + } = parsedBody.data; + + const now = Date.now(); + + const [rule] = await db + .insert(alertRules) + .values({ + orgId, + name, + eventType, + siteId: siteId ?? null, + healthCheckId: healthCheckId ?? null, + enabled, + cooldownSeconds, + createdAt: now, + updatedAt: now + }) + .returning(); + + if (emailAction) { + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ + alertRuleId: rule.alertRuleId, + enabled: emailAction.enabled + }) + .returning(); + + if (emailAction.recipients.length > 0) { + await db.insert(alertEmailRecipients).values( + emailAction.recipients.map((r) => ({ + emailActionId: emailActionRow.emailActionId, + userId: r.userId ?? null, + roleId: r.roleId ?? null, + email: r.email ?? null + })) + ); + } + } + + if (webhookActions && webhookActions.length > 0) { + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId: rule.alertRuleId, + webhookUrl: wa.webhookUrl, + config: wa.config ?? null, + enabled: wa.enabled + })) + ); + } + + return response(res, { + data: { + alertRuleId: rule.alertRuleId + }, + success: true, + error: false, + message: "Alert rule created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts new file mode 100644 index 000000000..298ae50bf --- /dev/null +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -0,0 +1,100 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Delete an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + await db + .delete(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Alert rule deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts new file mode 100644 index 000000000..f0c197f05 --- /dev/null +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -0,0 +1,187 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +export type GetAlertRuleResponse = { + alertRuleId: number; + orgId: string; + name: string; + eventType: + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + siteId: number | null; + healthCheckId: number | null; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + emailAction: { + emailActionId: number; + enabled: boolean; + lastSentAt: number | null; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; + } | null; + webhookActions: { + webhookActionId: number; + webhookUrl: string; + enabled: boolean; + lastSentAt: number | null; + }[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Get a specific alert rule for an organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function getAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [rule] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!rule) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + // Fetch email action and recipients + const [emailAction] = await db + .select() + .from(alertEmailActions) + .where(eq(alertEmailActions.alertRuleId, alertRuleId)); + + let emailActionResult: GetAlertRuleResponse["emailAction"] = null; + if (emailAction) { + const recipients = await db + .select() + .from(alertEmailRecipients) + .where( + eq( + alertEmailRecipients.emailActionId, + emailAction.emailActionId + ) + ); + + emailActionResult = { + emailActionId: emailAction.emailActionId, + enabled: emailAction.enabled, + lastSentAt: emailAction.lastSentAt ?? null, + recipients: recipients.map((r) => ({ + recipientId: r.recipientId, + userId: r.userId ?? null, + roleId: r.roleId ?? null, + email: r.email ?? null + })) + }; + } + + // Fetch webhook actions + const webhooks = await db + .select() + .from(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + return response(res, { + data: { + alertRuleId: rule.alertRuleId, + orgId: rule.orgId, + name: rule.name, + eventType: rule.eventType, + siteId: rule.siteId ?? null, + healthCheckId: rule.healthCheckId ?? null, + enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds, + lastTriggeredAt: rule.lastTriggeredAt ?? null, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + emailAction: emailActionResult, + webhookActions: webhooks.map((w) => ({ + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null + })) + }, + success: true, + error: false, + message: "Alert rule retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts new file mode 100644 index 000000000..b01be8c01 --- /dev/null +++ b/server/private/routers/alertRule/index.ts @@ -0,0 +1,18 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 "./createAlertRule"; +export * from "./updateAlertRule"; +export * from "./deleteAlertRule"; +export * from "./listAlertRules"; +export * from "./getAlertRule"; \ No newline at end of file diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts new file mode 100644 index 000000000..da997a5d8 --- /dev/null +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -0,0 +1,139 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, sql } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export type ListAlertRulesResponse = { + alertRules: { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + siteId: number | null; + healthCheckId: number | null; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rules", + description: "List all alert rules for a specific organization.", + tags: [OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); + +export async function listAlertRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await db + .select() + .from(alertRules) + .where(eq(alertRules.orgId, orgId)) + .orderBy(sql`${alertRules.createdAt} DESC`) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(alertRules) + .where(eq(alertRules.orgId, orgId)); + + return response(res, { + data: { + alertRules: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts new file mode 100644 index 000000000..58ed56071 --- /dev/null +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + eventType: z + .enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]) + .optional(), + siteId: z.number().int().nullable().optional(), + healthCheckId: z.number().int().nullable().optional(), + enabled: z.boolean().optional(), + cooldownSeconds: z.number().int().nonnegative().optional() +}); + +export type UpdateAlertRuleResponse = { + alertRuleId: number; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Update an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + const { + name, + eventType, + siteId, + healthCheckId, + enabled, + cooldownSeconds + } = parsedBody.data; + + const updateData: Record = { + updatedAt: Date.now() + }; + + if (name !== undefined) updateData.name = name; + if (eventType !== undefined) updateData.eventType = eventType; + if (siteId !== undefined) updateData.siteId = siteId; + if (healthCheckId !== undefined) updateData.healthCheckId = healthCheckId; + if (enabled !== undefined) updateData.enabled = enabled; + if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + + await db + .update(alertRules) + .set(updateData) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + return response(res, { + data: { + alertRuleId + }, + success: true, + error: false, + message: "Alert rule updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 4410a44c8..590f67a46 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -29,6 +29,7 @@ import * as ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; +import * as alertRule from "#private/routers/alertRule"; import { verifyOrgAccess, @@ -652,3 +653,45 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), eventStreamingDestination.listEventStreamingDestinations ); + +authenticated.put( + "/org/:orgId/alert-rule", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createAlertRule), + logActionAudit(ActionsEnum.createAlertRule), + alertRule.createAlertRule +); + +authenticated.post( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateAlertRule), + logActionAudit(ActionsEnum.updateAlertRule), + alertRule.updateAlertRule +); + +authenticated.delete( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteAlertRule), + logActionAudit(ActionsEnum.deleteAlertRule), + alertRule.deleteAlertRule +); + +authenticated.get( + "/org/:orgId/alert-rules", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAlertRules), + alertRule.listAlertRules +); + +authenticated.get( + "/org/:orgId/alert-rule/:alertRuleId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getAlertRule), + alertRule.getAlertRule +); From 22ead84aa70132378ccfcfce1e6841a922bc316c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:42:48 -0700 Subject: [PATCH 060/176] Update license year --- server/private/lib/alerts/events/healthCheckEvents.ts | 2 +- server/private/lib/alerts/events/siteEvents.ts | 2 +- server/private/lib/alerts/index.ts | 2 +- server/private/lib/alerts/processAlerts.ts | 2 +- server/private/lib/alerts/sendAlertEmail.ts | 2 +- server/private/lib/alerts/sendAlertWebhook.ts | 2 +- server/private/lib/alerts/types.ts | 2 +- server/private/routers/alertRule/createAlertRule.ts | 2 +- server/private/routers/alertRule/deleteAlertRule.ts | 2 +- server/private/routers/alertRule/getAlertRule.ts | 2 +- server/private/routers/alertRule/index.ts | 2 +- server/private/routers/alertRule/listAlertRules.ts | 2 +- server/private/routers/alertRule/updateAlertRule.ts | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 0adb2441b..38dff916b 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 7074542cf..27c4cb8bf 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts index e529533e6..3460e965d 100644 --- a/server/private/lib/alerts/index.ts +++ b/server/private/lib/alerts/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 1a6fd242f..ecde09a8d 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index e119b5eb7..cd78e6e87 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 38d3c514a..a1cb79c60 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index d0e91ea8a..626c2710f 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 2c1ef42e3..e9834b2dd 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts index 298ae50bf..0988cd631 100644 --- a/server/private/routers/alertRule/deleteAlertRule.ts +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index f0c197f05..5c623cbd1 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts index b01be8c01..19e35f7dc 100644 --- a/server/private/routers/alertRule/index.ts +++ b/server/private/routers/alertRule/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index da997a5d8..faf164d73 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 58ed56071..1939c5c6b 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. From f379986a597a5ff24ba3893829e671883ceee5c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:48:50 -0700 Subject: [PATCH 061/176] Allow many to one on the receipients on the rules --- .../routers/alertRule/createAlertRule.ts | 73 +++++------ .../private/routers/alertRule/getAlertRule.ts | 47 +++---- .../routers/alertRule/updateAlertRule.ts | 116 +++++++++++++++++- 3 files changed, 169 insertions(+), 67 deletions(-) diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index e9834b2dd..20ce52492 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025-2026 Fossorial, Inc. + * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -31,21 +31,6 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const recipientSchema = z - .strictObject({ - userId: z.string().optional(), - roleId: z.string().optional(), - email: z.string().email().optional() - }) - .refine((r) => r.userId || r.roleId || r.email, { - message: "Each recipient must have at least one of userId, roleId, or email" - }); - -const emailActionSchema = z.strictObject({ - enabled: z.boolean().optional().default(true), - recipients: z.array(recipientSchema).min(1) -}); - const webhookActionSchema = z.strictObject({ webhookUrl: z.string().url(), config: z.string().optional(), @@ -64,8 +49,10 @@ const bodySchema = z.strictObject({ healthCheckId: z.number().int().optional(), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), - emailAction: emailActionSchema.optional(), - webhookActions: z.array(webhookActionSchema).optional() + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.string().nonempty()).optional().default([]), + emails: z.array(z.string().email()).optional().default([]), + webhookActions: z.array(webhookActionSchema).optional().default([]) }); export type CreateAlertRuleResponse = { @@ -125,7 +112,9 @@ export async function createAlertRule( healthCheckId, enabled, cooldownSeconds, - emailAction, + userIds, + roleIds, + emails, webhookActions } = parsedBody.data; @@ -146,28 +135,42 @@ export async function createAlertRule( }) .returning(); - if (emailAction) { + // Create the email action pivot row and recipients if any recipients + // were supplied (userIds, roleIds, or raw emails). + const hasRecipients = + userIds.length > 0 || roleIds.length > 0 || emails.length > 0; + + if (hasRecipients) { const [emailActionRow] = await db .insert(alertEmailActions) - .values({ - alertRuleId: rule.alertRuleId, - enabled: emailAction.enabled - }) + .values({ alertRuleId: rule.alertRuleId }) .returning(); - if (emailAction.recipients.length > 0) { - await db.insert(alertEmailRecipients).values( - emailAction.recipients.map((r) => ({ - emailActionId: emailActionRow.emailActionId, - userId: r.userId ?? null, - roleId: r.roleId ?? null, - email: r.email ?? null - })) - ); - } + const recipientRows = [ + ...userIds.map((userId) => ({ + emailActionId: emailActionRow.emailActionId, + userId, + roleId: null, + email: null + })), + ...roleIds.map((roleId) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null, + roleId, + email: null + })), + ...emails.map((email) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null, + roleId: null, + email + })) + ]; + + await db.insert(alertEmailRecipients).values(recipientRows); } - if (webhookActions && webhookActions.length > 0) { + if (webhookActions.length > 0) { await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 5c623cbd1..72c6e1df5 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025-2026 Fossorial, Inc. + * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -51,17 +51,12 @@ export type GetAlertRuleResponse = { lastTriggeredAt: number | null; createdAt: number; updatedAt: number; - emailAction: { - emailActionId: number; - enabled: boolean; - lastSentAt: number | null; - recipients: { - recipientId: number; - userId: string | null; - roleId: string | null; - email: string | null; - }[]; - } | null; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; webhookActions: { webhookActionId: number; webhookUrl: string; @@ -115,15 +110,17 @@ export async function getAlertRule( ); } - // Fetch email action and recipients + // Resolve the single email action row for this rule, then collect all + // recipients into a flat list. The emailAction pivot row is an internal + // implementation detail and is not surfaced to callers. const [emailAction] = await db .select() .from(alertEmailActions) .where(eq(alertEmailActions.alertRuleId, alertRuleId)); - let emailActionResult: GetAlertRuleResponse["emailAction"] = null; + let recipients: GetAlertRuleResponse["recipients"] = []; if (emailAction) { - const recipients = await db + const rows = await db .select() .from(alertEmailRecipients) .where( @@ -133,20 +130,14 @@ export async function getAlertRule( ) ); - emailActionResult = { - emailActionId: emailAction.emailActionId, - enabled: emailAction.enabled, - lastSentAt: emailAction.lastSentAt ?? null, - recipients: recipients.map((r) => ({ - recipientId: r.recipientId, - userId: r.userId ?? null, - roleId: r.roleId ?? null, - email: r.email ?? null - })) - }; + recipients = rows.map((r) => ({ + recipientId: r.recipientId, + userId: r.userId ?? null, + roleId: r.roleId ?? null, + email: r.email ?? null + })); } - // Fetch webhook actions const webhooks = await db .select() .from(alertWebhookActions) @@ -165,7 +156,7 @@ export async function getAlertRule( lastTriggeredAt: rule.lastTriggeredAt ?? null, createdAt: rule.createdAt, updatedAt: rule.updatedAt, - emailAction: emailActionResult, + recipients, webhookActions: webhooks.map((w) => ({ webhookActionId: w.webhookActionId, webhookUrl: w.webhookUrl, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 1939c5c6b..05116d3b8 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025-2026 Fossorial, Inc. + * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -14,7 +14,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -30,7 +35,14 @@ const paramsSchema = z }) .strict(); +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) +}); + const bodySchema = z.strictObject({ + // Alert rule fields - all optional for partial updates name: z.string().nonempty().optional(), eventType: z .enum([ @@ -43,7 +55,13 @@ const bodySchema = z.strictObject({ siteId: z.number().int().nullable().optional(), healthCheckId: z.number().int().nullable().optional(), enabled: z.boolean().optional(), - cooldownSeconds: z.number().int().nonnegative().optional() + cooldownSeconds: z.number().int().nonnegative().optional(), + // Recipient arrays - if any are provided the full recipient set is replaced + userIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.string().nonempty()).optional(), + emails: z.array(z.string().email()).optional(), + // Webhook actions - if provided the full webhook set is replaced + webhookActions: z.array(webhookActionSchema).optional() }); export type UpdateAlertRuleResponse = { @@ -118,9 +136,14 @@ export async function updateAlertRule( siteId, healthCheckId, enabled, - cooldownSeconds + cooldownSeconds, + userIds, + roleIds, + emails, + webhookActions } = parsedBody.data; + // --- Update rule fields --- const updateData: Record = { updatedAt: Date.now() }; @@ -142,6 +165,91 @@ export async function updateAlertRule( ) ); + // --- Full-replace recipients if any recipient array was provided --- + const recipientsProvided = + userIds !== undefined || + roleIds !== undefined || + emails !== undefined; + + if (recipientsProvided) { + // Build the flat list of recipient rows to insert + const newRecipients = [ + ...(userIds ?? []).map((userId) => ({ + userId, + roleId: null as string | null, + email: null as string | null + })), + ...(roleIds ?? []).map((roleId) => ({ + userId: null as string | null, + roleId, + email: null as string | null + })), + ...(emails ?? []).map((email) => ({ + userId: null as string | null, + roleId: null as string | null, + email + })) + ]; + + // Find or create the single emailAction row for this rule + const [existingEmailAction] = await db + .select() + .from(alertEmailActions) + .where(eq(alertEmailActions.alertRuleId, alertRuleId)); + + if (existingEmailAction) { + // Delete all current recipients then re-insert + await db + .delete(alertEmailRecipients) + .where( + eq( + alertEmailRecipients.emailActionId, + existingEmailAction.emailActionId + ) + ); + + if (newRecipients.length > 0) { + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: existingEmailAction.emailActionId, + ...r + })) + ); + } + } else if (newRecipients.length > 0) { + // No emailAction exists yet - create one then insert recipients + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ alertRuleId, enabled: true }) + .returning(); + + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: emailActionRow.emailActionId, + ...r + })) + ); + } + } + + // --- Full-replace webhook actions if the array was provided --- + if (webhookActions !== undefined) { + await db + .delete(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + if (webhookActions.length > 0) { + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId, + webhookUrl: wa.webhookUrl, + config: wa.config ?? null, + enabled: wa.enabled + })) + ); + } + } + return response(res, { data: { alertRuleId From bf64e226d3d9fb7c3bbdcc85cfaf7f9f2b1c528e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:58:33 -0700 Subject: [PATCH 062/176] Many to one on sites and health checks --- server/db/pg/schema/privateSchema.ts | 47 +++++-- server/db/sqlite/schema/privateSchema.ts | 25 +++- .../routers/alertRule/createAlertRule.ts | 130 ++++++++++++++---- .../private/routers/alertRule/getAlertRule.ts | 23 +++- .../routers/alertRule/listAlertRules.ts | 62 ++++++++- .../routers/alertRule/updateAlertRule.ts | 125 ++++++++++++----- 6 files changed, 326 insertions(+), 86 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 011373065..9007013b1 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -480,18 +480,33 @@ export const alertRules = pgTable("alertRules", { >() .notNull(), // Nullable depending on eventType - siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), - healthCheckId: integer("healthCheckId").references( - () => targetHealthCheck.targetHealthCheckId, - { onDelete: "cascade" } - ), enabled: boolean("enabled").notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), - lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable + lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() }); +export const alertSites = pgTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = pgTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + // Separating channels by type avoids the mixed-shape problem entirely export const alertEmailActions = pgTable("alertEmailActions", { emailActionId: serial("emailActionId").primaryKey(), @@ -499,18 +514,24 @@ export const alertEmailActions = pgTable("alertEmailActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), enabled: boolean("enabled").notNull().default(true), - lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); export const alertEmailRecipients = pgTable("alertEmailRecipients", { recipientId: serial("recipientId").primaryKey(), emailActionId: integer("emailActionId") .notNull() - .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), // At least one of these should be set - enforced at app level - userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }), - email: varchar("email", { length: 255 }) // external emails not tied to a user + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: varchar("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), + email: varchar("email", { length: 255 }) // external emails not tied to a user }); export const alertWebhookActions = pgTable("alertWebhookActions", { @@ -519,9 +540,9 @@ export const alertWebhookActions = pgTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - config: text("config"), // encrypted JSON with auth config (authType, credentials) + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: boolean("enabled").notNull().default(true), - lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); export type Approval = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index e0a3aed6f..318a094dd 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -471,11 +471,6 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_not_healthy" >() .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), - healthCheckId: integer("healthCheckId").references( - () => targetHealthCheck.targetHealthCheckId, - { onDelete: "cascade" } - ), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), lastTriggeredAt: integer("lastTriggeredAt"), @@ -483,6 +478,26 @@ export const alertRules = sqliteTable("alertRules", { updatedAt: integer("updatedAt").notNull() }); +export const alertSites = sqliteTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = sqliteTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + export const alertEmailActions = sqliteTable("alertEmailActions", { emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), alertRuleId: integer("alertRuleId") diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 20ce52492..014a62956 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -27,6 +29,12 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; +const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_not_healthy" +] as const; + const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -37,23 +45,73 @@ const webhookActionSchema = z.strictObject({ enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - eventType: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" - ]), - siteId: z.number().int().optional(), - healthCheckId: z.number().int().optional(), - enabled: z.boolean().optional().default(true), - cooldownSeconds: z.number().int().nonnegative().optional().default(300), - userIds: z.array(z.string().nonempty()).optional().default([]), - roleIds: z.array(z.string().nonempty()).optional().default([]), - emails: z.array(z.string().email()).optional().default([]), - webhookActions: z.array(webhookActionSchema).optional().default([]) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + eventType: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]), + enabled: z.boolean().optional().default(true), + cooldownSeconds: z.number().int().nonnegative().optional().default(300), + // Source join tables - which is required depends on eventType + siteIds: z.array(z.number().int().positive()).optional().default([]), + healthCheckIds: z + .array(z.number().int().positive()) + .optional() + .default([]), + // Email recipients (flat) + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.string().nonempty()).optional().default([]), + emails: z.array(z.string().email()).optional().default([]), + // Webhook actions + webhookActions: z.array(webhookActionSchema).optional().default([]) + }) + .superRefine((val, ctx) => { + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one siteId is required for site event types", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one healthCheckId is required for health check event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + }); export type CreateAlertRuleResponse = { alertRuleId: number; @@ -108,10 +166,10 @@ export async function createAlertRule( const { name, eventType, - siteId, - healthCheckId, enabled, cooldownSeconds, + siteIds, + healthCheckIds, userIds, roleIds, emails, @@ -126,8 +184,6 @@ export async function createAlertRule( orgId, name, eventType, - siteId: siteId ?? null, - healthCheckId: healthCheckId ?? null, enabled, cooldownSeconds, createdAt: now, @@ -135,6 +191,26 @@ export async function createAlertRule( }) .returning(); + // Insert site associations + if (siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId: rule.alertRuleId, + siteId + })) + ); + } + + // Insert health check associations + if (healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId: rule.alertRuleId, + healthCheckId + })) + ); + } + // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = @@ -150,19 +226,19 @@ export async function createAlertRule( ...userIds.map((userId) => ({ emailActionId: emailActionRow.emailActionId, userId, - roleId: null, - email: null + roleId: null as string | null, + email: null as string | null })), ...roleIds.map((roleId) => ({ emailActionId: emailActionRow.emailActionId, - userId: null, + userId: null as string | null, roleId, - email: null + email: null as string | null })), ...emails.map((email) => ({ emailActionId: emailActionRow.emailActionId, - userId: null, - roleId: null, + userId: null as string | null, + roleId: null as string | null, email })) ]; diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 72c6e1df5..9a19c70be 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -44,13 +46,13 @@ export type GetAlertRuleResponse = { | "site_offline" | "health_check_healthy" | "health_check_not_healthy"; - siteId: number | null; - healthCheckId: number | null; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; createdAt: number; updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -110,6 +112,18 @@ export async function getAlertRule( ); } + // Fetch site associations + const siteRows = await db + .select() + .from(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + // Fetch health check associations + const healthCheckRows = await db + .select() + .from(alertHealthChecks) + .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + // Resolve the single email action row for this rule, then collect all // recipients into a flat list. The emailAction pivot row is an internal // implementation detail and is not surfaced to callers. @@ -138,6 +152,7 @@ export async function getAlertRule( })); } + // Fetch webhook actions const webhooks = await db .select() .from(alertWebhookActions) @@ -149,13 +164,13 @@ export async function getAlertRule( orgId: rule.orgId, name: rule.name, eventType: rule.eventType, - siteId: rule.siteId ?? null, - healthCheckId: rule.healthCheckId ?? null, enabled: rule.enabled, cooldownSeconds: rule.cooldownSeconds, lastTriggeredAt: rule.lastTriggeredAt ?? null, createdAt: rule.createdAt, updatedAt: rule.updatedAt, + siteIds: siteRows.map((r) => r.siteId), + healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, webhookActions: webhooks.map((w) => ({ webhookActionId: w.webhookActionId, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index faf164d73..c0729e75b 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025-2026 Fossorial, Inc. + * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -14,14 +14,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules } from "@server/db"; +import { alertRules, alertSites, alertHealthChecks } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -48,13 +48,13 @@ export type ListAlertRulesResponse = { orgId: string; name: string; eventType: string; - siteId: number | null; - healthCheckId: number | null; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; createdAt: number; updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; }[]; pagination: { total: number; @@ -116,9 +116,59 @@ export async function listAlertRules( .from(alertRules) .where(eq(alertRules.orgId, orgId)); + // Batch-fetch site and health-check associations for all returned rules + // in two queries rather than N+1 individual lookups. + const ruleIds = list.map((r) => r.alertRuleId); + + const siteRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertSites) + .where(inArray(alertSites.alertRuleId, ruleIds)) + : []; + + const healthCheckRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertHealthChecks) + .where( + inArray(alertHealthChecks.alertRuleId, ruleIds) + ) + : []; + + // Index by alertRuleId for O(1) lookup when building the response + const sitesByRule = new Map(); + for (const row of siteRows) { + const existing = sitesByRule.get(row.alertRuleId) ?? []; + existing.push(row.siteId); + sitesByRule.set(row.alertRuleId, existing); + } + + const healthChecksByRule = new Map(); + for (const row of healthCheckRows) { + const existing = healthChecksByRule.get(row.alertRuleId) ?? []; + existing.push(row.healthCheckId); + healthChecksByRule.set(row.alertRuleId, existing); + } + return response(res, { data: { - alertRules: list, + alertRules: list.map((rule) => ({ + alertRuleId: rule.alertRuleId, + orgId: rule.orgId, + name: rule.name, + eventType: rule.eventType, + enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds, + lastTriggeredAt: rule.lastTriggeredAt ?? null, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + siteIds: sitesByRule.get(rule.alertRuleId) ?? [], + healthCheckIds: + healthChecksByRule.get(rule.alertRuleId) ?? [] + })), pagination: { total: count, limit, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 05116d3b8..6c7bc14d7 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -28,6 +30,12 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; +const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_not_healthy" +] as const; + const paramsSchema = z .object({ orgId: z.string().nonempty(), @@ -41,28 +49,56 @@ const webhookActionSchema = z.strictObject({ enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ - // Alert rule fields - all optional for partial updates - name: z.string().nonempty().optional(), - eventType: z - .enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" - ]) - .optional(), - siteId: z.number().int().nullable().optional(), - healthCheckId: z.number().int().nullable().optional(), - enabled: z.boolean().optional(), - cooldownSeconds: z.number().int().nonnegative().optional(), - // Recipient arrays - if any are provided the full recipient set is replaced - userIds: z.array(z.string().nonempty()).optional(), - roleIds: z.array(z.string().nonempty()).optional(), - emails: z.array(z.string().email()).optional(), - // Webhook actions - if provided the full webhook set is replaced - webhookActions: z.array(webhookActionSchema).optional() -}); +const bodySchema = z + .strictObject({ + // Alert rule fields - all optional for partial updates + name: z.string().nonempty().optional(), + eventType: z + .enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]) + .optional(), + enabled: z.boolean().optional(), + cooldownSeconds: z.number().int().nonnegative().optional(), + // Source join tables - if provided the full set is replaced + siteIds: z.array(z.number().int().positive()).optional(), + healthCheckIds: z.array(z.number().int().positive()).optional(), + // Recipient arrays - if any are provided the full recipient set is replaced + userIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.string().nonempty()).optional(), + emails: z.array(z.string().email()).optional(), + // Webhook actions - if provided the full webhook set is replaced + webhookActions: z.array(webhookActionSchema).optional() + }) + .superRefine((val, ctx) => { + if (!val.eventType) return; + + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + }); export type UpdateAlertRuleResponse = { alertRuleId: number; @@ -133,10 +169,10 @@ export async function updateAlertRule( const { name, eventType, - siteId, - healthCheckId, enabled, cooldownSeconds, + siteIds, + healthCheckIds, userIds, roleIds, emails, @@ -150,10 +186,9 @@ export async function updateAlertRule( if (name !== undefined) updateData.name = name; if (eventType !== undefined) updateData.eventType = eventType; - if (siteId !== undefined) updateData.siteId = siteId; - if (healthCheckId !== undefined) updateData.healthCheckId = healthCheckId; if (enabled !== undefined) updateData.enabled = enabled; - if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (cooldownSeconds !== undefined) + updateData.cooldownSeconds = cooldownSeconds; await db .update(alertRules) @@ -165,6 +200,38 @@ export async function updateAlertRule( ) ); + // --- Full-replace site associations if siteIds was provided --- + if (siteIds !== undefined) { + await db + .delete(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + if (siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId, + siteId + })) + ); + } + } + + // --- Full-replace health check associations if healthCheckIds was provided --- + if (healthCheckIds !== undefined) { + await db + .delete(alertHealthChecks) + .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + + if (healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId, + healthCheckId + })) + ); + } + } + // --- Full-replace recipients if any recipient array was provided --- const recipientsProvided = userIds !== undefined || @@ -172,7 +239,6 @@ export async function updateAlertRule( emails !== undefined; if (recipientsProvided) { - // Build the flat list of recipient rows to insert const newRecipients = [ ...(userIds ?? []).map((userId) => ({ userId, @@ -191,14 +257,12 @@ export async function updateAlertRule( })) ]; - // Find or create the single emailAction row for this rule const [existingEmailAction] = await db .select() .from(alertEmailActions) .where(eq(alertEmailActions.alertRuleId, alertRuleId)); if (existingEmailAction) { - // Delete all current recipients then re-insert await db .delete(alertEmailRecipients) .where( @@ -217,7 +281,6 @@ export async function updateAlertRule( ); } } else if (newRecipients.length > 0) { - // No emailAction exists yet - create one then insert recipients const [emailActionRow] = await db .insert(alertEmailActions) .values({ alertRuleId, enabled: true }) From 5e505224d0a861a023a1da49eeb5b8559c027958 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 15:26:27 -0700 Subject: [PATCH 063/176] Basic ui is working --- server/auth/actions.ts | 1 + server/routers/external.ts | 7 + server/routers/resource/index.ts | 1 + server/routers/resource/listHealthChecks.ts | 138 ++++++++ .../settings/alerting/[ruleId]/page.tsx | 58 +++- .../[orgId]/settings/alerting/create/page.tsx | 24 +- src/components/AlertingRulesTable.tsx | 165 +++++---- .../alert-rule-editor/AlertRuleFields.tsx | 144 ++++---- .../AlertRuleGraphEditor.tsx | 68 ++-- src/lib/alertRuleForm.ts | 312 ++++++++++++------ src/lib/queries.ts | 34 ++ 11 files changed, 634 insertions(+), 318 deletions(-) create mode 100644 server/routers/resource/listHealthChecks.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 40777676c..f192459cc 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -145,6 +145,7 @@ export enum ActionsEnum { updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", listEventStreamingDestinations = "listEventStreamingDestinations", + listHealthChecks = "listHealthChecks", createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", diff --git a/server/routers/external.ts b/server/routers/external.ts index d7729bca5..484db4344 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,6 +427,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/health-checks", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listHealthChecks), + resource.listHealthChecks +); + authenticated.get( "/org/:orgId/resource-names", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 12e98a70d..2b379a7d5 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,3 +32,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./listHealthChecks"; diff --git a/server/routers/resource/listHealthChecks.ts b/server/routers/resource/listHealthChecks.ts new file mode 100644 index 000000000..698f35052 --- /dev/null +++ b/server/routers/resource/listHealthChecks.ts @@ -0,0 +1,138 @@ +import { db, targetHealthCheck, targets, resources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, sql, inArray } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const listHealthChecksParamsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listHealthChecksSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export type ListHealthChecksResponse = { + healthChecks: { + targetHealthCheckId: number; + resourceId: number; + resourceName: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/health-checks", + description: "List health checks for all resources in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.PublicResource], + request: { + params: listHealthChecksParamsSchema, + query: listHealthChecksSchema + }, + responses: {} +}); + +export async function listHealthChecks( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listHealthChecksSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listHealthChecksParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const list = await db + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceId: resources.resourceId, + resourceName: resources.name, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth + }) + .from(targetHealthCheck) + .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .where(eq(resources.orgId, orgId)) + .orderBy(sql`${resources.name} ASC`) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(targetHealthCheck) + .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .where(eq(resources.orgId, orgId)); + + return response(res, { + data: { + healthChecks: list.map((row) => ({ + targetHealthCheckId: row.targetHealthCheckId, + resourceId: row.resourceId, + resourceName: row.resourceName, + hcEnabled: row.hcEnabled, + hcHealth: (row.hcHealth ?? "unknown") as + | "unknown" + | "healthy" + | "unhealthy" + })), + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Health checks retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index b9379f7cc..c9ef938d5 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -1,33 +1,60 @@ "use client"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; -import { ruleToFormValues } from "@app/lib/alertRuleForm"; -import type { AlertRule } from "@app/lib/alertRulesLocalStorage"; -import { getRule } from "@app/lib/alertRulesLocalStorage"; +import { apiResponseToFormValues } from "@app/lib/alertRuleForm"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; +import type { AxiosResponse } from "axios"; +import type { GetAlertRuleResponse } from "@server/private/routers/alertRule"; +import type { AlertRuleFormValues } from "@app/lib/alertRuleForm"; export default function EditAlertRulePage() { const t = useTranslations(); const params = useParams(); const router = useRouter(); const orgId = params.orgId as string; - const ruleId = params.ruleId as string; - const [rule, setRule] = useState(undefined); + const ruleIdParam = params.ruleId as string; + const alertRuleId = parseInt(ruleIdParam, 10); + + const api = createApiClient(useEnvContext()); + + const [formValues, setFormValues] = useState(undefined); useEffect(() => { - const r = getRule(orgId, ruleId); - setRule(r ?? null); - }, [orgId, ruleId]); + if (isNaN(alertRuleId)) { + router.replace(`/${orgId}/settings/alerting`); + return; + } + + api.get>( + `/org/${orgId}/alert-rule/${alertRuleId}` + ) + .then((res) => { + const rule = res.data.data; + setFormValues(apiResponseToFormValues(rule)); + }) + .catch((e) => { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + setFormValues(null); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId, alertRuleId]); useEffect(() => { - if (rule === null) { + if (formValues === null) { router.replace(`/${orgId}/settings/alerting`); } - }, [rule, orgId, router]); + }, [formValues, orgId, router]); - if (rule === undefined) { + if (formValues === undefined) { return (
{t("loading")} @@ -35,17 +62,16 @@ export default function EditAlertRulePage() { ); } - if (rule === null) { + if (formValues === null) { return null; } return ( ); diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx index 24c0d2ffe..fc5c51660 100644 --- a/src/app/[orgId]/settings/alerting/create/page.tsx +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -2,39 +2,17 @@ import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; import { defaultFormValues } from "@app/lib/alertRuleForm"; -import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage"; import { useParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; export default function NewAlertRulePage() { - const t = useTranslations(); const params = useParams(); const orgId = params.orgId as string; - const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>( - null - ); - - useEffect(() => { - setMeta({ id: newRuleId(), createdAt: isoNow() }); - }, []); - - if (!meta) { - return ( -
- {t("loading")} -
- ); - } return ( ); -} +} \ No newline at end of file diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 97e1d1755..a28f45563 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -11,123 +11,131 @@ import { } from "@app/components/ui/dropdown-menu"; import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; -import { - type AlertRule, - deleteRule, - isoNow, - loadRules, - upsertRule -} from "@app/lib/alertRulesLocalStorage"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries } from "@app/lib/queries"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import moment from "moment"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; -import { Badge } from "@app/components/ui/badge"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; type AlertingRulesTableProps = { orgId: string; }; -function ruleHref(orgId: string, ruleId: string) { +type AlertRuleRow = { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; +}; + +function ruleHref(orgId: string, ruleId: number) { return `/${orgId}/settings/alerting/${ruleId}`; } function sourceSummary( - rule: AlertRule, + rule: AlertRuleRow, t: (k: string, o?: Record) => string ) { - if (rule.source.type === "site") { - return t("alertingSummarySites", { - count: rule.source.siteIds.length - }); + if ( + rule.eventType === "site_online" || + rule.eventType === "site_offline" + ) { + return t("alertingSummarySites", { count: rule.siteIds.length }); } return t("alertingSummaryHealthChecks", { - count: rule.source.targetIds.length + count: rule.healthCheckIds.length }); } -function triggerLabel(rule: AlertRule, t: (k: string) => string) { - switch (rule.trigger) { +function triggerLabel( + rule: AlertRuleRow, + t: (k: string) => string +) { + switch (rule.eventType) { case "site_online": return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); - case "health_check_unhealthy": + case "health_check_not_healthy": return t("alertingTriggerHcUnhealthy"); default: - return rule.trigger; + return rule.eventType; } } -function actionBadges(rule: AlertRule, t: (k: string) => string) { - return rule.actions.map((a, i) => { - if (a.type === "notify") { - return ( - - {t("alertingActionNotify")} - - ); - } - if (a.type === "sms") { - return ( - - {t("alertingActionSms")} - - ); - } - return ( - - {t("alertingActionWebhook")} - - ); - }); -} - export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); - const [rows, setRows] = useState([]); + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + const [deleteOpen, setDeleteOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [isRefreshing, setIsRefreshing] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); - const refreshFromStorage = useCallback(() => { - setRows(loadRules(orgId)); - }, [orgId]); + const { + data: rows = [], + isLoading, + refetch, + isRefetching + } = useQuery(orgQueries.alertRules({ orgId })); - useEffect(() => { - refreshFromStorage(); - }, [refreshFromStorage]); + const invalidate = () => + queryClient.invalidateQueries(orgQueries.alertRules({ orgId })); - const refreshData = async () => { - setIsRefreshing(true); + const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { + setTogglingId(rule.alertRuleId); try { - await new Promise((r) => setTimeout(r, 200)); - refreshFromStorage(); + await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { + enabled + }); + await invalidate(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); } finally { - setIsRefreshing(false); + setTogglingId(null); } }; - const setEnabled = (rule: AlertRule, enabled: boolean) => { - upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() }); - refreshFromStorage(); - }; - const confirmDelete = async () => { if (!selected) return; - deleteRule(orgId, selected.id); - refreshFromStorage(); - setDeleteOpen(false); - setSelected(null); - toast({ title: t("alertingRuleDeleted") }); + try { + await api.delete( + `/org/${orgId}/alert-rule/${selected.alertRuleId}` + ); + await invalidate(); + toast({ title: t("alertingRuleDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } }; - const columns: ExtendedColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, @@ -163,18 +171,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { ), cell: ({ row }) => {triggerLabel(row.original, t)} }, - { - id: "actionsCol", - friendlyName: t("alertingColumnActions"), - header: () => ( - {t("alertingColumnActions")} - ), - cell: ({ row }) => ( -
- {actionBadges(row.original, t)} -
- ) - }, { accessorKey: "enabled", friendlyName: t("alertingColumnEnabled"), @@ -186,6 +182,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { return ( setEnabled(r, v)} /> ); @@ -230,7 +227,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { @@ -270,8 +267,8 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={() => refetch()} + isRefreshing={isRefetching || isLoading} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 5a2e42393..ec57ae065 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -172,9 +172,7 @@ function SiteMultiSelect({ ); } -const ALERT_RESOURCES_PAGE_SIZE = 10; - -function ResourceTenMultiSelect({ +function HealthCheckMultiSelect({ orgId, value, onChange @@ -185,58 +183,46 @@ function ResourceTenMultiSelect({ }) { const t = useTranslations(); const [open, setOpen] = useState(false); - const { data: resources = [] } = useQuery( - orgQueries.resources({ - orgId, - perPage: ALERT_RESOURCES_PAGE_SIZE - }) + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: healthChecks = [] } = useQuery( + orgQueries.healthChecks({ orgId }) ); - const rows = useMemo(() => { - const out: { - resourceId: number; - name: string; - targetIds: number[]; - }[] = []; - for (const r of resources) { - const targetIds = r.targets.map((x) => x.targetId); - if (targetIds.length > 0) { - out.push({ - resourceId: r.resourceId, - name: r.name, - targetIds - }); - } + + const shown = useMemo(() => { + const query = debounced.trim().toLowerCase(); + const base = query + ? healthChecks.filter((hc) => + hc.resourceName.toLowerCase().includes(query) + ) + : healthChecks; + // Always keep already-selected items visible even if they fall outside the search + if (query && value.length > 0) { + const selectedNotInBase = healthChecks.filter( + (hc) => + value.includes(hc.targetHealthCheckId) && + !base.some( + (b) => b.targetHealthCheckId === hc.targetHealthCheckId + ) + ); + return [...selectedNotInBase, ...base]; } - return out; - }, [resources]); + return base; + }, [healthChecks, debounced, value]); - const selectedResourceCount = useMemo( - () => - rows.filter( - (row) => - row.targetIds.length > 0 && - row.targetIds.every((id) => value.includes(id)) - ).length, - [rows, value] - ); - - const toggle = (targetIds: number[]) => { - const allOn = - targetIds.length > 0 && - targetIds.every((id) => value.includes(id)); - if (allOn) { - onChange(value.filter((id) => !targetIds.includes(id))); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); } else { - onChange([...new Set([...value, ...targetIds])]); + onChange([...value, id]); } }; const summary = - selectedResourceCount === 0 - ? t("alertingSelectResources") - : t("alertingResourcesSelected", { - count: selectedResourceCount - }); + value.length === 0 + ? t("alertingSelectHealthChecks") + : t("alertingHealthChecksSelected", { count: value.length }); return ( @@ -255,38 +241,42 @@ function ResourceTenMultiSelect({ className="w-[var(--radix-popover-trigger-width)] p-0" align="start" > -
- {rows.length === 0 ? ( -

- {t("alertingResourcesEmpty")} -

- ) : ( - rows.map((row) => { - const checked = - row.targetIds.length > 0 && - row.targetIds.every((id) => - value.includes(id) - ); - return ( - - ); - }) - )} -
+ + ))} + + +
); @@ -909,11 +899,13 @@ export function AlertRuleSourceFields({ ) : ( ( - {t("alertingPickResources")} - + {t("alertingPickHealthChecks")} + + 0 - : v.targetIds.length > 0; + : v.healthCheckIds.length > 0; } return Boolean(v.trigger); } @@ -300,8 +303,7 @@ function buildNodeData( type AlertRuleGraphEditorProps = { orgId: string; - ruleId: string; - createdAt: string; + alertRuleId?: number; initialValues: AlertRuleFormValues; isNew: boolean; }; @@ -310,13 +312,14 @@ const FORM_ID = "alert-rule-graph-form"; export default function AlertRuleGraphEditor({ orgId, - ruleId, - createdAt, + alertRuleId, initialValues, isNew }: AlertRuleGraphEditorProps) { const t = useTranslations(); const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isSaving, setIsSaving] = useState(false); const schema = useMemo(() => buildFormSchema(t), [t]); const form = useForm({ resolver: zodResolver(schema), @@ -335,8 +338,8 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "sourceType" }) ?? "site"; const wSiteIds = useWatch({ control: form.control, name: "siteIds" }) ?? []; - const wTargetIds = - useWatch({ control: form.control, name: "targetIds" }) ?? []; + const wHealthCheckIds = + useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? "site_offline"; @@ -349,7 +352,7 @@ export default function AlertRuleGraphEditor({ enabled: wEnabled, sourceType: wSourceType, siteIds: wSiteIds, - targetIds: wTargetIds, + healthCheckIds: wHealthCheckIds, trigger: wTrigger, actions: wActions }), @@ -358,7 +361,7 @@ export default function AlertRuleGraphEditor({ wEnabled, wSourceType, wSiteIds, - wTargetIds, + wHealthCheckIds, wTrigger, wActions ] @@ -472,7 +475,7 @@ export default function AlertRuleGraphEditor({ if (!m) { return; } - const i = Number(m[1], 10); + const i = parseInt(m[1], 10); if (i >= wActions.length) { setSelectedStep( wActions.length > 0 @@ -486,12 +489,33 @@ export default function AlertRuleGraphEditor({ setSelectedStep(node.id); }, []); - const onSubmit = form.handleSubmit((values) => { - const next = formValuesToRule(values, ruleId, createdAt); - upsertRule(orgId, next); - toast({ title: t("alertingRuleSaved") }); - if (isNew) { - router.replace(`/${orgId}/settings/alerting/${ruleId}`); + const onSubmit = form.handleSubmit(async (values) => { + setIsSaving(true); + try { + const payload = formValuesToApiPayload(values); + if (isNew) { + const res = await api.put< + AxiosResponse + >(`/org/${orgId}/alert-rule`, payload); + toast({ title: t("alertingRuleSaved") }); + router.replace( + `/${orgId}/settings/alerting/${res.data.data.alertRuleId}` + ); + } else { + await api.post( + `/org/${orgId}/alert-rule/${alertRuleId}`, + payload + ); + toast({ title: t("alertingRuleSaved") }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setIsSaving(false); } }); @@ -565,8 +589,8 @@ export default function AlertRuleGraphEditor({ )} /> -
diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index c219f316b..b865a09ed 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -1,17 +1,27 @@ import type { Tag } from "@app/components/tags/tag-input"; -import { - type AlertRule, - type AlertTrigger, - isoNow, - type AlertAction as StoredAlertAction -} from "@app/lib/alertRulesLocalStorage"; import { z } from "zod"; +// --------------------------------------------------------------------------- +// Shared primitive schemas +// --------------------------------------------------------------------------- + export const tagSchema = z.object({ id: z.string(), text: z.string() }); +// --------------------------------------------------------------------------- +// Form-layer types +// NOTE: the form uses "health_check_unhealthy" internally; it maps to the +// backend's "health_check_not_healthy" at the API boundary. +// --------------------------------------------------------------------------- + +export type AlertTrigger = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_unhealthy"; + export type AlertRuleFormAction = | { type: "notify"; @@ -33,11 +43,86 @@ export type AlertRuleFormValues = { enabled: boolean; sourceType: "site" | "health_check"; siteIds: number[]; - targetIds: number[]; + healthCheckIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; }; +// --------------------------------------------------------------------------- +// API boundary types +// --------------------------------------------------------------------------- + +export type AlertRuleApiPayload = { + name: string; + eventType: + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + enabled: boolean; + siteIds: number[]; + healthCheckIds: number[]; + userIds: string[]; + roleIds: string[]; + emails: string[]; + webhookActions: { + webhookUrl: string; + enabled: boolean; + config?: string; + }[]; +}; + +// Shape of what GET /org/:orgId/alert-rule/:alertRuleId returns +export type AlertRuleApiResponse = { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; + webhookActions: { + webhookActionId: number; + webhookUrl: string; + enabled: boolean; + lastSentAt: number | null; + }[]; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function triggerToEventType( + trigger: AlertTrigger +): AlertRuleApiPayload["eventType"] { + if (trigger === "health_check_unhealthy") { + return "health_check_not_healthy"; + } + return trigger as AlertRuleApiPayload["eventType"]; +} + +function eventTypeToTrigger(eventType: string): AlertTrigger { + if (eventType === "health_check_not_healthy") { + return "health_check_unhealthy"; + } + return eventType as AlertTrigger; +} + +// --------------------------------------------------------------------------- +// Zod form schema (for react-hook-form validation) +// --------------------------------------------------------------------------- + export function buildFormSchema(t: (k: string) => string) { return z .object({ @@ -45,7 +130,7 @@ export function buildFormSchema(t: (k: string) => string) { enabled: z.boolean(), sourceType: z.enum(["site", "health_check"]), siteIds: z.array(z.number()), - targetIds: z.array(z.number()), + healthCheckIds: z.array(z.number()), trigger: z.enum([ "site_online", "site_offline", @@ -97,18 +182,15 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "health_check" && - val.targetIds.length === 0 + val.healthCheckIds.length === 0 ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("alertingErrorPickHealthChecks"), - path: ["targetIds"] + path: ["healthCheckIds"] }); } - const siteTriggers: AlertTrigger[] = [ - "site_online", - "site_offline" - ]; + const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; const hcTriggers: AlertTrigger[] = [ "health_check_healthy", "health_check_unhealthy" @@ -156,7 +238,6 @@ export function buildFormSchema(t: (k: string) => string) { } if (a.type === "webhook") { try { - // eslint-disable-next-line no-new new URL(a.url.trim()); } catch { ctx.addIssue({ @@ -170,13 +251,17 @@ export function buildFormSchema(t: (k: string) => string) { }); } +// --------------------------------------------------------------------------- +// defaultFormValues +// --------------------------------------------------------------------------- + export function defaultFormValues(): AlertRuleFormValues { return { name: "", enabled: true, sourceType: "site", siteIds: [], - targetIds: [], + healthCheckIds: [], trigger: "site_offline", actions: [ { @@ -189,95 +274,128 @@ export function defaultFormValues(): AlertRuleFormValues { }; } -export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { - const actions: AlertRuleFormAction[] = rule.actions.map( - (a: StoredAlertAction) => { - if (a.type === "notify") { - return { - type: "notify", - userIds: a.userIds.map(String), - roleIds: [...a.roleIds], - emailTags: a.emails.map((e) => ({ id: e, text: e })) - }; - } - if (a.type === "sms") { - return { - type: "sms", - phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) - }; - } - return { - type: "webhook", - url: a.url, - method: a.method, - headers: - a.headers.length > 0 - ? a.headers.map((h) => ({ ...h })) - : [{ key: "", value: "" }], - secret: a.secret ?? "" - }; - } - ); +// --------------------------------------------------------------------------- +// API response → form values +// --------------------------------------------------------------------------- + +export function apiResponseToFormValues( + rule: AlertRuleApiResponse +): AlertRuleFormValues { + const trigger = eventTypeToTrigger(rule.eventType); + const sourceType = rule.eventType.startsWith("site_") + ? "site" + : "health_check"; + + // Collect notify recipients into a single notify action (if any) + const userIds = rule.recipients + .filter((r) => r.userId != null) + .map((r) => r.userId!); + const roleIds = rule.recipients + .filter((r) => r.roleId != null) + .map((r) => parseInt(r.roleId!, 10)) + .filter((n) => !isNaN(n)); + const emailTags = rule.recipients + .filter((r) => r.email != null) + .map((r) => ({ id: r.email!, text: r.email! })); + + const actions: AlertRuleFormAction[] = []; + + if (userIds.length > 0 || roleIds.length > 0 || emailTags.length > 0) { + actions.push({ type: "notify", userIds, roleIds, emailTags }); + } + + // Each webhook action becomes its own form webhook action + for (const w of rule.webhookActions) { + actions.push({ + type: "webhook", + url: w.webhookUrl, + method: "POST", + headers: [{ key: "", value: "" }], + secret: "" + }); + } + + // Always ensure at least one action so the form is valid + if (actions.length === 0) { + actions.push({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } + return { name: rule.name, enabled: rule.enabled, - sourceType: rule.source.type, - siteIds: - rule.source.type === "site" ? [...rule.source.siteIds] : [], - targetIds: - rule.source.type === "health_check" - ? [...rule.source.targetIds] - : [], - trigger: rule.trigger, + sourceType, + siteIds: rule.siteIds, + healthCheckIds: rule.healthCheckIds, + trigger, actions }; } -export function formValuesToRule( - v: AlertRuleFormValues, - id: string, - createdAt: string -): AlertRule { - const source = - v.sourceType === "site" - ? { type: "site" as const, siteIds: v.siteIds } - : { - type: "health_check" as const, - targetIds: v.targetIds - }; - const actions = v.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify" as const, - userIds: a.userIds, - roleIds: a.roleIds, - emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean) - }; - } - if (a.type === "sms") { - return { - type: "sms" as const, - phoneNumbers: a.phoneTags - .map((tg) => tg.text.trim()) +// --------------------------------------------------------------------------- +// Form values → API payload +// --------------------------------------------------------------------------- + +export function formValuesToApiPayload( + values: AlertRuleFormValues +): AlertRuleApiPayload { + const eventType = triggerToEventType(values.trigger); + + // Collect all notify-type actions and merge their recipient lists + const allUserIds: string[] = []; + const allRoleIds: string[] = []; + const allEmails: string[] = []; + + const webhookActions: AlertRuleApiPayload["webhookActions"] = []; + + for (const action of values.actions) { + if (action.type === "notify") { + allUserIds.push(...action.userIds); + allRoleIds.push(...action.roleIds.map(String)); + allEmails.push( + ...action.emailTags + .map((t) => t.text.trim()) .filter(Boolean) - }; + ); + } else if (action.type === "webhook") { + webhookActions.push({ + webhookUrl: action.url.trim(), + enabled: true, + // Encode any headers / secret as config JSON if present + ...(action.secret.trim() || + action.headers.some((h) => h.key.trim()) + ? { + config: JSON.stringify({ + secret: action.secret.trim() || undefined, + headers: action.headers.filter( + (h) => h.key.trim() + ) + }) + } + : {}) + }); } - return { - type: "webhook" as const, - url: a.url.trim(), - method: a.method, - headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), - secret: a.secret.trim() || undefined - }; - }); + // sms is not supported by the backend; silently skip + } + + // Deduplicate + const uniqueUserIds = [...new Set(allUserIds)]; + const uniqueRoleIds = [...new Set(allRoleIds)]; + const uniqueEmails = [...new Set(allEmails)]; + return { - id, - name: v.name.trim(), - enabled: v.enabled, - createdAt, - updatedAt: isoNow(), - source, - trigger: v.trigger, - actions + name: values.name.trim(), + eventType, + enabled: values.enabled, + siteIds: values.siteIds, + healthCheckIds: values.healthCheckIds, + userIds: uniqueUserIds, + roleIds: uniqueRoleIds, + emails: uniqueEmails, + webhookActions }; -} +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index d7822d6cf..17948d63a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,9 +4,11 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, + ListHealthChecksResponse, ListResourceNamesResponse, ListResourcesResponse } from "@server/routers/resource"; +import type { ListAlertRulesResponse } from "@server/private/routers/alertRule"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { @@ -230,6 +232,38 @@ export const orgQueries = { return res.data.data.resources; } + }), + + healthChecks: ({ + orgId, + perPage = 10_000 + }: { + orgId: string; + perPage?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "HEALTH_CHECKS", { perPage }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + limit: perPage.toString(), + offset: "0" + }); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/health-checks?${sp.toString()}`, { signal }); + return res.data.data.healthChecks; + } + }), + + alertRules: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "ALERT_RULES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/alert-rules`, { signal }); + return res.data.data.alertRules; + } }) }; From 55595ec0422af398717c5f4e7a39ef40cb130244 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 15:51:41 -0700 Subject: [PATCH 064/176] Trying to use more consistant components --- messages/en-US.json | 7 +- .../alert-rule-editor/AlertRuleFields.tsx | 426 ++++++------------ .../AlertRuleGraphEditor.tsx | 41 +- src/lib/alertRuleForm.ts | 54 +-- src/lib/alertRulesLocalStorage.ts | 4 - 5 files changed, 180 insertions(+), 352 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index df252ef4a..db140de0a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1382,16 +1382,13 @@ "alertingTriggerHcUnhealthy": "Health check unhealthy", "alertingSectionActions": "Actions", "alertingAddAction": "Add action", - "alertingActionNotify": "Notify", - "alertingActionSms": "SMS", + "alertingActionNotify": "Email", "alertingActionWebhook": "Webhook", "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", "alertingNotifyEmails": "Email addresses", "alertingEmailPlaceholder": "Add email and press Enter", - "alertingSmsNumbers": "Phone numbers", - "alertingSmsPlaceholder": "Add number and press Enter", "alertingWebhookMethod": "HTTP method", "alertingWebhookSecret": "Signing secret (optional)", "alertingWebhookSecretPlaceholder": "HMAC secret", @@ -1416,8 +1413,6 @@ "alertingErrorTriggerSite": "Choose a site trigger", "alertingErrorTriggerHealth": "Choose a health check trigger", "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", - "alertingErrorSmsPhones": "Add at least one phone number", - "alertingErrorWebhookUrl": "Enter a valid webhook URL", "alertingConfigureSource": "Configure Source", "alertingConfigureTrigger": "Configure Trigger", "alertingConfigureActions": "Configure Actions", diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index ec57ae065..73b2302e0 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -30,7 +30,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { TagInput } from "@app/components/tags/tag-input"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { type AlertRuleFormAction, @@ -46,9 +46,9 @@ import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; export function DropdownAddAction({ - onPick + onAdd }: { - onPick: (type: "notify" | "sms" | "webhook") => void; + onAdd: (type: AlertRuleFormAction["type"]) => void; }) { const t = useTranslations(); const [open, setOpen] = useState(false); @@ -58,44 +58,32 @@ export function DropdownAddAction({ - -
- - - -
+ + + + + { + onAdd("notify"); + setOpen(false); + }} + > + {t("alertingActionNotify")} + + { + onAdd("webhook"); + setOpen(false); + }} + > + {t("alertingActionWebhook")} + + + + ); @@ -325,15 +313,10 @@ export function ActionBlock({ if (nt === "notify") { form.setValue(`actions.${index}`, { type: "notify", - userIds: [], - roleIds: [], + userTags: [], + roleTags: [], emailTags: [] }); - } else if (nt === "sms") { - form.setValue(`actions.${index}`, { - type: "sms", - phoneTags: [] - }); } else { form.setValue(`actions.${index}`, { type: "webhook", @@ -354,9 +337,6 @@ export function ActionBlock({ {t("alertingActionNotify")} - - {t("alertingActionSms")} - {t("alertingActionWebhook")} @@ -373,9 +353,6 @@ export function ActionBlock({ form={form} /> )} - {type === "sms" && ( - - )} {type === "webhook" && ( ; }) { const t = useTranslations(); + const [emailActiveIdx, setEmailActiveIdx] = useState(null); - const userIds = form.watch(`actions.${index}.userIds`) ?? []; - const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; - const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + + const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId })); + const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId })); + + const allUsers = useMemo( + () => + orgUsers.map((u) => ({ + id: String(u.id), + text: getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }) + })), + [orgUsers] + ); + + const allRoles = useMemo( + () => + orgRoles + .map((r) => ({ id: String(r.roleId), text: r.name })) + .filter((r) => r.text !== "Admin"), + [orgRoles] + ); + + const userTags = (form.watch(`actions.${index}.userTags`) ?? []) as Tag[]; + const roleTags = (form.watch(`actions.${index}.roleTags`) ?? []) as Tag[]; + const emailTags = (form.watch(`actions.${index}.emailTags`) ?? []) as Tag[]; return (
- - {t("alertingNotifyUsers")} - - form.setValue(`actions.${index}.userIds`, ids) - } - /> - - - {t("alertingNotifyRoles")} - - form.setValue(`actions.${index}.roleIds`, ids) - } - /> - + ( + + {t("alertingNotifyUsers")} + + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + form.setValue( + `actions.${index}.userTags`, + next as Tag[] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> + ( + + {t("alertingNotifyRoles")} + + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + form.setValue( + `actions.${index}.roleTags`, + next as Tag[] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> ( - + render={({ field }) => ( + {t("alertingNotifyEmails")} { const next = @@ -442,12 +502,18 @@ function NotifyActionFields({ : updater; form.setValue( `actions.${index}.emailTags`, - next + next as Tag[] ); }} activeTagIndex={emailActiveIdx} setActiveTagIndex={setEmailActiveIdx} placeholder={t("alertingEmailPlaceholder")} + size="sm" + allowDuplicates={false} + sortTags={true} + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } delimiterList={[",", "Enter"]} /> @@ -459,51 +525,6 @@ function NotifyActionFields({ ); } -function SmsActionFields({ - index, - control, - form -}: { - index: number; - control: Control; - form: UseFormReturn; -}) { - const t = useTranslations(); - const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); - const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; - return ( - ( - - {t("alertingSmsNumbers")} - - { - const next = - typeof updater === "function" - ? updater(phoneTags) - : updater; - form.setValue( - `actions.${index}.phoneTags`, - next - ); - }} - activeTagIndex={phoneActiveIdx} - setActiveTagIndex={setPhoneActiveIdx} - placeholder={t("alertingSmsPlaceholder")} - delimiterList={[",", "Enter"]} - /> - - - - )} - /> - ); -} - function WebhookActionFields({ index, control, @@ -663,160 +684,6 @@ function WebhookHeadersField({ ); } -function UserMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: string[]; - onChange: (v: string[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const [q, setQ] = useState(""); - const [debounced] = useDebounce(q, 150); - const { data: users = [] } = useQuery(orgQueries.users({ orgId })); - const shown = useMemo(() => { - const qq = debounced.trim().toLowerCase(); - if (!qq) return users.slice(0, 200); - return users - .filter((u) => { - const label = getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - }).toLowerCase(); - return ( - label.includes(qq) || - (u.email ?? "").toLowerCase().includes(qq) - ); - }) - .slice(0, 200); - }, [users, debounced]); - const toggle = (id: string) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectUsers") - : t("alertingUsersSelected", { count: value.length }); - return ( - - - - - - - - - {t("noResults")} - - {shown.map((u) => { - const uid = String(u.id); - return ( - toggle(uid)} - className="cursor-pointer" - > - - {getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - })} - - ); - })} - - - - - - ); -} - -function RoleMultiSelect({ - orgId, - value, - onChange -}: { - orgId: string; - value: number[]; - onChange: (v: number[]) => void; -}) { - const t = useTranslations(); - const [open, setOpen] = useState(false); - const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); - const toggle = (id: number) => { - if (value.includes(id)) { - onChange(value.filter((x) => x !== id)); - } else { - onChange([...value, id]); - } - }; - const summary = - value.length === 0 - ? t("alertingSelectRoles") - : t("alertingRolesSelected", { count: value.length }); - return ( - - - - - - - - - {roles.map((r) => ( - toggle(r.roleId)} - className="cursor-pointer" - > - - {r.name} - - ))} - - - - - - ); -} - export function AlertRuleSourceFields({ orgId, control @@ -838,7 +705,8 @@ export function AlertRuleSourceFields({ { + field.onChange(value); + handleFieldChange( + "hcMode", + value + ); + }} + value={field.value} + > + + + + + + + + HTTP + + + TCP + + + + + {t( + "healthCheckModeDescription" + )} + + + + )} + /> + + {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + + {t("healthHostname")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcHostname", + e.target + .value + ); + }} + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target + .value; + field.onChange( + value + ); + handleFieldChange( + "hcPort", + value + ); + }} + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcHostname", + e.target + .value + ); + }} + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target + .value; + field.onChange( + value + ); + handleFieldChange( + "hcPort", + value + ); + }} + /> + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcPath", + e.target + .value + ); + }} + /> + + + + )} + /> +
+ )} + + {/* HTTP Method */} + {watchedMode !== "tcp" && ( ( - {t("healthScheme")} + {t("httpMethod")} @@ -273,143 +565,9 @@ export default function HealthCheckDialog({ )} /> - ( - - - {t("healthHostname")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcHostname", - e.target - .value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthPort")} - - - { - const value = - e.target - .value; - field.onChange( - value - ); - handleFieldChange( - "hcPort", - value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthCheckPath")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcPath", - e.target - .value - ); - }} - /> - - - - )} - /> -
+ )} - {/* HTTP Method */} - ( - - - {t("httpMethod")} - - - - - )} - /> - - {/* Check Interval, Timeout, and Retry Attempts */} + {/* Check Interval, Unhealthy Interval, and Timeout */}
- {/* Expected Response Codes */} - ( - - - {t("expectedResponseCodes")} - - - { - const value = - parseInt( - e.target - .value + {/* Healthy and Unhealthy Thresholds */} +
+ ( + + + {t("healthyThreshold")} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value ); - field.onChange( - value - ); - handleFieldChange( - "hcStatus", - value - ); - }} - /> - - - {t( - "expectedResponseCodesDescription" - )} - - - - )} - /> + handleFieldChange( + "hcHealthyThreshold", + value + ); + }} + /> + + + {t( + "healthyThresholdDescription" + )} + + + + )} + /> - {/*TLS Server Name (SNI)*/} - ( - - - {t("tlsServerName")} - - - { - field.onChange(e); - handleFieldChange( - "hcTlsServerName", - e.target.value - ); - }} - /> - - - {t( - "tlsServerNameDescription" - )} - - - - )} - /> + ( + + + {t("unhealthyThreshold")} + + + { + const value = + parseInt( + e.target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcUnhealthyThreshold", + value + ); + }} + /> + + + {t( + "unhealthyThresholdDescription" + )} + + + + )} + /> +
- {/* Custom Headers */} - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - handleFieldChange( - "hcHeaders", - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Codes */} + ( + + + {t( + "expectedResponseCodes" + )} + + + { + const value = + parseInt( + e + .target + .value + ); + field.onChange( + value + ); + handleFieldChange( + "hcStatus", + value + ); + }} + /> + + + {t( + "expectedResponseCodesDescription" + )} + + + + )} + /> + + {/* TLS Server Name (SNI) */} + ( + + + {t("tlsServerName")} + + + { + field.onChange( + e + ); + handleFieldChange( + "hcTlsServerName", + e.target + .value + ); + }} + /> + + + {t( + "tlsServerNameDescription" + )} + + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + { + field.onChange( + value + ); + handleFieldChange( + "hcHeaders", + value + ); + }} + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} )} @@ -632,4 +889,4 @@ export default function HealthCheckDialog({ ); -} +} \ No newline at end of file From 1d4b2b1da13c29bd3e3036fca5eee02bffb1873d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 16:40:04 -0700 Subject: [PATCH 067/176] seperate out the offline checker logic --- .../handleRemoteExitNodePingMessage.ts | 72 +------ .../private/routers/remoteExitNode/index.ts | 1 + .../routers/remoteExitNode/offlineChecker.ts | 82 ++++++++ server/routers/newt/handleNewtPingMessage.ts | 179 +----------------- server/routers/newt/index.ts | 1 + server/routers/newt/offlineChecker.ts | 176 +++++++++++++++++ server/routers/olm/handleOlmPingMessage.ts | 93 +-------- server/routers/olm/index.ts | 1 + server/routers/olm/offlineChecker.ts | 92 +++++++++ 9 files changed, 362 insertions(+), 335 deletions(-) create mode 100644 server/private/routers/remoteExitNode/offlineChecker.ts create mode 100644 server/routers/newt/offlineChecker.ts create mode 100644 server/routers/olm/offlineChecker.ts diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index cc7578791..c2c710e11 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -11,78 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, exitNodes, sites } from "@server/db"; +import { db, exitNodes } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, RemoteExitNode } from "@server/db"; -import { eq, lt, isNull, and, or, inArray } from "drizzle-orm"; +import { RemoteExitNode } from "@server/db"; +import { eq } from "drizzle-orm"; import logger from "@server/logger"; -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - -/** - * Starts the background interval that checks for clients that haven't pinged recently - * and marks them as offline - */ -export const startRemoteExitNodeOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // Find clients that haven't pinged in the last 2 minutes and mark them as offline - const offlineNodes = await db - .update(exitNodes) - .set({ online: false }) - .where( - and( - eq(exitNodes.online, true), - eq(exitNodes.type, "remoteExitNode"), - or( - lt(exitNodes.lastPing, twoMinutesAgo), - isNull(exitNodes.lastPing) - ) - ) - ) - .returning(); - - if (offlineNodes.length > 0) { - logger.info( - `checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity` - ); - - for (const offlineClient of offlineNodes) { - logger.debug( - `checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})` - ); - } - } - } catch (error) { - logger.error("Error in offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline clients - */ -export const stopRemoteExitNodeOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped offline checker interval"); - } -}; - /** * Handles ping messages from clients and responds with pong */ diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index bfbf98fed..730f6b693 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; +export * from "./offlineChecker"; diff --git a/server/private/routers/remoteExitNode/offlineChecker.ts b/server/private/routers/remoteExitNode/offlineChecker.ts new file mode 100644 index 000000000..7f5e906f8 --- /dev/null +++ b/server/private/routers/remoteExitNode/offlineChecker.ts @@ -0,0 +1,82 @@ +/* + * 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, exitNodes } from "@server/db"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; +import logger from "@server/logger"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + const offlineNodes = await db + .update(exitNodes) + .set({ online: false }) + .where( + and( + eq(exitNodes.online, true), + eq(exitNodes.type, "remoteExitNode"), + or( + lt(exitNodes.lastPing, twoMinutesAgo), + isNull(exitNodes.lastPing) + ) + ) + ) + .returning(); + + if (offlineNodes.length > 0) { + logger.info( + `checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity` + ); + + for (const offlineClient of offlineNodes) { + logger.debug( + `checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})` + ); + } + } + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline clients + */ +export const stopRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +}; diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index e8a1d341e..56b8a2a24 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -1,184 +1,11 @@ -import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; -import { - hasActiveConnections, - getClientConfigVersion -} from "#dynamic/routers/ws"; +import { db, sites } from "@server/db"; +import { getClientConfigVersion } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; -import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; import { recordPing } from "./pingAccumulator"; -import { fireSiteOfflineAlert } from "#dynamic/lib/alerts"; - -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes -const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes - -/** - * Starts the background interval that checks for newt sites that haven't - * pinged recently and marks them as offline. For backward compatibility, - * a site is only marked offline when there is no active WebSocket connection - * either — so older newt versions that don't send pings but remain connected - * continue to be treated as online. - */ -export const startNewtOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // Find all online newt-type sites that haven't pinged recently - // (or have never pinged at all). Join newts to obtain the newtId - // needed for the WebSocket connection check. - const staleSites = await db - .select({ - siteId: sites.siteId, - orgId: sites.orgId, - name: sites.name, - newtId: newts.newtId, - lastPing: sites.lastPing - }) - .from(sites) - .innerJoin(newts, eq(newts.siteId, sites.siteId)) - .where( - and( - eq(sites.online, true), - eq(sites.type, "newt"), - or( - lt(sites.lastPing, twoMinutesAgo), - isNull(sites.lastPing) - ) - ) - ); - - for (const staleSite of staleSites) { - // Backward-compatibility check: if the newt still has an - // active WebSocket connection (older clients that don't send - // pings), keep the site online. - const isConnected = await hasActiveConnections( - staleSite.newtId - ); - if (isConnected) { - logger.debug( - `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` - ); - continue; - } - - logger.info( - `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` - ); - - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, staleSite.siteId)); - - const healthChecksOnSite = await db - .select() - .from(targetHealthCheck) - .innerJoin( - targets, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .innerJoin(sites, eq(sites.siteId, targets.siteId)) - .where(eq(sites.siteId, staleSite.siteId)); - - for (const healthCheck of healthChecksOnSite) { - logger.info( - `Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline` - ); - await db - .update(targetHealthCheck) - .set({ hcHealth: "unknown" }) - .where( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheck.targetHealthCheck - .targetHealthCheckId - ) - ); - } - - await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name); - } - - // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites - // select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update - const allWireguardSites = await db - .select({ - siteId: sites.siteId, - online: sites.online, - lastBandwidthUpdate: sites.lastBandwidthUpdate - }) - .from(sites) - .where( - and( - eq(sites.type, "wireguard"), - not(isNull(sites.lastBandwidthUpdate)) - ) - ); - - const wireguardOfflineThreshold = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000 - ); - - // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline - for (const site of allWireguardSites) { - const lastBandwidthUpdate = - new Date(site.lastBandwidthUpdate!).getTime() / 1000; - if ( - lastBandwidthUpdate < wireguardOfflineThreshold && - site.online - ) { - logger.info( - `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` - ); - - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, site.siteId)); - } else if ( - lastBandwidthUpdate >= wireguardOfflineThreshold && - !site.online - ) { - logger.info( - `Marking wireguard site ${site.siteId} online: recent bandwidth update` - ); - - await db - .update(sites) - .set({ online: true }) - .where(eq(sites.siteId, site.siteId)); - } - } - } catch (error) { - logger.error("Error in newt offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started newt offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline newt sites. - */ -export const stopNewtOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped newt offline checker interval"); - } -}; /** * Handles ping messages from newt clients. diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index fe6998722..368cdf636 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -11,3 +11,4 @@ export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; export * from "./handleRequestLogMessage"; export * from "./registerNewt"; +export * from "./offlineChecker"; diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts new file mode 100644 index 000000000..20e924efa --- /dev/null +++ b/server/routers/newt/offlineChecker.ts @@ -0,0 +1,176 @@ +import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; +import { + hasActiveConnections, +} from "#dynamic/routers/ws"; +import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; +import logger from "@server/logger"; +import { fireSiteOfflineAlert } from "#dynamic/lib/alerts"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes +const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes + +/** + * Starts the background interval that checks for newt sites that haven't + * pinged recently and marks them as offline. For backward compatibility, + * a site is only marked offline when there is no active WebSocket connection + * either — so older newt versions that don't send pings but remain connected + * continue to be treated as online. + */ +export const startNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // Find all online newt-type sites that haven't pinged recently + // (or have never pinged at all). Join newts to obtain the newtId + // needed for the WebSocket connection check. + const staleSites = await db + .select({ + siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name, + newtId: newts.newtId, + lastPing: sites.lastPing + }) + .from(sites) + .innerJoin(newts, eq(newts.siteId, sites.siteId)) + .where( + and( + eq(sites.online, true), + eq(sites.type, "newt"), + or( + lt(sites.lastPing, twoMinutesAgo), + isNull(sites.lastPing) + ) + ) + ); + + for (const staleSite of staleSites) { + // Backward-compatibility check: if the newt still has an + // active WebSocket connection (older clients that don't send + // pings), keep the site online. + const isConnected = await hasActiveConnections( + staleSite.newtId + ); + if (isConnected) { + logger.debug( + `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` + ); + continue; + } + + logger.info( + `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` + ); + + await db + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, staleSite.siteId)); + + const healthChecksOnSite = await db + .select() + .from(targetHealthCheck) + .innerJoin( + targets, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .innerJoin(sites, eq(sites.siteId, targets.siteId)) + .where(eq(sites.siteId, staleSite.siteId)); + + for (const healthCheck of healthChecksOnSite) { + logger.info( + `Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline` + ); + await db + .update(targetHealthCheck) + .set({ hcHealth: "unknown" }) + .where( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheck.targetHealthCheck + .targetHealthCheckId + ) + ); + } + + await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name); + } + + // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites + // select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update + const allWireguardSites = await db + .select({ + siteId: sites.siteId, + online: sites.online, + lastBandwidthUpdate: sites.lastBandwidthUpdate + }) + .from(sites) + .where( + and( + eq(sites.type, "wireguard"), + not(isNull(sites.lastBandwidthUpdate)) + ) + ); + + const wireguardOfflineThreshold = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000 + ); + + // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline + for (const site of allWireguardSites) { + const lastBandwidthUpdate = + new Date(site.lastBandwidthUpdate!).getTime() / 1000; + if ( + lastBandwidthUpdate < wireguardOfflineThreshold && + site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` + ); + + await db + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, site.siteId)); + } else if ( + lastBandwidthUpdate >= wireguardOfflineThreshold && + !site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} online: recent bandwidth update` + ); + + await db + .update(sites) + .set({ online: true }) + .where(eq(sites.siteId, site.siteId)); + } + } + } catch (error) { + logger.error("Error in newt offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started newt offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline newt sites. + */ +export const stopNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped newt offline checker interval"); + } +}; diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 0f520b234..0e18c7f5b 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,104 +1,17 @@ -import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; +import { getClientConfigVersion } from "#dynamic/routers/ws"; import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, olms, Olm } from "@server/db"; -import { eq, lt, isNull, and, or } from "drizzle-orm"; +import { clients, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; import { recordClientPing } from "@server/routers/newt/pingAccumulator"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { sendOlmSyncMessage } from "./sync"; -import { OlmErrorCodes } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - -/** - * Starts the background interval that checks for clients that haven't pinged recently - * and marks them as offline - */ -export const startOlmOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING - - // Find clients that haven't pinged in the last 2 minutes and mark them as offline - const offlineClients = await db - .update(clients) - .set({ online: false }) - .where( - and( - eq(clients.online, true), - or( - lt(clients.lastPing, twoMinutesAgo), - isNull(clients.lastPing) - ) - ) - ) - .returning(); - - for (const offlineClient of offlineClients) { - logger.info( - `Kicking offline olm client ${offlineClient.clientId} due to inactivity` - ); - - if (!offlineClient.olmId) { - logger.warn( - `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` - ); - continue; - } - - // Send a disconnect message to the client if connected - try { - await sendTerminateClient( - offlineClient.clientId, - OlmErrorCodes.TERMINATED_INACTIVITY, - offlineClient.olmId - ); // terminate first - // wait a moment to ensure the message is sent - await new Promise((resolve) => setTimeout(resolve, 1000)); - await disconnectClient(offlineClient.olmId); - } catch (error) { - logger.error( - `Error sending disconnect to offline olm ${offlineClient.clientId}`, - { error } - ); - } - } - } catch (error) { - logger.error("Error in offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline clients - */ -export const stopOlmOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped offline checker interval"); - } -}; - /** * Handles ping messages from clients and responds with pong */ diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 322428572..5c151a8cf 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -12,3 +12,4 @@ export * from "./handleOlmUnRelayMessage"; export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; +export * from "./offlineChecker"; diff --git a/server/routers/olm/offlineChecker.ts b/server/routers/olm/offlineChecker.ts new file mode 100644 index 000000000..7dd06a29c --- /dev/null +++ b/server/routers/olm/offlineChecker.ts @@ -0,0 +1,92 @@ +import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; +import logger from "@server/logger"; +import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startOlmOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + const offlineClients = await db + .update(clients) + .set({ online: false }) + .where( + and( + eq(clients.online, true), + or( + lt(clients.lastPing, twoMinutesAgo), + isNull(clients.lastPing) + ) + ) + ) + .returning(); + + for (const offlineClient of offlineClients) { + logger.info( + `Kicking offline olm client ${offlineClient.clientId} due to inactivity` + ); + + if (!offlineClient.olmId) { + logger.warn( + `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` + ); + continue; + } + + // Send a disconnect message to the client if connected + try { + await sendTerminateClient( + offlineClient.clientId, + OlmErrorCodes.TERMINATED_INACTIVITY, + offlineClient.olmId + ); // terminate first + // wait a moment to ensure the message is sent + await new Promise((resolve) => setTimeout(resolve, 1000)); + await disconnectClient(offlineClient.olmId); + } catch (error) { + logger.error( + `Error sending disconnect to offline olm ${offlineClient.clientId}`, + { error } + ); + } + } + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline clients + */ +export const stopOlmOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +}; From b169a872a754674f13f3279e8f3c3c4c71c86fd2 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 16:40:15 -0700 Subject: [PATCH 068/176] Fix header --- server/private/lib/acmeCertSync.ts | 2 +- server/private/routers/alertRule/createAlertRule.ts | 2 +- server/private/routers/alertRule/getAlertRule.ts | 2 +- server/private/routers/alertRule/listAlertRules.ts | 2 +- server/private/routers/alertRule/updateAlertRule.ts | 2 +- server/private/routers/newt/handleRequestLogMessage.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 1fb609c28..f640a6859 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 014a62956..fa547661b 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 9a19c70be..a493cd279 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index c0729e75b..e5e0053c9 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 6c7bc14d7..4cbd3795a 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts index 42f1baf2c..f06c59bc6 100644 --- a/server/private/routers/newt/handleRequestLogMessage.ts +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. From a04e2a5e009eb0bdddc0da4d1fe07ebe151fe595 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 17:46:04 -0700 Subject: [PATCH 069/176] Transititioning the hc table and firing the alerts --- server/db/pg/schema/schema.ts | 10 ++- server/db/sqlite/schema/schema.ts | 5 +- server/routers/newt/buildConfiguration.ts | 2 + server/routers/newt/offlineChecker.ts | 10 ++- server/routers/newt/pingAccumulator.ts | 38 ++++++++-- server/routers/newt/targets.ts | 72 ++++++++++++------- server/routers/target/createTarget.ts | 4 +- server/routers/target/getTarget.ts | 11 ++- .../target/handleHealthcheckStatusMessage.ts | 34 ++++++++- 9 files changed, 139 insertions(+), 47 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c542af33b..f39a125a3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -186,9 +186,13 @@ export const targets = pgTable("targets", { export const targetHealthCheck = pgTable("targetHealthCheck", { targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), - targetId: integer("targetId") - .notNull() - .references(() => targets.targetId, { onDelete: "cascade" }), + targetId: integer("targetId").references(() => targets.targetId, { + onDelete: "cascade" + }), + orgId: varchar("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), hcScheme: varchar("hcScheme"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5ec932c3c..9939f0309 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -210,8 +210,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { autoIncrement: true }), targetId: integer("targetId") - .notNull() .references(() => targets.targetId, { onDelete: "cascade" }), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + name: text("name").notNull(), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index fc0abd9cf..28b6373e0 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -201,6 +201,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { internalPort: targets.internalPort, enabled: targets.enabled, protocol: resources.protocol, + hcId: targetHealthCheck.targetHealthCheckId, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, @@ -272,6 +273,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { return { id: target.targetId, + hcId: target.hcId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 20e924efa..3343a92fd 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -4,7 +4,7 @@ import { } from "#dynamic/routers/ws"; import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; import logger from "@server/logger"; -import { fireSiteOfflineAlert } from "#dynamic/lib/alerts"; +import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; @@ -101,6 +101,8 @@ export const startNewtOfflineChecker = (): void => { .targetHealthCheckId ) ); + + // TODO: should we be firing an alert here when the health check goes to unknown? } await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name); @@ -111,6 +113,8 @@ export const startNewtOfflineChecker = (): void => { const allWireguardSites = await db .select({ siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name, online: sites.online, lastBandwidthUpdate: sites.lastBandwidthUpdate }) @@ -142,6 +146,8 @@ export const startNewtOfflineChecker = (): void => { .update(sites) .set({ online: false }) .where(eq(sites.siteId, site.siteId)); + + await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); } else if ( lastBandwidthUpdate >= wireguardOfflineThreshold && !site.online @@ -154,6 +160,8 @@ export const startNewtOfflineChecker = (): void => { .update(sites) .set({ online: true }) .where(eq(sites.siteId, site.siteId)); + + await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); } } } catch (error) { diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index fe2cde216..8f2154c39 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -1,7 +1,8 @@ import { db } from "@server/db"; import { sites, clients, olms } from "@server/db"; -import { inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; +import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; /** * Ping Accumulator @@ -110,15 +111,44 @@ async function flushSitePingsToDb(): Promise { const siteIds = batch.map(([id]) => id); try { - await withRetry(async () => { - await db + const newlyOnlineSites = await withRetry(async () => { + // Only update sites that were offline — these are the + // offline→online transitions. .returning() gives us exactly + // the site IDs that changed state. + const transitioned = await db .update(sites) .set({ online: true, lastPing: maxTimestamp }) - .where(inArray(sites.siteId, siteIds)); + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.online, false) + ) + ) + .returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name }); + + // Update lastPing for sites that were already online. + // After the update above, the newly-online sites now have + // online = true, so this catches all remaining sites in the + // batch and keeps lastPing current for them too. + await db + .update(sites) + .set({ lastPing: maxTimestamp }) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.online, true) + ) + ); + + return transitioned; }, "flushSitePingsToDb"); + + for (const site of newlyOnlineSites) { + await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); + } } catch (error) { logger.error( `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index afc983472..cd0814bb8 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,7 +1,6 @@ -import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db"; +import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; -import { eq, inArray } from "drizzle-orm"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function addTargets( @@ -18,17 +17,23 @@ export async function addTargets( }:${target.port}`; }); - await sendToClient(newtId, { - type: `newt/${protocol}/add`, - data: { - targets: payloadTargets - } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + await sendToClient( + newtId, + { + type: `newt/${protocol}/add`, + data: { + targets: payloadTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); // Create a map for quick lookup const healthCheckMap = new Map(); healthCheckData.forEach((hc) => { - healthCheckMap.set(hc.targetId, hc); + if (hc.targetId !== null) { + healthCheckMap.set(hc.targetId, hc); + } }); const healthCheckTargets = targets.map((target) => { @@ -79,6 +84,7 @@ export async function addTargets( return { id: target.targetId, + hcId: hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, @@ -102,12 +108,16 @@ export async function addTargets( (target) => target !== null ); - await sendToClient(newtId, { - type: `newt/healthcheck/add`, - data: { - targets: validHealthCheckTargets - } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + await sendToClient( + newtId, + { + type: `newt/healthcheck/add`, + data: { + targets: validHealthCheckTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } export async function removeTargets( @@ -123,21 +133,29 @@ export async function removeTargets( }:${target.port}`; }); - await sendToClient(newtId, { - type: `newt/${protocol}/remove`, - data: { - targets: payloadTargets - } - }, { incrementConfigVersion: true }); + await sendToClient( + newtId, + { + type: `newt/${protocol}/remove`, + data: { + targets: payloadTargets + } + }, + { incrementConfigVersion: true } + ); const healthCheckTargets = targets.map((target) => { return target.targetId; }); - await sendToClient(newtId, { - type: `newt/healthcheck/remove`, - data: { - ids: healthCheckTargets - } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + await sendToClient( + newtId, + { + type: `newt/healthcheck/remove`, + data: { + ids: healthCheckTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 129a70abf..a4d2e7e54 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -275,8 +275,8 @@ export async function createTarget( return response(res, { data: { - ...newTarget[0], - ...healthCheck[0] + ...healthCheck[0], + ...newTarget[0] }, success: true, error: false, diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index 749e1399b..281c39906 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -15,8 +15,8 @@ const getTargetSchema = z.strictObject({ }); type GetTargetResponse = Target & - Omit & { - hcHeaders: { name: string; value: string }[] | null; + Partial> & { + hcHeaders: { name: string; value: string }[] | null | undefined; }; registry.registerPath({ @@ -70,20 +70,19 @@ export async function getTarget( .limit(1); // Parse hcHeaders from JSON string back to array - let parsedHcHeaders = null; + let parsedHcHeaders: { name: string; value: string }[] | null = null; if (targetHc?.hcHeaders) { try { parsedHcHeaders = JSON.parse(targetHc.hcHeaders); } catch (error) { - // If parsing fails, keep as string for backward compatibility - parsedHcHeaders = targetHc.hcHeaders; + // If parsing fails, keep as null for safety } } return response(res, { data: { - ...target[0], ...targetHc, + ...target[0], hcHeaders: parsedHcHeaders }, success: true, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 7ea1730ce..ef2244c39 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -3,7 +3,10 @@ import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; -import { unknown } from "zod"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckNotHealthyAlert +} from "#dynamic/lib/alerts"; interface TargetHealthStatus { status: string; @@ -11,7 +14,7 @@ interface TargetHealthStatus { checkCount: number; lastError?: string; config: { - id: string; + id: string; // this could be the hc id or the target id, depending on the version of newt hcEnabled: boolean; hcPath?: string; hcScheme?: string; @@ -23,6 +26,9 @@ interface TargetHealthStatus { hcTimeout?: number; hcHeaders?: any; hcMethod?: string; + hcTlsServerName?: string; + hcHealthyThreshold?: number; + hcUnhealthyThreshold?: number; }; } @@ -78,6 +84,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .select({ targetId: targets.targetId, siteId: targets.siteId, + orgId: targetHealthCheck.orgId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceOrgId: resources.orgId, + name: targetHealthCheck.name, hcStatus: targetHealthCheck.hcHealth }) .from(targets) @@ -86,7 +96,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( eq(targets.resourceId, resources.resourceId) ) .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId)) + .innerJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) .where( and( eq(targets.targetId, targetIdNum), @@ -123,6 +136,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); + // because we are checking above if there was a change we can fire the alert here because it changed + if (healthStatus.status === "unhealthy") { + await fireHealthCheckHealthyAlert( + targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + targetCheck.targetHealthCheckId, + targetCheck.name + ); + } else if (healthStatus.status === "healthy") { + await fireHealthCheckNotHealthyAlert( + targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + targetCheck.targetHealthCheckId, + targetCheck.name + ); + } + logger.debug( `Updated health status for target ${targetId} to ${healthStatus.status}` ); From 1397e616439692666d274dee3d64ed0011d0229f Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 20:32:02 -0700 Subject: [PATCH 070/176] Create hcs freely --- messages/en-US.json | 17 + server/auth/actions.ts | 7 +- server/lib/blueprints/proxyResources.ts | 1 + server/private/routers/external.ts | 36 + .../routers/healthChecks/createHealthCheck.ts | 158 ++++ .../routers/healthChecks/deleteHealthCheck.ts | 107 +++ server/private/routers/healthChecks/index.ts | 17 + .../routers/healthChecks}/listHealthChecks.ts | 118 +-- .../routers/healthChecks/updateHealthCheck.ts | 239 +++++ server/routers/external.ts | 7 - server/routers/healthChecks/types.ts | 28 + server/routers/resource/index.ts | 1 - server/routers/target/createTarget.ts | 1 + src/app/[orgId]/settings/alerting/page.tsx | 14 +- .../StandaloneHealthCheckCredenza.tsx | 856 ++++++++++++++++++ .../StandaloneHealthChecksTable.tsx | 299 ++++++ .../alert-rule-editor/AlertRuleFields.tsx | 6 +- src/lib/queries.ts | 41 +- 18 files changed, 1881 insertions(+), 72 deletions(-) create mode 100644 server/private/routers/healthChecks/createHealthCheck.ts create mode 100644 server/private/routers/healthChecks/deleteHealthCheck.ts create mode 100644 server/private/routers/healthChecks/index.ts rename server/{routers/resource => private/routers/healthChecks}/listHealthChecks.ts (50%) create mode 100644 server/private/routers/healthChecks/updateHealthCheck.ts create mode 100644 server/routers/healthChecks/types.ts create mode 100644 src/components/StandaloneHealthCheckCredenza.tsx create mode 100644 src/components/StandaloneHealthChecksTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7146bac3a..33003e434 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1426,6 +1426,23 @@ "alertingNodeRoleSource": "Source", "alertingNodeRoleTrigger": "Trigger", "alertingNodeRoleAction": "Action", + "alertingTabRules": "Alert Rules", + "alertingTabHealthChecks": "Health Checks", + "standaloneHcTableTitle": "Health Checks", + "standaloneHcSearchPlaceholder": "Search health checks…", + "standaloneHcAddButton": "Create Health Check", + "standaloneHcCreateTitle": "Create Health Check", + "standaloneHcEditTitle": "Edit Health Check", + "standaloneHcDescription": "Configure a HTTP or TCP health check for use in alert rules.", + "standaloneHcNameLabel": "Name", + "standaloneHcNamePlaceholder": "My HTTP Monitor", + "standaloneHcDeleteTitle": "Delete health check", + "standaloneHcDeleteQuestion": "Delete this health check? This cannot be undone.", + "standaloneHcDeleted": "Health check deleted", + "standaloneHcSaved": "Health check saved", + "standaloneHcColumnHealth": "Health", + "standaloneHcColumnMode": "Mode", + "standaloneHcColumnTarget": "Target", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f192459cc..fd9c02e93 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -145,12 +145,15 @@ export enum ActionsEnum { updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", listEventStreamingDestinations = "listEventStreamingDestinations", - listHealthChecks = "listHealthChecks", createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", listAlertRules = "listAlertRules", - getAlertRule = "getAlertRule" + getAlertRule = "getAlertRule", + createHealthCheck = "createHealthCheck", + updateHealthCheck = "updateHealthCheck", + deleteHealthCheck = "deleteHealthCheck", + listHealthChecks = "listHealthChecks" } export async function checkUserActionPermission( diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index b75914021..72bcda76f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -140,6 +140,7 @@ export async function updateProxyResources( const [newHealthcheck] = await trx .insert(targetHealthCheck) .values({ + name: `${targetData.hostname}:${targetData.port}`, targetId: newTarget.targetId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0e5c5e0ef..f10e526ed 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -30,6 +30,7 @@ import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; +import * as healthChecks from "#private/routers/healthChecks"; import { verifyOrgAccess, @@ -695,3 +696,38 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getAlertRule), alertRule.getAlertRule ); + +authenticated.get( + "/org/:orgId/health-checks", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listHealthChecks), + healthChecks.listHealthChecks +); + +authenticated.put( + "/org/:orgId/health-check", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createHealthCheck), + logActionAudit(ActionsEnum.createHealthCheck), + healthChecks.createHealthCheck +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateHealthCheck), + logActionAudit(ActionsEnum.updateHealthCheck), + healthChecks.updateHealthCheck +); + +authenticated.delete( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteHealthCheck), + logActionAudit(ActionsEnum.deleteHealthCheck), + healthChecks.deleteHealthCheck +); diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts new file mode 100644 index 000000000..2a6028ea8 --- /dev/null +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -0,0 +1,158 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(5), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) +}); + +export type CreateHealthCheckResponse = { + targetHealthCheckId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/health-check", + description: "Create a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const [record] = await db + .insert(targetHealthCheck) + .values({ + targetId: null, + orgId, + name, + hcEnabled, + hcMode, + hcHostname: hcHostname ?? null, + hcPort: hcPort ?? null, + hcPath: hcPath ?? null, + hcScheme: hcScheme ?? null, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders: hcHeaders ?? null, + hcFollowRedirects, + hcStatus: hcStatus ?? null, + hcTlsServerName: hcTlsServerName ?? null, + hcHealthyThreshold, + hcUnhealthyThreshold + }) + .returning(); + + return response(res, { + data: { + targetHealthCheckId: record.targetHealthCheckId + }, + success: true, + error: false, + message: "Standalone health check 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/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts new file mode 100644 index 000000000..b65e4a701 --- /dev/null +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -0,0 +1,107 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, isNull } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Delete a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + await db + .delete(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Standalone health check 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/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts new file mode 100644 index 000000000..5f5c796f3 --- /dev/null +++ b/server/private/routers/healthChecks/index.ts @@ -0,0 +1,17 @@ +/* + * 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 "./listHealthChecks"; +export * from "./createHealthCheck"; +export * from "./updateHealthCheck"; +export * from "./deleteHealthCheck"; diff --git a/server/routers/resource/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts similarity index 50% rename from server/routers/resource/listHealthChecks.ts rename to server/private/routers/healthChecks/listHealthChecks.ts index 698f35052..d5b05ac24 100644 --- a/server/routers/resource/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -1,19 +1,33 @@ -import { db, targetHealthCheck, targets, resources } from "@server/db"; +/* + * 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, targetHealthCheck } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, sql, inArray } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -const listHealthChecksParamsSchema = z.strictObject({ +const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const listHealthChecksSchema = z.object({ +const querySchema = z.object({ limit: z .string() .optional() @@ -28,29 +42,14 @@ const listHealthChecksSchema = z.object({ .pipe(z.int().nonnegative()) }); -export type ListHealthChecksResponse = { - healthChecks: { - targetHealthCheckId: number; - resourceId: number; - resourceName: string; - hcEnabled: boolean; - hcHealth: "unknown" | "healthy" | "unhealthy"; - }[]; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; - registry.registerPath({ method: "get", path: "/org/{orgId}/health-checks", - description: "List health checks for all resources in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResource], + description: "List health checks for an organization.", + tags: [OpenAPITags.Org], request: { - params: listHealthChecksParamsSchema, - query: listHealthChecksSchema + params: paramsSchema, + query: querySchema }, responses: {} }); @@ -61,62 +60,71 @@ export async function listHealthChecks( next: NextFunction ): Promise { try { - const parsedQuery = listHealthChecksSchema.safeParse(req.query); - if (!parsedQuery.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedQuery.error) - ) - ); - } - const { limit, offset } = parsedQuery.data; - - const parsedParams = listHealthChecksParamsSchema.safeParse(req.params); + const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedParams.error) + fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const whereClause = and( + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ); + const list = await db - .select({ - targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceId: resources.resourceId, - resourceName: resources.name, - hcEnabled: targetHealthCheck.hcEnabled, - hcHealth: targetHealthCheck.hcHealth - }) + .select() .from(targetHealthCheck) - .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) - .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .where(eq(resources.orgId, orgId)) - .orderBy(sql`${resources.name} ASC`) + .where(whereClause) + .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .limit(limit) .offset(offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(targetHealthCheck) - .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) - .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .where(eq(resources.orgId, orgId)); + .where(whereClause); return response(res, { data: { healthChecks: list.map((row) => ({ targetHealthCheckId: row.targetHealthCheckId, - resourceId: row.resourceId, - resourceName: row.resourceName, + name: row.name ?? "", hcEnabled: row.hcEnabled, hcHealth: (row.hcHealth ?? "unknown") as | "unknown" | "healthy" - | "unhealthy" + | "unhealthy", + hcMode: row.hcMode ?? null, + hcHostname: row.hcHostname ?? null, + hcPort: row.hcPort ?? null, + hcPath: row.hcPath ?? null, + hcScheme: row.hcScheme ?? null, + hcMethod: row.hcMethod ?? null, + hcInterval: row.hcInterval ?? null, + hcUnhealthyInterval: row.hcUnhealthyInterval ?? null, + hcTimeout: row.hcTimeout ?? null, + hcHeaders: row.hcHeaders ?? null, + hcFollowRedirects: row.hcFollowRedirects ?? null, + hcStatus: row.hcStatus ?? null, + hcTlsServerName: row.hcTlsServerName ?? null, + hcHealthyThreshold: row.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null })), pagination: { total: count, @@ -126,7 +134,7 @@ export async function listHealthChecks( }, success: true, error: false, - message: "Health checks retrieved successfully", + message: "Standalone health checks retrieved successfully", status: HttpCode.OK }); } catch (error) { @@ -135,4 +143,4 @@ export async function listHealthChecks( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts new file mode 100644 index 000000000..c5a0759b7 --- /dev/null +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -0,0 +1,239 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, isNull } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + hcEnabled: z.boolean().optional(), + hcMode: z.string().optional(), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z.number().int().positive().optional(), + hcUnhealthyInterval: z.number().int().positive().optional(), + hcTimeout: z.number().int().positive().optional(), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().optional(), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().optional(), + hcUnhealthyThreshold: z.number().int().positive().optional() +}); + +export type UpdateHealthCheckResponse = { + targetHealthCheckId: number; + name: string | null; + hcEnabled: boolean; + hcHealth: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Update a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const { + name, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const updateData: Record = {}; + + if (name !== undefined) updateData.name = name; + if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; + if (hcMode !== undefined) updateData.hcMode = hcMode; + if (hcHostname !== undefined) updateData.hcHostname = hcHostname; + if (hcPort !== undefined) updateData.hcPort = hcPort; + if (hcPath !== undefined) updateData.hcPath = hcPath; + if (hcScheme !== undefined) updateData.hcScheme = hcScheme; + if (hcMethod !== undefined) updateData.hcMethod = hcMethod; + if (hcInterval !== undefined) updateData.hcInterval = hcInterval; + if (hcUnhealthyInterval !== undefined) + updateData.hcUnhealthyInterval = hcUnhealthyInterval; + if (hcTimeout !== undefined) updateData.hcTimeout = hcTimeout; + if (hcHeaders !== undefined) updateData.hcHeaders = hcHeaders; + if (hcFollowRedirects !== undefined) + updateData.hcFollowRedirects = hcFollowRedirects; + if (hcStatus !== undefined) updateData.hcStatus = hcStatus; + if (hcTlsServerName !== undefined) + updateData.hcTlsServerName = hcTlsServerName; + if (hcHealthyThreshold !== undefined) + updateData.hcHealthyThreshold = hcHealthyThreshold; + if (hcUnhealthyThreshold !== undefined) + updateData.hcUnhealthyThreshold = hcUnhealthyThreshold; + + const [updated] = await db + .update(targetHealthCheck) + .set(updateData) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ) + .returning(); + + return response(res, { + data: { + targetHealthCheckId: updated.targetHealthCheckId, + name: updated.name ?? null, + hcEnabled: updated.hcEnabled, + hcHealth: updated.hcHealth ?? null, + hcMode: updated.hcMode ?? null, + hcHostname: updated.hcHostname ?? null, + hcPort: updated.hcPort ?? null, + hcPath: updated.hcPath ?? null, + hcScheme: updated.hcScheme ?? null, + hcMethod: updated.hcMethod ?? null, + hcInterval: updated.hcInterval ?? null, + hcUnhealthyInterval: updated.hcUnhealthyInterval ?? null, + hcTimeout: updated.hcTimeout ?? null, + hcHeaders: updated.hcHeaders ?? null, + hcFollowRedirects: updated.hcFollowRedirects ?? null, + hcStatus: updated.hcStatus ?? null, + hcTlsServerName: updated.hcTlsServerName ?? null, + hcHealthyThreshold: updated.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: updated.hcUnhealthyThreshold ?? null + }, + success: true, + error: false, + message: "Standalone health check 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/external.ts b/server/routers/external.ts index 484db4344..d7729bca5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,13 +427,6 @@ authenticated.get( resource.listResources ); -authenticated.get( - "/org/:orgId/health-checks", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listHealthChecks), - resource.listHealthChecks -); - authenticated.get( "/org/:orgId/resource-names", verifyOrgAccess, diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts new file mode 100644 index 000000000..429da80c0 --- /dev/null +++ b/server/routers/healthChecks/types.ts @@ -0,0 +1,28 @@ +export type ListHealthChecksResponse = { + healthChecks: { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 2b379a7d5..12e98a70d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,4 +32,3 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; -export * from "./listHealthChecks"; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a4d2e7e54..973155ccc 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -228,6 +228,7 @@ export async function createTarget( healthCheck = await db .insert(targetHealthCheck) .values({ + name: `${targetData.ip}:${targetData.port}`, targetId: newTarget[0].targetId, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index 3d100bed2..aeba881a0 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,5 +1,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import StandaloneHealthChecksTable from "@app/components/StandaloneHealthChecksTable"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; type AlertingPageProps = { @@ -12,13 +14,21 @@ export default async function AlertingPage(props: AlertingPageProps) { const params = await props.params; const t = await getTranslations(); + const tabs: TabItem[] = [ + { title: t("alertingTabRules"), href: "" }, + { title: t("alertingTabHealthChecks"), href: "" } + ]; + return ( <> - + + + + ); -} +} \ No newline at end of file diff --git a/src/components/StandaloneHealthCheckCredenza.tsx b/src/components/StandaloneHealthCheckCredenza.tsx new file mode 100644 index 000000000..dd5a7ab17 --- /dev/null +++ b/src/components/StandaloneHealthCheckCredenza.tsx @@ -0,0 +1,856 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@/components/Credenza"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; + +export type HealthCheckRow = { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}; + +type StandaloneHealthCheckCredenzaProps = { + open: boolean; + setOpen: (v: boolean) => void; + orgId: string; + initialValues?: HealthCheckRow | null; + onSaved: () => void; +}; + +const DEFAULT_VALUES = { + name: "", + hcEnabled: true, + hcMode: "http", + hcScheme: "https", + hcMethod: "GET", + hcHostname: "", + hcPort: "", + hcPath: "/", + hcInterval: 30, + hcUnhealthyInterval: 30, + hcTimeout: 5, + hcHealthyThreshold: 1, + hcUnhealthyThreshold: 1, + hcFollowRedirects: true, + hcTlsServerName: "", + hcStatus: null as number | null, + hcHeaders: [] as { name: string; value: string }[] +}; + +export default function StandaloneHealthCheckCredenza({ + open, + setOpen, + orgId, + initialValues, + onSaved +}: StandaloneHealthCheckCredenzaProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const healthCheckSchema = z + .object({ + name: z.string().min(1, { message: t("standaloneHcNameLabel") }), + hcEnabled: z.boolean(), + hcPath: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z + .int() + .positive() + .min(5, { message: t("healthCheckIntervalMin") }), + hcTimeout: z + .int() + .positive() + .min(1, { message: t("healthCheckTimeoutMin") }), + hcStatus: z.int().positive().min(100).optional().nullable(), + hcHeaders: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcScheme: z.string().optional(), + hcHostname: z.string(), + hcPort: z + .string() + .min(1, { message: t("healthCheckPortInvalid") }) + .refine( + (val) => { + const port = parseInt(val); + return port > 0 && port <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ), + hcFollowRedirects: z.boolean(), + hcMode: z.string(), + hcUnhealthyInterval: z.int().positive().min(5), + hcTlsServerName: z.string(), + hcHealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckHealthyThresholdMin") }), + hcUnhealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckUnhealthyThresholdMin") }) + }) + .superRefine((data, ctx) => { + if (data.hcMode !== "tcp") { + if (!data.hcPath || data.hcPath.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckPathRequired"), + path: ["hcPath"] + }); + } + if (!data.hcMethod || data.hcMethod.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckMethodRequired"), + path: ["hcMethod"] + }); + } + } + }); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(healthCheckSchema), + defaultValues: DEFAULT_VALUES + }); + + useEffect(() => { + if (!open) return; + + if (initialValues) { + let parsedHeaders: { name: string; value: string }[] = []; + if (initialValues.hcHeaders) { + try { + parsedHeaders = JSON.parse(initialValues.hcHeaders); + } catch { + parsedHeaders = []; + } + } + + form.reset({ + name: initialValues.name, + hcEnabled: initialValues.hcEnabled, + hcMode: initialValues.hcMode ?? "http", + hcScheme: initialValues.hcScheme ?? "https", + hcMethod: initialValues.hcMethod ?? "GET", + hcHostname: initialValues.hcHostname ?? "", + hcPort: initialValues.hcPort + ? initialValues.hcPort.toString() + : "", + hcPath: initialValues.hcPath ?? "/", + hcInterval: initialValues.hcInterval ?? 30, + hcUnhealthyInterval: initialValues.hcUnhealthyInterval ?? 30, + hcTimeout: initialValues.hcTimeout ?? 5, + hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: initialValues.hcUnhealthyThreshold ?? 1, + hcFollowRedirects: initialValues.hcFollowRedirects ?? true, + hcTlsServerName: initialValues.hcTlsServerName ?? "", + hcStatus: initialValues.hcStatus ?? null, + hcHeaders: parsedHeaders + }); + } else { + form.reset(DEFAULT_VALUES); + } + }, [open]); + + const watchedEnabled = form.watch("hcEnabled"); + const watchedMode = form.watch("hcMode"); + + const onSubmit = async (values: FormValues) => { + setLoading(true); + try { + const payload = { + name: values.name, + hcEnabled: values.hcEnabled, + hcMode: values.hcMode, + hcScheme: values.hcScheme, + hcMethod: values.hcMethod, + hcHostname: values.hcHostname, + hcPort: parseInt(values.hcPort), + hcPath: values.hcPath ?? "", + hcInterval: values.hcInterval, + hcUnhealthyInterval: values.hcUnhealthyInterval, + hcTimeout: values.hcTimeout, + hcHealthyThreshold: values.hcHealthyThreshold, + hcUnhealthyThreshold: values.hcUnhealthyThreshold, + hcFollowRedirects: values.hcFollowRedirects, + hcTlsServerName: values.hcTlsServerName, + hcStatus: values.hcStatus || null, + hcHeaders: + values.hcHeaders && values.hcHeaders.length > 0 + ? JSON.stringify(values.hcHeaders) + : null + }; + + if (initialValues) { + await api.post( + `/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`, + payload + ); + } else { + await api.put( + `/org/${orgId}/health-check`, + payload + ); + } + + toast({ title: t("standaloneHcSaved") }); + onSaved(); + setOpen(false); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + const isEditing = !!initialValues; + + return ( + + + + + {isEditing + ? t("standaloneHcEditTitle") + : t("standaloneHcCreateTitle")} + + + {t("standaloneHcDescription")} + + + +
+ + {/* Name */} + ( + + + {t("standaloneHcNameLabel")} + + + + + + + )} + /> + + {/* Enable Health Check */} + ( + +
+ + {t("enableHealthChecks")} + + + {t( + "enableHealthChecksDescription" + )} + +
+ + + +
+ )} + /> + + {watchedEnabled && ( +
+ {/* Mode */} + ( + + + {t("healthCheckMode")} + + + + {t( + "healthCheckModeDescription" + )} + + + + )} + /> + + {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + + {t("healthHostname")} + + + + + + + )} + /> + ( + + + {t("healthPort")} + + + + + + + )} + /> +
+ ) : ( +
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + + + + + )} + /> + ( + + + {t("healthPort")} + + + + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + + + + + )} + /> +
+ )} + + {/* HTTP Method */} + {watchedMode !== "tcp" && ( + ( + + + {t("httpMethod")} + + + + + )} + /> + )} + + {/* Check Interval, Unhealthy Interval, and Timeout */} +
+ ( + + + {t( + "healthyIntervalSeconds" + )} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> + + ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> + + ( + + + {t("timeoutSeconds")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> +
+ + {/* Healthy and Unhealthy Thresholds */} +
+ ( + + + {t("healthyThreshold")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + {t( + "healthyThresholdDescription" + )} + + + + )} + /> + + ( + + + {t("unhealthyThreshold")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + {t( + "unhealthyThresholdDescription" + )} + + + + )} + /> +
+ + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Code */} + ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e.target + .value; + field.onChange( + val + ? parseInt( + val + ) + : null + ); + }} + /> + + + {t( + "expectedResponseCodesDescription" + )} + + + + )} + /> + + {/* TLS Server Name */} + ( + + + {t("tlsServerName")} + + + + + + {t( + "tlsServerNameDescription" + )} + + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + {/* Follow Redirects */} + ( + +
+ + {t( + "followRedirects" + )} + + + {t( + "followRedirectsDescription" + )} + +
+ + + +
+ )} + /> + + )} +
+ )} + + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/StandaloneHealthChecksTable.tsx b/src/components/StandaloneHealthChecksTable.tsx new file mode 100644 index 000000000..c839b705a --- /dev/null +++ b/src/components/StandaloneHealthChecksTable.tsx @@ -0,0 +1,299 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import StandaloneHealthCheckCredenza, { + HealthCheckRow +} from "@app/components/StandaloneHealthCheckCredenza"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type StandaloneHealthChecksTableProps = { + orgId: string; +}; + +function formatTarget(row: HealthCheckRow): string { + if (!row.hcHostname) return "—"; + if (row.hcMode === "tcp") { + if (!row.hcPort) return row.hcHostname; + return `${row.hcHostname}:${row.hcPort}`; + } + // HTTP / default + const scheme = row.hcScheme ?? "http"; + const host = row.hcHostname; + const port = row.hcPort ? `:${row.hcPort}` : ""; + const path = row.hcPath ?? "/"; + return `${scheme}://${host}${port}${path}`; +} + +const healthLabel: Record = { + healthy: "Healthy", + unhealthy: "Unhealthy", + unknown: "Unknown" +}; + +const healthVariant: Record< + HealthCheckRow["hcHealth"], + "green" | "red" | "secondary" +> = { + healthy: "green", + unhealthy: "red", + unknown: "secondary" +}; + +function HealthBadge({ health }: { health: HealthCheckRow["hcHealth"] }) { + return ( + {healthLabel[health]} + ); +} + +export default function StandaloneHealthChecksTable({ + orgId +}: StandaloneHealthChecksTableProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + + const [credenzaOpen, setCredenzaOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + const { + data: rows = [], + isLoading, + refetch, + isRefetching + } = useQuery(orgQueries.standaloneHealthChecks({ orgId })); + + const invalidate = () => + queryClient.invalidateQueries( + orgQueries.standaloneHealthChecks({ orgId }) + ); + + const handleToggleEnabled = async ( + row: HealthCheckRow, + enabled: boolean + ) => { + setTogglingId(row.targetHealthCheckId); + try { + await api.post( + `/org/${orgId}/health-check/${row.targetHealthCheckId}`, + { hcEnabled: enabled } + ); + await invalidate(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setTogglingId(null); + } + }; + + const handleDelete = async () => { + if (!selected) return; + try { + await api.delete( + `/org/${orgId}/health-check/${selected.targetHealthCheckId}` + ); + await invalidate(); + toast({ title: t("standaloneHcDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ) + }, + { + id: "mode", + friendlyName: t("standaloneHcColumnMode"), + header: () => ( + {t("standaloneHcColumnMode")} + ), + cell: ({ row }) => ( + + {row.original.hcMode?.toUpperCase() ?? "—"} + + ) + }, + { + id: "target", + friendlyName: t("standaloneHcColumnTarget"), + header: () => ( + {t("standaloneHcColumnTarget")} + ), + cell: ({ row }) => ( + + {formatTarget(row.original)} + + ) + }, + { + id: "health", + friendlyName: t("standaloneHcColumnHealth"), + header: () => ( + {t("standaloneHcColumnHealth")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "hcEnabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + {t("alertingColumnEnabled")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + handleToggleEnabled(r, v)} + /> + ); + } + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selected && deleteOpen && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("standaloneHcDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={handleDelete} + string={selected.name} + title={t("standaloneHcDeleteTitle")} + /> + )} + + { + setCredenzaOpen(val); + if (!val) setSelected(null); + }} + orgId={orgId} + initialValues={selected} + onSaved={invalidate} + /> + + { + setSelected(null); + setCredenzaOpen(true); + }} + onRefresh={() => refetch()} + isRefreshing={isRefetching || isLoading} + addButtonText={t("standaloneHcAddButton")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + /> + + ); +} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 73b2302e0..824fc1b10 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -182,7 +182,7 @@ function HealthCheckMultiSelect({ const query = debounced.trim().toLowerCase(); const base = query ? healthChecks.filter((hc) => - hc.resourceName.toLowerCase().includes(query) + hc.name.toLowerCase().includes(query) ) : healthChecks; // Always keep already-selected items visible even if they fall outside the search @@ -243,7 +243,7 @@ function HealthCheckMultiSelect({ {shown.map((hc) => ( toggle(hc.targetHealthCheckId) } @@ -258,7 +258,7 @@ function HealthCheckMultiSelect({ tabIndex={-1} /> - {hc.resourceName} + {hc.name} ))} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 17948d63a..17cc10f11 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,7 +4,6 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, - ListHealthChecksResponse, ListResourceNamesResponse, ListResourcesResponse } from "@server/routers/resource"; @@ -28,7 +27,7 @@ import type { AxiosResponse } from "axios"; import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; -import { wait } from "./wait"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; export type ProductUpdate = { link: string | null; @@ -264,6 +263,44 @@ export const orgQueries = { >(`/org/${orgId}/alert-rules`, { signal }); return res.data.data.alertRules; } + }), + + standaloneHealthChecks: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse<{ + healthChecks: { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + }> + >(`/org/${orgId}/health-checks`, { signal }); + return res.data.data.healthChecks; + } }) }; From 1a1d1cfb83be24166bde7ba314fba203fa463424 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 20:40:23 -0700 Subject: [PATCH 071/176] Not null removed --- server/db/sqlite/schema/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 9939f0309..22144a2c6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -214,7 +214,7 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), - name: text("name").notNull(), + name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() .default(false), From 57579e635c5af2a9cf56fde5d88e10acd7639717 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 11:49:48 -0700 Subject: [PATCH 072/176] Working on alerting --- .../lib/alerts/events/healthCheckEvents.ts | 6 +- server/routers/newt/buildConfiguration.ts | 2 + server/routers/newt/targets.ts | 1 + .../target/handleHealthcheckStatusMessage.ts | 1 + src/app/[orgId]/settings/alerting/page.tsx | 6 +- .../resources/proxy/[niceId]/proxy/page.tsx | 2 +- src/components/HealthCheckDialog.tsx | 685 +----------------- src/components/HealthCheckFormFields.tsx | 548 ++++++++++++++ ...hChecksTable.tsx => HealthChecksTable.tsx} | 3 +- src/components/LicenseKeysDataTable.tsx | 1 - .../StandaloneHealthCheckCredenza.tsx | 572 +-------------- 11 files changed, 572 insertions(+), 1255 deletions(-) create mode 100644 src/components/HealthCheckFormFields.tsx rename src/components/{StandaloneHealthChecksTable.tsx => HealthChecksTable.tsx} (99%) diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 38dff916b..9ede25fe6 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -32,7 +32,7 @@ import { processAlerts } from "../processAlerts"; export async function fireHealthCheckHealthyAlert( orgId: string, healthCheckId: number, - healthCheckName?: string, + healthCheckName?: string | null, extra?: Record ): Promise { try { @@ -68,7 +68,7 @@ export async function fireHealthCheckHealthyAlert( export async function fireHealthCheckNotHealthyAlert( orgId: string, healthCheckId: number, - healthCheckName?: string, + healthCheckName?: string | null, extra?: Record ): Promise { try { @@ -88,4 +88,4 @@ export async function fireHealthCheckNotHealthyAlert( err ); } -} \ No newline at end of file +} diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 28b6373e0..46729f11d 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -212,6 +212,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, hcTimeout: targetHealthCheck.hcTimeout, hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, hcMethod: targetHealthCheck.hcMethod, hcTlsServerName: targetHealthCheck.hcTlsServerName, hcStatus: targetHealthCheck.hcStatus, @@ -284,6 +285,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds hcTimeout: target.hcTimeout, // in seconds hcHeaders: hcHeadersSend, + hcFollowRedirects: target.hcFollowRedirects, hcMethod: target.hcMethod, hcTlsServerName: target.hcTlsServerName, hcStatus: target.hcStatus, diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index cd0814bb8..572c63e98 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -95,6 +95,7 @@ export async function addTargets( hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds hcTimeout: hc.hcTimeout, // in seconds hcHeaders: hcHeadersSend, + hcFollowRedirects: hc.hcFollowRedirects, hcMethod: hc.hcMethod, hcStatus: hcStatus, hcTlsServerName: hc.hcTlsServerName, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index ef2244c39..87f47c17b 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -25,6 +25,7 @@ interface TargetHealthStatus { hcUnhealthyInterval?: number; hcTimeout?: number; hcHeaders?: any; + hcFollowRedirects?: boolean; hcMethod?: string; hcTlsServerName?: string; hcHealthyThreshold?: number; diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index aeba881a0..cadc83516 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,6 +1,6 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import AlertingRulesTable from "@app/components/AlertingRulesTable"; -import StandaloneHealthChecksTable from "@app/components/StandaloneHealthChecksTable"; +import HealthChecksTable from "@app/components/HealthChecksTable"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; @@ -27,8 +27,8 @@ export default async function AlertingPage(props: AlertingPageProps) { /> - + ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index a9128b9d3..8c3d4910c 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -640,10 +640,10 @@ function ProxyResourceTargetsForm({ hcInterval: null, hcTimeout: null, hcHeaders: null, + hcFollowRedirects: null, hcScheme: null, hcHostname: null, hcPort: null, - hcFollowRedirects: null, hcHealth: "unknown", hcStatus: null, hcMode: null, diff --git a/src/components/HealthCheckDialog.tsx b/src/components/HealthCheckDialog.tsx index abdc32d4a..d441cdaf3 100644 --- a/src/components/HealthCheckDialog.tsx +++ b/src/components/HealthCheckDialog.tsx @@ -2,28 +2,11 @@ import { useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { HeadersInput } from "@app/components/HeadersInput"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; +import { Form } from "@/components/ui/form"; +import { HealthCheckFormFields } from "@app/components/HealthCheckFormFields"; import { Credenza, CredenzaBody, @@ -59,7 +42,7 @@ type HealthCheckConfig = { type HealthCheckDialogProps = { open: boolean; setOpen: (val: boolean) => void; - targetId: number; + orgId: string; targetAddress: string; targetMethod?: string; initialConfig?: Partial; @@ -69,7 +52,7 @@ type HealthCheckDialogProps = { export default function HealthCheckDialog({ open, setOpen, - targetId, + orgId, targetAddress, targetMethod, initialConfig, @@ -185,9 +168,6 @@ export default function HealthCheckDialog({ }); }, [open]); - const watchedEnabled = form.watch("hcEnabled"); - const watchedMode = form.watch("hcMode"); - const handleFieldChange = async (fieldName: string, value: any) => { try { const currentValues = form.getValues(); @@ -227,659 +207,10 @@ export default function HealthCheckDialog({
- {/* Enable Health Checks */} - ( - -
- - {t("enableHealthChecks")} - - - {t( - "enableHealthChecksDescription" - )} - -
- - { - field.onChange(value); - handleFieldChange( - "hcEnabled", - value - ); - }} - /> - -
- )} + - - {watchedEnabled && ( -
- {/* Mode */} - ( - - - {t("healthCheckMode")} - - - - {t( - "healthCheckModeDescription" - )} - - - - )} - /> - - {/* Connection fields */} - {watchedMode === "tcp" ? ( -
- ( - - - {t("healthHostname")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcHostname", - e.target - .value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthPort")} - - - { - const value = - e.target - .value; - field.onChange( - value - ); - handleFieldChange( - "hcPort", - value - ); - }} - /> - - - - )} - /> -
- ) : ( -
- ( - - - {t("healthScheme")} - - - - - )} - /> - ( - - - {t("healthHostname")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcHostname", - e.target - .value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthPort")} - - - { - const value = - e.target - .value; - field.onChange( - value - ); - handleFieldChange( - "hcPort", - value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthCheckPath")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcPath", - e.target - .value - ); - }} - /> - - - - )} - /> -
- )} - - {/* HTTP Method */} - {watchedMode !== "tcp" && ( - ( - - - {t("httpMethod")} - - - - - )} - /> - )} - - {/* Check Interval, Unhealthy Interval, and Timeout */} -
- ( - - - {t( - "healthyIntervalSeconds" - )} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcInterval", - value - ); - }} - /> - - - - )} - /> - - ( - - - {t( - "unhealthyIntervalSeconds" - )} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcUnhealthyInterval", - value - ); - }} - /> - - - - )} - /> - - ( - - - {t("timeoutSeconds")} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcTimeout", - value - ); - }} - /> - - - - )} - /> -
- - {/* Healthy and Unhealthy Thresholds */} -
- ( - - - {t("healthyThreshold")} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcHealthyThreshold", - value - ); - }} - /> - - - {t( - "healthyThresholdDescription" - )} - - - - )} - /> - - ( - - - {t("unhealthyThreshold")} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcUnhealthyThreshold", - value - ); - }} - /> - - - {t( - "unhealthyThresholdDescription" - )} - - - - )} - /> -
- - {/* HTTP-only fields */} - {watchedMode !== "tcp" && ( - <> - {/* Expected Response Codes */} - ( - - - {t( - "expectedResponseCodes" - )} - - - { - const value = - parseInt( - e - .target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcStatus", - value - ); - }} - /> - - - {t( - "expectedResponseCodesDescription" - )} - - - - )} - /> - - {/* TLS Server Name (SNI) */} - ( - - - {t("tlsServerName")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcTlsServerName", - e.target - .value - ); - }} - /> - - - {t( - "tlsServerNameDescription" - )} - - - - )} - /> - - {/* Custom Headers */} - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - handleFieldChange( - "hcHeaders", - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> - - )} -
- )}
@@ -889,4 +220,4 @@ export default function HealthCheckDialog({ ); -} \ No newline at end of file +} diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx new file mode 100644 index 000000000..9873f9c5d --- /dev/null +++ b/src/components/HealthCheckFormFields.tsx @@ -0,0 +1,548 @@ +"use client"; + +import { UseFormReturn } from "react-hook-form"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; + +type HealthCheckFormFieldsProps = { + form: UseFormReturn; + onFieldChange?: (fieldName: string, value: any) => void; + showNameField?: boolean; +}; + +export function HealthCheckFormFields({ + form, + onFieldChange, + showNameField +}: HealthCheckFormFieldsProps) { + const t = useTranslations(); + + const watchedEnabled = form.watch("hcEnabled"); + const watchedMode = form.watch("hcMode"); + + const handleChange = (fieldName: string, value: any, fieldOnChange: (v: any) => void) => { + fieldOnChange(value); + if (onFieldChange) { + onFieldChange(fieldName, value); + } + }; + + return ( + <> + {/* Name */} + {showNameField && ( + ( + + {t("standaloneHcNameLabel")} + + + + + + )} + /> + )} + + {/* Enable Health Checks */} + ( + +
+ {t("enableHealthChecks")} + + {t("enableHealthChecksDescription")} + +
+ + + handleChange("hcEnabled", value, field.onChange) + } + /> + +
+ )} + /> + + {watchedEnabled && ( +
+ {/* Mode */} + ( + + {t("healthCheckMode")} + + + {t("healthCheckModeDescription")} + + + + )} + /> + + {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + {t("healthHostname")} + + + handleChange( + "hcHostname", + e.target.value, + (v) => field.onChange(e) + ) + } + /> + + + + )} + /> + ( + + {t("healthPort")} + + { + const value = e.target.value; + handleChange("hcPort", value, field.onChange); + }} + /> + + + + )} + /> +
+ ) : ( +
+ ( + + {t("healthScheme")} + + + + )} + /> + ( + + {t("healthHostname")} + + + handleChange( + "hcHostname", + e.target.value, + (v) => field.onChange(e) + ) + } + /> + + + + )} + /> + ( + + {t("healthPort")} + + { + const value = e.target.value; + handleChange("hcPort", value, field.onChange); + }} + /> + + + + )} + /> + ( + + {t("healthCheckPath")} + + + handleChange( + "hcPath", + e.target.value, + (v) => field.onChange(e) + ) + } + /> + + + + )} + /> +
+ )} + + {/* HTTP Method */} + {watchedMode !== "tcp" && ( + ( + + {t("httpMethod")} + + + + )} + /> + )} + + {/* Check Interval, Unhealthy Interval, and Timeout */} +
+ ( + + {t("healthyIntervalSeconds")} + + { + const value = parseInt(e.target.value); + handleChange("hcInterval", value, field.onChange); + }} + /> + + + + )} + /> + + ( + + {t("unhealthyIntervalSeconds")} + + { + const value = parseInt(e.target.value); + handleChange( + "hcUnhealthyInterval", + value, + field.onChange + ); + }} + /> + + + + )} + /> + + ( + + {t("timeoutSeconds")} + + { + const value = parseInt(e.target.value); + handleChange("hcTimeout", value, field.onChange); + }} + /> + + + + )} + /> +
+ + {/* Healthy and Unhealthy Thresholds */} +
+ ( + + {t("healthyThreshold")} + + { + const value = parseInt(e.target.value); + handleChange( + "hcHealthyThreshold", + value, + field.onChange + ); + }} + /> + + + {t("healthyThresholdDescription")} + + + + )} + /> + + ( + + {t("unhealthyThreshold")} + + { + const value = parseInt(e.target.value); + handleChange( + "hcUnhealthyThreshold", + value, + field.onChange + ); + }} + /> + + + {t("unhealthyThresholdDescription")} + + + + )} + /> +
+ + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Code */} + ( + + {t("expectedResponseCodes")} + + { + const val = e.target.value; + const value = val ? parseInt(val) : null; + handleChange("hcStatus", value, field.onChange); + }} + /> + + + {t("expectedResponseCodesDescription")} + + + + )} + /> + + {/* TLS Server Name */} + ( + + {t("tlsServerName")} + + + handleChange( + "hcTlsServerName", + e.target.value, + (v) => field.onChange(e) + ) + } + /> + + + {t("tlsServerNameDescription")} + + + + )} + /> + + {/* Custom Headers */} + ( + + {t("customHeaders")} + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={4} + /> + + + {t("customHeadersDescription")} + + + + )} + /> + + {/* Follow Redirects */} + ( + +
+ {t("followRedirects")} + + {t("followRedirectsDescription")} + +
+ + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + +
+ )} + /> + + )} +
+ )} + + ); +} diff --git a/src/components/StandaloneHealthChecksTable.tsx b/src/components/HealthChecksTable.tsx similarity index 99% rename from src/components/StandaloneHealthChecksTable.tsx rename to src/components/HealthChecksTable.tsx index c839b705a..9d68498b5 100644 --- a/src/components/StandaloneHealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -22,6 +22,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import HealthCheckDialog from "./HealthCheckDialog"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -62,7 +63,7 @@ function HealthBadge({ health }: { health: HealthCheckRow["hcHealth"] }) { ); } -export default function StandaloneHealthChecksTable({ +export default function HealthChecksTable({ orgId }: StandaloneHealthChecksTableProps) { const t = useTranslations(); diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 1e39c9225..a3e6f3ce5 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -1,6 +1,5 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DataTable } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; diff --git a/src/components/StandaloneHealthCheckCredenza.tsx b/src/components/StandaloneHealthCheckCredenza.tsx index dd5a7ab17..99260707d 100644 --- a/src/components/StandaloneHealthCheckCredenza.tsx +++ b/src/components/StandaloneHealthCheckCredenza.tsx @@ -2,28 +2,11 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { HeadersInput } from "@app/components/HeadersInput"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; +import { Form } from "@/components/ui/form"; +import { HealthCheckFormFields } from "@app/components/HealthCheckFormFields"; import { Credenza, CredenzaBody, @@ -209,9 +192,6 @@ export default function StandaloneHealthCheckCredenza({ } }, [open]); - const watchedEnabled = form.watch("hcEnabled"); - const watchedMode = form.watch("hcMode"); - const onSubmit = async (values: FormValues) => { setLoading(true); try { @@ -286,553 +266,7 @@ export default function StandaloneHealthCheckCredenza({ onSubmit={form.handleSubmit(onSubmit)} className="space-y-6" > - {/* Name */} - ( - - - {t("standaloneHcNameLabel")} - - - - - - - )} - /> - - {/* Enable Health Check */} - ( - -
- - {t("enableHealthChecks")} - - - {t( - "enableHealthChecksDescription" - )} - -
- - - -
- )} - /> - - {watchedEnabled && ( -
- {/* Mode */} - ( - - - {t("healthCheckMode")} - - - - {t( - "healthCheckModeDescription" - )} - - - - )} - /> - - {/* Connection fields */} - {watchedMode === "tcp" ? ( -
- ( - - - {t("healthHostname")} - - - - - - - )} - /> - ( - - - {t("healthPort")} - - - - - - - )} - /> -
- ) : ( -
- ( - - - {t("healthScheme")} - - - - - )} - /> - ( - - - {t("healthHostname")} - - - - - - - )} - /> - ( - - - {t("healthPort")} - - - - - - - )} - /> - ( - - - {t("healthCheckPath")} - - - - - - - )} - /> -
- )} - - {/* HTTP Method */} - {watchedMode !== "tcp" && ( - ( - - - {t("httpMethod")} - - - - - )} - /> - )} - - {/* Check Interval, Unhealthy Interval, and Timeout */} -
- ( - - - {t( - "healthyIntervalSeconds" - )} - - - - field.onChange( - parseInt( - e.target - .value - ) - ) - } - /> - - - - )} - /> - - ( - - - {t( - "unhealthyIntervalSeconds" - )} - - - - field.onChange( - parseInt( - e.target - .value - ) - ) - } - /> - - - - )} - /> - - ( - - - {t("timeoutSeconds")} - - - - field.onChange( - parseInt( - e.target - .value - ) - ) - } - /> - - - - )} - /> -
- - {/* Healthy and Unhealthy Thresholds */} -
- ( - - - {t("healthyThreshold")} - - - - field.onChange( - parseInt( - e.target - .value - ) - ) - } - /> - - - {t( - "healthyThresholdDescription" - )} - - - - )} - /> - - ( - - - {t("unhealthyThreshold")} - - - - field.onChange( - parseInt( - e.target - .value - ) - ) - } - /> - - - {t( - "unhealthyThresholdDescription" - )} - - - - )} - /> -
- - {/* HTTP-only fields */} - {watchedMode !== "tcp" && ( - <> - {/* Expected Response Code */} - ( - - - {t( - "expectedResponseCodes" - )} - - - { - const val = - e.target - .value; - field.onChange( - val - ? parseInt( - val - ) - : null - ); - }} - /> - - - {t( - "expectedResponseCodesDescription" - )} - - - - )} - /> - - {/* TLS Server Name */} - ( - - - {t("tlsServerName")} - - - - - - {t( - "tlsServerNameDescription" - )} - - - - )} - /> - - {/* Custom Headers */} - ( - - - {t("customHeaders")} - - - - - - {t( - "customHeadersDescription" - )} - - - - )} - /> - - {/* Follow Redirects */} - ( - -
- - {t( - "followRedirects" - )} - - - {t( - "followRedirectsDescription" - )} - -
- - - -
- )} - /> - - )} -
- )} + From a9d68bd0cf3af614fabe2ae8dd8e300577fc21b7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 16:20:30 -0700 Subject: [PATCH 073/176] Standardize the healch check form between the two --- messages/en-US.json | 4 +- .../resources/proxy/[niceId]/proxy/page.tsx | 8 +- .../settings/resources/proxy/create/page.tsx | 10 +- src/components/HealthCheckCredenza.tsx | 417 ++++++++++++++++++ src/components/HealthCheckDialog.tsx | 223 ---------- src/components/HealthCheckFormFields.tsx | 53 ++- src/components/HealthChecksTable.tsx | 9 +- .../StandaloneHealthCheckCredenza.tsx | 290 ------------ 8 files changed, 462 insertions(+), 552 deletions(-) create mode 100644 src/components/HealthCheckCredenza.tsx delete mode 100644 src/components/HealthCheckDialog.tsx delete mode 100644 src/components/StandaloneHealthCheckCredenza.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 33003e434..0b4dde100 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3027,5 +3027,7 @@ "httpDestUpdatedSuccess": "Destination updated successfully", "httpDestCreatedSuccess": "Destination created successfully", "httpDestUpdateFailed": "Failed to update destination", - "httpDestCreateFailed": "Failed to create destination" + "httpDestCreateFailed": "Failed to create destination", + "followRedirects": "Follow Redirects", + "followRedirectsDescription": "Automatically follow HTTP redirects for requests." } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 8c3d4910c..f43ad1543 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1,6 +1,6 @@ "use client"; -import HealthCheckDialog from "@/components/HealthCheckDialog"; +import HealthCheckCredenza from "@/components/HealthCheckCredenza"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -965,10 +965,10 @@ function ProxyResourceTargetsForm({ {selectedTargetForHealthCheck && ( - {selectedTargetForHealthCheck && ( - void; + orgId?: string; + targetAddress: string; + targetMethod?: string; + initialConfig?: Partial; + onChanges: (config: HealthCheckConfig) => Promise; + } + | { + mode: "submit"; + open: boolean; + setOpen: (v: boolean) => void; + orgId: string; + initialValues?: HealthCheckRow | null; + onSaved: () => void; + }; + +const DEFAULT_VALUES = { + name: "", + hcEnabled: true, + hcMode: "http", + hcScheme: "https", + hcMethod: "GET", + hcHostname: "", + hcPort: "", + hcPath: "/", + hcInterval: 30, + hcUnhealthyInterval: 30, + hcTimeout: 5, + hcHealthyThreshold: 1, + hcUnhealthyThreshold: 1, + hcFollowRedirects: true, + hcTlsServerName: "", + hcStatus: null as number | null, + hcHeaders: [] as { name: string; value: string }[] +}; + +export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { + const { mode, open, setOpen, orgId } = props; + + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const healthCheckSchema = z + .object({ + ...(mode === "submit" + ? { + name: z + .string() + .min(1, { message: t("standaloneHcNameLabel") }) + } + : {}), + hcEnabled: z.boolean(), + hcPath: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z + .int() + .positive() + .min(5, { message: t("healthCheckIntervalMin") }), + hcTimeout: z + .int() + .positive() + .min(1, { message: t("healthCheckTimeoutMin") }), + hcStatus: z.int().positive().min(100).optional().nullable(), + hcHeaders: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcScheme: z.string().optional(), + hcHostname: z.string(), + hcPort: z + .string() + .min(1, { message: t("healthCheckPortInvalid") }) + .refine( + (val) => { + const port = parseInt(val); + return port > 0 && port <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ), + hcFollowRedirects: z.boolean(), + hcMode: z.string(), + hcUnhealthyInterval: z.int().positive().min(5), + hcTlsServerName: z.string(), + hcHealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckHealthyThresholdMin") }), + hcUnhealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckUnhealthyThresholdMin") }) + }) + .superRefine((data, ctx) => { + if (data.hcMode !== "tcp") { + if (!data.hcPath || data.hcPath.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckPathRequired"), + path: ["hcPath"] + }); + } + if (!data.hcMethod || data.hcMethod.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckMethodRequired"), + path: ["hcMethod"] + }); + } + } + }); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(healthCheckSchema), + defaultValues: mode === "submit" ? DEFAULT_VALUES : {} + }); + + useEffect(() => { + if (!open) return; + + if (mode === "autoSave") { + const { initialConfig, targetMethod } = props; + + const getDefaultScheme = () => { + if (initialConfig?.hcScheme) return initialConfig.hcScheme; + if (targetMethod === "https") return "https"; + return "http"; + }; + + form.reset({ + hcEnabled: initialConfig?.hcEnabled, + hcPath: initialConfig?.hcPath, + hcMethod: initialConfig?.hcMethod, + hcInterval: initialConfig?.hcInterval, + hcTimeout: initialConfig?.hcTimeout, + hcStatus: initialConfig?.hcStatus, + hcHeaders: initialConfig?.hcHeaders, + hcScheme: getDefaultScheme(), + hcHostname: initialConfig?.hcHostname, + hcPort: initialConfig?.hcPort + ? initialConfig.hcPort.toString() + : "", + hcFollowRedirects: initialConfig?.hcFollowRedirects, + hcMode: initialConfig?.hcMode ?? "http", + hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval, + hcTlsServerName: initialConfig?.hcTlsServerName ?? "", + hcHealthyThreshold: initialConfig?.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: initialConfig?.hcUnhealthyThreshold ?? 1 + }); + } else { + const { initialValues } = props; + + if (initialValues) { + let parsedHeaders: { name: string; value: string }[] = []; + if (initialValues.hcHeaders) { + try { + parsedHeaders = JSON.parse(initialValues.hcHeaders); + } catch { + parsedHeaders = []; + } + } + + form.reset({ + name: initialValues.name, + hcEnabled: initialValues.hcEnabled, + hcMode: initialValues.hcMode ?? "http", + hcScheme: initialValues.hcScheme ?? "https", + hcMethod: initialValues.hcMethod ?? "GET", + hcHostname: initialValues.hcHostname ?? "", + hcPort: initialValues.hcPort + ? initialValues.hcPort.toString() + : "", + hcPath: initialValues.hcPath ?? "/", + hcInterval: initialValues.hcInterval ?? 30, + hcUnhealthyInterval: + initialValues.hcUnhealthyInterval ?? 30, + hcTimeout: initialValues.hcTimeout ?? 5, + hcHealthyThreshold: + initialValues.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: + initialValues.hcUnhealthyThreshold ?? 1, + hcFollowRedirects: + initialValues.hcFollowRedirects ?? true, + hcTlsServerName: initialValues.hcTlsServerName ?? "", + hcStatus: initialValues.hcStatus ?? null, + hcHeaders: parsedHeaders + }); + } else { + form.reset(DEFAULT_VALUES); + } + } + }, [open]); + + const handleFieldChange = async (fieldName: string, value: any) => { + if (mode !== "autoSave") return; + try { + const currentValues = form.getValues(); + const updatedValues = { ...currentValues, [fieldName]: value }; + + const configToSend: HealthCheckConfig = { + ...updatedValues, + hcPath: updatedValues.hcPath ?? "", + hcMethod: updatedValues.hcMethod ?? "", + hcPort: parseInt(updatedValues.hcPort), + hcStatus: updatedValues.hcStatus || null, + hcHealthyThreshold: updatedValues.hcHealthyThreshold, + hcUnhealthyThreshold: updatedValues.hcUnhealthyThreshold + }; + + await props.onChanges(configToSend); + } catch (error) { + toast({ + title: t("healthCheckError"), + description: t("healthCheckErrorDescription"), + variant: "destructive" + }); + } + }; + + const onSubmit = async (values: FormValues) => { + if (mode !== "submit") return; + const { initialValues, onSaved } = props; + + setLoading(true); + try { + const payload = { + name: (values as any).name, + hcEnabled: values.hcEnabled, + hcMode: values.hcMode, + hcScheme: values.hcScheme, + hcMethod: values.hcMethod, + hcHostname: values.hcHostname, + hcPort: parseInt(values.hcPort), + hcPath: values.hcPath ?? "", + hcInterval: values.hcInterval, + hcUnhealthyInterval: values.hcUnhealthyInterval, + hcTimeout: values.hcTimeout, + hcHealthyThreshold: values.hcHealthyThreshold, + hcUnhealthyThreshold: values.hcUnhealthyThreshold, + hcFollowRedirects: values.hcFollowRedirects, + hcTlsServerName: values.hcTlsServerName, + hcStatus: values.hcStatus || null, + hcHeaders: + values.hcHeaders && values.hcHeaders.length > 0 + ? JSON.stringify(values.hcHeaders) + : null + }; + + if (initialValues) { + await api.post( + `/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`, + payload + ); + } else { + await api.put(`/org/${orgId}/health-check`, payload); + } + + toast({ title: t("standaloneHcSaved") }); + onSaved(); + setOpen(false); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + const isEditing = mode === "submit" && !!(props as any).initialValues; + + const title = + mode === "autoSave" + ? t("configureHealthCheck") + : isEditing + ? t("standaloneHcEditTitle") + : t("standaloneHcCreateTitle"); + + const description = + mode === "autoSave" + ? t("configureHealthCheckDescription", { + target: (props as any).targetAddress + }) + : t("standaloneHcDescription"); + + return ( + + + + {title} + {description} + + +
+ + + + +
+ + {mode === "autoSave" ? ( + + ) : ( + <> + + + + + + )} + +
+
+ ); +} + +export default HealthCheckCredenza; \ No newline at end of file diff --git a/src/components/HealthCheckDialog.tsx b/src/components/HealthCheckDialog.tsx deleted file mode 100644 index d441cdaf3..000000000 --- a/src/components/HealthCheckDialog.tsx +++ /dev/null @@ -1,223 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Form } from "@/components/ui/form"; -import { HealthCheckFormFields } from "@app/components/HealthCheckFormFields"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@/components/Credenza"; -import { toast } from "@/hooks/useToast"; -import { useTranslations } from "next-intl"; - -type HealthCheckConfig = { - hcEnabled: boolean; - hcPath: string; - hcMethod: string; - hcInterval: number; - hcTimeout: number; - hcStatus: number | null; - hcHeaders?: { name: string; value: string }[] | null; - hcScheme?: string; - hcHostname: string; - hcPort: number; - hcFollowRedirects: boolean; - hcMode: string; - hcUnhealthyInterval: number; - hcTlsServerName: string; - hcHealthyThreshold: number; - hcUnhealthyThreshold: number; -}; - -type HealthCheckDialogProps = { - open: boolean; - setOpen: (val: boolean) => void; - orgId: string; - targetAddress: string; - targetMethod?: string; - initialConfig?: Partial; - onChanges: (config: HealthCheckConfig) => Promise; -}; - -export default function HealthCheckDialog({ - open, - setOpen, - orgId, - targetAddress, - targetMethod, - initialConfig, - onChanges -}: HealthCheckDialogProps) { - const t = useTranslations(); - - const healthCheckSchema = z - .object({ - hcEnabled: z.boolean(), - hcPath: z.string().optional(), - hcMethod: z.string().optional(), - hcInterval: z - .int() - .positive() - .min(5, { message: t("healthCheckIntervalMin") }), - hcTimeout: z - .int() - .positive() - .min(1, { message: t("healthCheckTimeoutMin") }), - hcStatus: z.int().positive().min(100).optional().nullable(), - hcHeaders: z - .array(z.object({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcScheme: z.string().optional(), - hcHostname: z.string(), - hcPort: z - .string() - .min(1, { message: t("healthCheckPortInvalid") }) - .refine( - (val) => { - const port = parseInt(val); - return port > 0 && port <= 65535; - }, - { - message: t("healthCheckPortInvalid") - } - ), - hcFollowRedirects: z.boolean(), - hcMode: z.string(), - hcUnhealthyInterval: z.int().positive().min(5), - hcTlsServerName: z.string(), - hcHealthyThreshold: z - .int() - .positive() - .min(1, { - message: t("healthCheckHealthyThresholdMin") - }), - hcUnhealthyThreshold: z - .int() - .positive() - .min(1, { - message: t("healthCheckUnhealthyThresholdMin") - }) - }) - .superRefine((data, ctx) => { - if (data.hcMode !== "tcp") { - if (!data.hcPath || data.hcPath.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("healthCheckPathRequired"), - path: ["hcPath"] - }); - } - if (!data.hcMethod || data.hcMethod.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("healthCheckMethodRequired"), - path: ["hcMethod"] - }); - } - } - }); - - const form = useForm>({ - resolver: zodResolver(healthCheckSchema), - defaultValues: {} - }); - - useEffect(() => { - if (!open) return; - - const getDefaultScheme = () => { - if (initialConfig?.hcScheme) { - return initialConfig.hcScheme; - } - if (targetMethod === "https") { - return "https"; - } - return "http"; - }; - - form.reset({ - hcEnabled: initialConfig?.hcEnabled, - hcPath: initialConfig?.hcPath, - hcMethod: initialConfig?.hcMethod, - hcInterval: initialConfig?.hcInterval, - hcTimeout: initialConfig?.hcTimeout, - hcStatus: initialConfig?.hcStatus, - hcHeaders: initialConfig?.hcHeaders, - hcScheme: getDefaultScheme(), - hcHostname: initialConfig?.hcHostname, - hcPort: initialConfig?.hcPort - ? initialConfig.hcPort.toString() - : "", - hcFollowRedirects: initialConfig?.hcFollowRedirects, - hcMode: initialConfig?.hcMode ?? "http", - hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval, - hcTlsServerName: initialConfig?.hcTlsServerName ?? "", - hcHealthyThreshold: initialConfig?.hcHealthyThreshold ?? 1, - hcUnhealthyThreshold: initialConfig?.hcUnhealthyThreshold ?? 1 - }); - }, [open]); - - const handleFieldChange = async (fieldName: string, value: any) => { - try { - const currentValues = form.getValues(); - const updatedValues = { ...currentValues, [fieldName]: value }; - - const configToSend: HealthCheckConfig = { - ...updatedValues, - hcPath: updatedValues.hcPath ?? "", - hcMethod: updatedValues.hcMethod ?? "", - hcPort: parseInt(updatedValues.hcPort), - hcStatus: updatedValues.hcStatus || null, - hcHealthyThreshold: updatedValues.hcHealthyThreshold, - hcUnhealthyThreshold: updatedValues.hcUnhealthyThreshold - }; - - await onChanges(configToSend); - } catch (error) { - toast({ - title: t("healthCheckError"), - description: t("healthCheckErrorDescription"), - variant: "destructive" - }); - } - }; - - return ( - - - - {t("configureHealthCheck")} - - {t("configureHealthCheckDescription", { - target: targetAddress - })} - - - -
- - - - -
- - - -
-
- ); -} diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index 9873f9c5d..db98948db 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -25,16 +25,19 @@ type HealthCheckFormFieldsProps = { form: UseFormReturn; onFieldChange?: (fieldName: string, value: any) => void; showNameField?: boolean; + hideEnabledField?: boolean; }; export function HealthCheckFormFields({ form, onFieldChange, - showNameField + showNameField, + hideEnabledField }: HealthCheckFormFieldsProps) { const t = useTranslations(); const watchedEnabled = form.watch("hcEnabled"); + const showFields = hideEnabledField || watchedEnabled; const watchedMode = form.watch("hcMode"); const handleChange = (fieldName: string, value: any, fieldOnChange: (v: any) => void) => { @@ -67,30 +70,32 @@ export function HealthCheckFormFields({ )} {/* Enable Health Checks */} - ( - -
- {t("enableHealthChecks")} - - {t("enableHealthChecksDescription")} - -
- - - handleChange("hcEnabled", value, field.onChange) - } - /> - -
- )} - /> + {!hideEnabledField && ( + ( + +
+ {t("enableHealthChecks")} + + {t("enableHealthChecksDescription")} + +
+ + + handleChange("hcEnabled", value, field.onChange) + } + /> + +
+ )} + /> + )} - {watchedEnabled && ( + {showFields && (
{/* Mode */} )} - { setCredenzaOpen(val); diff --git a/src/components/StandaloneHealthCheckCredenza.tsx b/src/components/StandaloneHealthCheckCredenza.tsx deleted file mode 100644 index 99260707d..000000000 --- a/src/components/StandaloneHealthCheckCredenza.tsx +++ /dev/null @@ -1,290 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Form } from "@/components/ui/form"; -import { HealthCheckFormFields } from "@app/components/HealthCheckFormFields"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; - -export type HealthCheckRow = { - targetHealthCheckId: number; - name: string; - hcEnabled: boolean; - hcHealth: "unknown" | "healthy" | "unhealthy"; - hcMode: string | null; - hcHostname: string | null; - hcPort: number | null; - hcPath: string | null; - hcScheme: string | null; - hcMethod: string | null; - hcInterval: number | null; - hcUnhealthyInterval: number | null; - hcTimeout: number | null; - hcHeaders: string | null; - hcFollowRedirects: boolean | null; - hcStatus: number | null; - hcTlsServerName: string | null; - hcHealthyThreshold: number | null; - hcUnhealthyThreshold: number | null; -}; - -type StandaloneHealthCheckCredenzaProps = { - open: boolean; - setOpen: (v: boolean) => void; - orgId: string; - initialValues?: HealthCheckRow | null; - onSaved: () => void; -}; - -const DEFAULT_VALUES = { - name: "", - hcEnabled: true, - hcMode: "http", - hcScheme: "https", - hcMethod: "GET", - hcHostname: "", - hcPort: "", - hcPath: "/", - hcInterval: 30, - hcUnhealthyInterval: 30, - hcTimeout: 5, - hcHealthyThreshold: 1, - hcUnhealthyThreshold: 1, - hcFollowRedirects: true, - hcTlsServerName: "", - hcStatus: null as number | null, - hcHeaders: [] as { name: string; value: string }[] -}; - -export default function StandaloneHealthCheckCredenza({ - open, - setOpen, - orgId, - initialValues, - onSaved -}: StandaloneHealthCheckCredenzaProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const [loading, setLoading] = useState(false); - - const healthCheckSchema = z - .object({ - name: z.string().min(1, { message: t("standaloneHcNameLabel") }), - hcEnabled: z.boolean(), - hcPath: z.string().optional(), - hcMethod: z.string().optional(), - hcInterval: z - .int() - .positive() - .min(5, { message: t("healthCheckIntervalMin") }), - hcTimeout: z - .int() - .positive() - .min(1, { message: t("healthCheckTimeoutMin") }), - hcStatus: z.int().positive().min(100).optional().nullable(), - hcHeaders: z - .array(z.object({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcScheme: z.string().optional(), - hcHostname: z.string(), - hcPort: z - .string() - .min(1, { message: t("healthCheckPortInvalid") }) - .refine( - (val) => { - const port = parseInt(val); - return port > 0 && port <= 65535; - }, - { message: t("healthCheckPortInvalid") } - ), - hcFollowRedirects: z.boolean(), - hcMode: z.string(), - hcUnhealthyInterval: z.int().positive().min(5), - hcTlsServerName: z.string(), - hcHealthyThreshold: z - .int() - .positive() - .min(1, { message: t("healthCheckHealthyThresholdMin") }), - hcUnhealthyThreshold: z - .int() - .positive() - .min(1, { message: t("healthCheckUnhealthyThresholdMin") }) - }) - .superRefine((data, ctx) => { - if (data.hcMode !== "tcp") { - if (!data.hcPath || data.hcPath.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("healthCheckPathRequired"), - path: ["hcPath"] - }); - } - if (!data.hcMethod || data.hcMethod.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("healthCheckMethodRequired"), - path: ["hcMethod"] - }); - } - } - }); - - type FormValues = z.infer; - - const form = useForm({ - resolver: zodResolver(healthCheckSchema), - defaultValues: DEFAULT_VALUES - }); - - useEffect(() => { - if (!open) return; - - if (initialValues) { - let parsedHeaders: { name: string; value: string }[] = []; - if (initialValues.hcHeaders) { - try { - parsedHeaders = JSON.parse(initialValues.hcHeaders); - } catch { - parsedHeaders = []; - } - } - - form.reset({ - name: initialValues.name, - hcEnabled: initialValues.hcEnabled, - hcMode: initialValues.hcMode ?? "http", - hcScheme: initialValues.hcScheme ?? "https", - hcMethod: initialValues.hcMethod ?? "GET", - hcHostname: initialValues.hcHostname ?? "", - hcPort: initialValues.hcPort - ? initialValues.hcPort.toString() - : "", - hcPath: initialValues.hcPath ?? "/", - hcInterval: initialValues.hcInterval ?? 30, - hcUnhealthyInterval: initialValues.hcUnhealthyInterval ?? 30, - hcTimeout: initialValues.hcTimeout ?? 5, - hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1, - hcUnhealthyThreshold: initialValues.hcUnhealthyThreshold ?? 1, - hcFollowRedirects: initialValues.hcFollowRedirects ?? true, - hcTlsServerName: initialValues.hcTlsServerName ?? "", - hcStatus: initialValues.hcStatus ?? null, - hcHeaders: parsedHeaders - }); - } else { - form.reset(DEFAULT_VALUES); - } - }, [open]); - - const onSubmit = async (values: FormValues) => { - setLoading(true); - try { - const payload = { - name: values.name, - hcEnabled: values.hcEnabled, - hcMode: values.hcMode, - hcScheme: values.hcScheme, - hcMethod: values.hcMethod, - hcHostname: values.hcHostname, - hcPort: parseInt(values.hcPort), - hcPath: values.hcPath ?? "", - hcInterval: values.hcInterval, - hcUnhealthyInterval: values.hcUnhealthyInterval, - hcTimeout: values.hcTimeout, - hcHealthyThreshold: values.hcHealthyThreshold, - hcUnhealthyThreshold: values.hcUnhealthyThreshold, - hcFollowRedirects: values.hcFollowRedirects, - hcTlsServerName: values.hcTlsServerName, - hcStatus: values.hcStatus || null, - hcHeaders: - values.hcHeaders && values.hcHeaders.length > 0 - ? JSON.stringify(values.hcHeaders) - : null - }; - - if (initialValues) { - await api.post( - `/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`, - payload - ); - } else { - await api.put( - `/org/${orgId}/health-check`, - payload - ); - } - - toast({ title: t("standaloneHcSaved") }); - onSaved(); - setOpen(false); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - const isEditing = !!initialValues; - - return ( - - - - - {isEditing - ? t("standaloneHcEditTitle") - : t("standaloneHcCreateTitle")} - - - {t("standaloneHcDescription")} - - - -
- - - - -
- - - - - - -
-
- ); -} From c4308aaa69464c6ae8a734a18542a04fe9686800 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 16:30:28 -0700 Subject: [PATCH 074/176] Working on ui --- src/components/HealthCheckCredenza.tsx | 5 +++ src/components/HealthCheckFormFields.tsx | 8 +++-- src/components/HealthChecksTable.tsx | 46 +++++++++++++++--------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index 18ba63f6e..597be7c0b 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -186,6 +186,9 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { defaultValues: mode === "submit" ? DEFAULT_VALUES : {} }); + const watchedEnabled = form.watch("hcEnabled"); + const watchedMode = form.watch("hcMode"); + useEffect(() => { if (!open) return; @@ -378,6 +381,8 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { form={form} showNameField={mode === "submit"} hideEnabledField={mode === "submit"} + watchedEnabled={watchedEnabled} + watchedMode={watchedMode} onFieldChange={ mode === "autoSave" ? handleFieldChange diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index db98948db..249fb2e24 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -26,19 +26,21 @@ type HealthCheckFormFieldsProps = { onFieldChange?: (fieldName: string, value: any) => void; showNameField?: boolean; hideEnabledField?: boolean; + watchedEnabled?: boolean; + watchedMode?: string; }; export function HealthCheckFormFields({ form, onFieldChange, showNameField, - hideEnabledField + hideEnabledField, + watchedEnabled, + watchedMode }: HealthCheckFormFieldsProps) { const t = useTranslations(); - const watchedEnabled = form.watch("hcEnabled"); const showFields = hideEnabledField || watchedEnabled; - const watchedMode = form.watch("hcMode"); const handleChange = (fieldName: string, value: any, fieldOnChange: (v: any) => void) => { fieldOnChange(value); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 1238d9973..08de63927 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -22,7 +22,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; - +import { Span } from "next/dist/trace"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -57,12 +57,6 @@ const healthVariant: Record< unknown: "secondary" }; -function HealthBadge({ health }: { health: HealthCheckRow["hcHealth"] }) { - return ( - {healthLabel[health]} - ); -} - export default function HealthChecksTable({ orgId }: StandaloneHealthChecksTableProps) { @@ -146,7 +140,7 @@ export default function HealthChecksTable({ ), cell: ({ row }) => ( - {row.original.name} + {row.original.name} ) }, { @@ -156,7 +150,7 @@ export default function HealthChecksTable({ {t("standaloneHcColumnMode")} ), cell: ({ row }) => ( - + {row.original.hcMode?.toUpperCase() ?? "—"} ) @@ -167,11 +161,7 @@ export default function HealthChecksTable({ header: () => ( {t("standaloneHcColumnTarget")} ), - cell: ({ row }) => ( - - {formatTarget(row.original)} - - ) + cell: ({ row }) => {formatTarget(row.original)} }, { id: "health", @@ -179,9 +169,31 @@ export default function HealthChecksTable({ header: () => ( {t("standaloneHcColumnHealth")} ), - cell: ({ row }) => ( - - ) + cell: ({ row }) => { + const health = row.original.hcHealth; + if (health === "healthy") { + return ( + +
+ {healthLabel.healthy} +
+ ); + } else if (health === "unhealthy") { + return ( + +
+ {healthLabel.unhealthy} +
+ ); + } else { + return ( + +
+ {healthLabel.unknown} +
+ ); + } + } }, { accessorKey: "hcEnabled", From 597cae2b78cba47d115c990d100e8c0513f3a683 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 16:53:18 -0700 Subject: [PATCH 075/176] Poll for status to show updates --- .../resources/proxy/[niceId]/proxy/page.tsx | 24 +++++++++++++++++++ src/components/HealthChecksTable.tsx | 5 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index f43ad1543..b0e044699 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -168,6 +168,30 @@ function ProxyResourceTargetsForm({ const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); + + const { data: polledTargets } = useQuery({ + ...resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }), + refetchInterval: 10_000 + }); + + useEffect(() => { + if (!polledTargets) return; + setTargets((prev) => + prev.map((t) => { + const fresh = polledTargets.find( + (p) => p.targetId === t.targetId + ); + if (!fresh) return t; + return { + ...t, + hcHealth: fresh.hcHealth, + hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled + }; + }) + ); + }, [polledTargets]); const [dockerStates, setDockerStates] = useState>( new Map() ); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 08de63927..eff3a6d22 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -74,7 +74,10 @@ export default function HealthChecksTable({ isLoading, refetch, isRefetching - } = useQuery(orgQueries.standaloneHealthChecks({ orgId })); + } = useQuery({ + ...orgQueries.standaloneHealthChecks({ orgId }), + refetchInterval: 10_000 + }); const invalidate = () => queryClient.invalidateQueries( From b958537f3ed4eab63249b2206eda2b09fc1fa5f9 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 17:05:25 -0700 Subject: [PATCH 076/176] Adjust the form --- messages/en-US.json | 5 +- src/components/HealthCheckFormFields.tsx | 328 ++++++++++++----------- 2 files changed, 171 insertions(+), 162 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0b4dde100..5d53ea03b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1862,6 +1862,7 @@ "healthCheckTimeoutMin": "Timeout must be at least 1 second", "healthCheckRetryMin": "Retry attempts must be at least 1", "healthCheckMode": "Check Mode", + "healthCheckStrategy": "Strategy", "healthCheckModeDescription": "TCP mode verifies connectivity only. HTTP mode validates the HTTP response.", "healthyThreshold": "Healthy Threshold", "healthyThresholdDescription": "Consecutive successes required before marking as healthy.", @@ -1869,8 +1870,8 @@ "unhealthyThresholdDescription": "Consecutive failures required before marking as unhealthy.", "healthCheckHealthyThresholdMin": "Healthy threshold must be at least 1", "healthCheckUnhealthyThresholdMin": "Unhealthy threshold must be at least 1", - "httpMethod": "HTTP Method", - "selectHttpMethod": "Select HTTP method", + "httpMethod": "Scheme", + "selectHttpMethod": "Select scheme", "domainPickerSubdomainLabel": "Subdomain", "domainPickerBaseDomainLabel": "Base Domain", "domainPickerSearchDomains": "Search domains...", diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index 249fb2e24..f6f32dd34 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -78,11 +78,8 @@ export function HealthCheckFormFields({ name="hcEnabled" render={({ field }) => ( -
+
{t("enableHealthChecks")} - - {t("enableHealthChecksDescription")} -
- {/* Mode */} + {/* Strategy */} ( - {t("healthCheckMode")} + {t("healthCheckStrategy")} - - {t("healthCheckModeDescription")} - )} @@ -164,6 +158,9 @@ export function HealthCheckFormFields({ { const value = e.target.value; handleChange("hcPort", value, field.onChange); @@ -176,7 +173,7 @@ export function HealthCheckFormFields({ />
) : ( -
+
{ const value = e.target.value; handleChange("hcPort", value, field.onChange); @@ -246,6 +246,43 @@ export function HealthCheckFormFields({ )} /> +
+ )} + + {/* HTTP Method + Timeout (shown when not TCP) */} + {watchedMode !== "tcp" && ( +
+ ( + + {t("httpMethod")} + + + + )} + /> )} /> + ( + + {t("timeoutSeconds")} + + { + const value = parseInt(e.target.value); + handleChange("hcTimeout", value, field.onChange); + }} + /> + + + + )} + />
)} - {/* HTTP Method */} - {watchedMode !== "tcp" && ( + {/* TCP timeout (shown only for TCP) */} + {watchedMode === "tcp" && ( ( - {t("httpMethod")} - + {t("timeoutSeconds")} + + { + const value = parseInt(e.target.value); + handleChange("hcTimeout", value, field.onChange); + }} + /> + )} /> )} - {/* Check Interval, Unhealthy Interval, and Timeout */} -
+ {/* Healthy interval + healthy threshold */} +
)} /> + ( + + {t("healthyThreshold")} + + { + const value = parseInt(e.target.value); + handleChange( + "hcHealthyThreshold", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ {/* Unhealthy interval + unhealthy threshold */} +
)} /> - - ( - - {t("timeoutSeconds")} - - { - const value = parseInt(e.target.value); - handleChange("hcTimeout", value, field.onChange); - }} - /> - - - - )} - /> -
- - {/* Healthy and Unhealthy Thresholds */} -
- ( - - {t("healthyThreshold")} - - { - const value = parseInt(e.target.value); - handleChange( - "hcHealthyThreshold", - value, - field.onChange - ); - }} - /> - - - {t("healthyThresholdDescription")} - - - - )} - /> - - - {t("unhealthyThresholdDescription")} - )} @@ -438,55 +455,74 @@ export function HealthCheckFormFields({ {/* HTTP-only fields */} {watchedMode !== "tcp" && ( <> - {/* Expected Response Code */} - ( - - {t("expectedResponseCodes")} - - { - const val = e.target.value; - const value = val ? parseInt(val) : null; - handleChange("hcStatus", value, field.onChange); - }} - /> - - - {t("expectedResponseCodesDescription")} - - - - )} - /> + {/* Expected Response Codes + TLS Server Name + Follow Redirects */} +
+ ( + + {t("expectedResponseCodes")} + + { + const val = e.target.value; + const value = val ? parseInt(val) : null; + handleChange("hcStatus", value, field.onChange); + }} + /> + + + + )} + /> + ( + + {t("tlsServerName")} + + + handleChange( + "hcTlsServerName", + e.target.value, + (v) => field.onChange(e) + ) + } + /> + + + + )} + /> +
- {/* TLS Server Name */} + {/* Follow Redirects inline toggle */} ( - - {t("tlsServerName")} + + + {t("followRedirects")} + - + handleChange( - "hcTlsServerName", - e.target.value, - (v) => field.onChange(e) + "hcFollowRedirects", + value, + field.onChange ) } /> - - {t("tlsServerNameDescription")} - - )} /> @@ -518,34 +554,6 @@ export function HealthCheckFormFields({ )} /> - - {/* Follow Redirects */} - ( - -
- {t("followRedirects")} - - {t("followRedirectsDescription")} - -
- - - handleChange( - "hcFollowRedirects", - value, - field.onChange - ) - } - /> - -
- )} - /> )}
From d6c15c8b81b72b36a97b0aa72250f2044d1ff072 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 17:42:30 -0700 Subject: [PATCH 077/176] =?UTF-8?q?Add=20resource=20column=20to=20hc=20and?= =?UTF-8?q?=20remove=20=E2=80=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +-- license_header_checker.py | 6 +-- messages/bg-BG.json | 6 +-- messages/cs-CZ.json | 2 +- messages/de-DE.json | 4 +- messages/en-US.json | 14 +++---- messages/es-ES.json | 6 +-- messages/fr-FR.json | 8 ++-- messages/it-IT.json | 8 ++-- messages/ko-KR.json | 6 +-- messages/nb-NO.json | 2 +- messages/nl-NL.json | 2 +- messages/pl-PL.json | 6 +-- messages/pt-PT.json | 8 ++-- messages/ru-RU.json | 12 +++--- messages/tr-TR.json | 4 +- messages/zh-CN.json | 8 ++-- messages/zh-TW.json | 4 +- server/db/pg/schema/schema.ts | 8 ++-- server/db/sqlite/schema/schema.ts | 11 ++++-- server/emails/templates/AlertNotification.tsx | 4 +- .../EnterpriseEditionKeyGenerated.tsx | 2 +- server/lib/rebuildClientAssociations.ts | 6 +-- server/lib/traefik/TraefikConfigManager.ts | 6 +-- server/lib/traefik/pathEncoding.test.ts | 32 ++++++++-------- server/private/lib/acmeCertSync.ts | 4 +- server/private/lib/logConnectionAudit.ts | 8 ++-- .../providers/HttpLogDestination.ts | 8 ++-- server/private/lib/logStreaming/types.ts | 6 +-- .../private/lib/traefik/getTraefikConfig.ts | 6 +-- .../routers/healthChecks/listHealthChecks.ts | 35 +++++++++++++++-- server/routers/gerbil/receiveBandwidth.ts | 18 ++++----- server/routers/healthChecks/types.ts | 3 ++ .../newt/handleReceiveBandwidthMessage.ts | 6 +-- server/routers/newt/offlineChecker.ts | 4 +- server/routers/newt/pingAccumulator.ts | 6 +-- server/routers/newt/registerNewt.ts | 2 +- .../handleOlmServerInitAddPeerHandshake.ts | 4 +- server/routers/target/createTarget.ts | 2 +- server/routers/ws/messageHandlers.ts | 2 +- .../[orgId]/settings/logs/connection/page.tsx | 28 +++++++------- .../[orgId]/settings/logs/streaming/page.tsx | 2 +- .../resources/proxy/[niceId]/proxy/page.tsx | 34 +++-------------- .../[orgId]/settings/sites/create/page.tsx | 4 +- src/components/ClientInfoCard.tsx | 2 +- src/components/ClientResourcesTable.tsx | 4 +- src/components/ExitNodeInfoCard.tsx | 2 +- src/components/ExitNodesTable.tsx | 2 +- src/components/HealthCheckCredenza.tsx | 3 ++ src/components/HealthCheckFormFields.tsx | 38 +++++++++++-------- src/components/HealthChecksTable.tsx | 31 ++++++++++++--- src/components/LocaleSwitcherSelect.tsx | 2 +- src/components/MachineClientsTable.tsx | 2 +- src/components/PendingSitesTable.tsx | 2 +- src/components/ResourceInfoBox.tsx | 2 +- src/components/SiteInfoCard.tsx | 2 +- src/components/SitesTable.tsx | 2 +- src/components/UserDevicesTable.tsx | 2 +- src/lib/queries.ts | 3 ++ src/services/locale.ts | 6 +-- 60 files changed, 257 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index f11196f77..a4bfb9fe8 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG ## Deployment Options -- **Pangolin Cloud** — Fully managed service - no infrastructure required. -- **Self-Host: Community Edition** — Free, open source, and licensed under AGPL-3. -- **Self-Host: Enterprise Edition** — Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue. +- **Pangolin Cloud** - Fully managed service - no infrastructure required. +- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3. +- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue. ## Key Features diff --git a/license_header_checker.py b/license_header_checker.py index c173d693b..ab7ddf4d5 100644 --- a/license_header_checker.py +++ b/license_header_checker.py @@ -96,7 +96,7 @@ def process_directory(root_dir): if has_correct_header: print(f"Header up-to-date: {file_path}") else: - # Either no header exists or the header is outdated — write + # Either no header exists or the header is outdated - write # the correct one. action = "Replaced header in" if has_any_header else "Added header to" new_content = HEADER_NORMALIZED + '\n\n' + body @@ -106,7 +106,7 @@ def process_directory(root_dir): files_modified += 1 else: if has_any_header: - # Remove the header — it shouldn't be here. + # Remove the header - it shouldn't be here. with open(file_path, 'w', encoding='utf-8') as f: f.write(body) print(f"Removed header from: {file_path}") @@ -134,4 +134,4 @@ if __name__ == "__main__": print(f"Error: Directory '{target_directory}' not found.") sys.exit(1) - process_directory(os.path.abspath(target_directory)) \ No newline at end of file + process_directory(os.path.abspath(target_directory)) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index a44d1948f..d429f4bd6 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1993,7 +1993,7 @@ "description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри", "introTitle": "Управлявано Самостоятелно-хостван Панголиин", "introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.", - "introDetail": "С тази опция все още управлявате свой собствен Панголиин възел — вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:", + "introDetail": "С тази опция все още управлявате свой собствен Панголиин възел - вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:", "benefitSimplerOperations": { "title": "По-прости операции", "description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало." @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Разкриване на употреба", - "description": "Изберете лицензионен клас, който точно отразява вашата целена употреба. Персоналният лиценз позволява безплатно ползване на софтуера за индивидуална, некомерсиална или маломащабна комерсиална дейност с годишен брутен приход под 100,000 USD. Всяко ползване извън тези граници — включително ползване във фирма, организация или друга доходоносна среда — изисква валиден корпоративен лиценз и плащане на съответната лицензионна такса. Всички потребители, независимо дали са лични или корпоративни, трябва да спазват Условията на Fossorial Commercial License." + "description": "Изберете лицензионен клас, който точно отразява вашата целена употреба. Персоналният лиценз позволява безплатно ползване на софтуера за индивидуална, некомерсиална или маломащабна комерсиална дейност с годишен брутен приход под 100,000 USD. Всяко ползване извън тези граници - включително ползване във фирма, организация или друга доходоносна среда - изисква валиден корпоративен лиценз и плащане на съответната лицензионна такса. Всички потребители, независимо дали са лични или корпоративни, трябва да спазват Условията на Fossorial Commercial License." }, "trialPeriodInformation": { "title": "Информация за пробен период", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON масив", "httpDestFormatJsonArrayDescription": "Една заявка на партида, тялото е JSON масив. Съвместим с повечето общи уеб куки и Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Една заявка на партида, тялото е ново линии отделени JSON — един обект на ред, няма външен масив. Изисквано от Splunk HEC, Elastic / OpenSearch и Grafana.", + "httpDestFormatNdjsonDescription": "Една заявка на партида, тялото е ново линии отделени JSON - един обект на ред, няма външен масив. Изисквано от Splunk HEC, Elastic / OpenSearch и Grafana.", "httpDestFormatSingleTitle": "Едно събитие на заявка", "httpDestFormatSingleDescription": "Изпращат се отделни HTTP POST за всяко индивидуално събитие. Използвайте само за крайни точки, които не могат да обработват партиди.", "httpDestLogTypesTitle": "Видове логове", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 3a797e564..66cee2a8b 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1993,7 +1993,7 @@ "description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami", "introTitle": "Spravovaný Pangolin", "introDescription": "je možnost nasazení určená pro lidi, kteří chtějí jednoduchost a spolehlivost při zachování soukromých a samoobslužných dat.", - "introDetail": "Pomocí této volby stále provozujete vlastní uzel Pangolin — tunely, SSL terminály a provoz všech pobytů na vašem serveru. Rozdíl spočívá v tom, že řízení a monitorování se řeší prostřednictvím našeho cloudového panelu, který odemkne řadu výhod:", + "introDetail": "Pomocí této volby stále provozujete vlastní uzel Pangolin - tunely, SSL terminály a provoz všech pobytů na vašem serveru. Rozdíl spočívá v tom, že řízení a monitorování se řeší prostřednictvím našeho cloudového panelu, který odemkne řadu výhod:", "benefitSimplerOperations": { "title": "Jednoduchý provoz", "description": "Není třeba spouštět svůj vlastní poštovní server nebo nastavit komplexní upozornění. Ze schránky dostanete upozornění na zdravotní kontrolu a výpadek." diff --git a/messages/de-DE.json b/messages/de-DE.json index 2b5e92865..4ea6c9fe6 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Verwendungsanzeige", - "description": "Wählen Sie die Lizenz-Ebene, die Ihre beabsichtigte Nutzung genau widerspiegelt. Die Persönliche Lizenz erlaubt die freie Nutzung der Software für individuelle, nicht-kommerzielle oder kleine kommerzielle Aktivitäten mit jährlichen Brutto-Einnahmen von 100.000 USD. Über diese Grenzen hinausgehende Verwendungszwecke – einschließlich der Verwendung innerhalb eines Unternehmens, einer Organisation, oder eine andere umsatzgenerierende Umgebung — erfordert eine gültige Enterprise-Lizenz und die Zahlung der Lizenzgebühr. Alle Benutzer, ob Personal oder Enterprise, müssen die Fossorial Commercial License Bedingungen einhalten." + "description": "Wählen Sie die Lizenz-Ebene, die Ihre beabsichtigte Nutzung genau widerspiegelt. Die Persönliche Lizenz erlaubt die freie Nutzung der Software für individuelle, nicht-kommerzielle oder kleine kommerzielle Aktivitäten mit jährlichen Brutto-Einnahmen von 100.000 USD. Über diese Grenzen hinausgehende Verwendungszwecke – einschließlich der Verwendung innerhalb eines Unternehmens, einer Organisation, oder eine andere umsatzgenerierende Umgebung - erfordert eine gültige Enterprise-Lizenz und die Zahlung der Lizenzgebühr. Alle Benutzer, ob Personal oder Enterprise, müssen die Fossorial Commercial License Bedingungen einhalten." }, "trialPeriodInformation": { "title": "Testperiode Information", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "Eine Anfrage pro Stapel ist ein JSON-Array. Kompatibel mit den meisten generischen Webhooks und Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Eine Anfrage pro Batch, der Körper ist newline-getrenntes JSON — ein Objekt pro Zeile, kein äußeres Array. Benötigt von Splunk HEC, Elastic / OpenSearch, und Grafana Loki.", + "httpDestFormatNdjsonDescription": "Eine Anfrage pro Batch, der Körper ist newline-getrenntes JSON - ein Objekt pro Zeile, kein äußeres Array. Benötigt von Splunk HEC, Elastic / OpenSearch, und Grafana Loki.", "httpDestFormatSingleTitle": "Ein Ereignis pro Anfrage", "httpDestFormatSingleDescription": "Sendet eine separate HTTP-POST für jedes einzelne Ereignis. Nur für Endpunkte, die Batches nicht handhaben können.", "httpDestLogTypesTitle": "Log-Typen", diff --git a/messages/en-US.json b/messages/en-US.json index 5d53ea03b..9dd4f1262 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1346,7 +1346,7 @@ "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", - "alertingTitle": "Alerting rules", + "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications.", "alertingRules": "Alert rules", "alertingSearchRules": "Search rules…", @@ -1399,7 +1399,7 @@ "alertingSelectHealthChecks": "Select health checks…", "alertingHealthChecksSelected": "{count} health checks selected", "alertingNoHealthChecks": "No targets with health checks enabled", - "alertingHealthCheckStub": "Health check source selection is not wired up yet — you can still configure triggers and actions.", + "alertingHealthCheckStub": "Health check source selection is not wired up yet - you can still configure triggers and actions.", "alertingSelectUsers": "Select users…", "alertingUsersSelected": "{count} users selected", "alertingSelectRoles": "Select roles…", @@ -1417,7 +1417,7 @@ "alertingConfigureTrigger": "Configure Trigger", "alertingConfigureActions": "Configure Actions", "alertingBackToRules": "Back to Rules", - "alertingDraftBadge": "Draft — save to store this rule", + "alertingDraftBadge": "Draft - save to store this rule", "alertingSidebarHint": "Click a step on the canvas to edit it here.", "alertingGraphCanvasTitle": "Rule Flow", "alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.", @@ -2115,7 +2115,7 @@ "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", "introTitle": "Managed Self-Hosted Pangolin", "introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.", - "introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:", + "introDetail": "With this option, you still run your own Pangolin node - your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:", "benefitSimplerOperations": { "title": "Simpler operations", "description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box." @@ -2240,7 +2240,7 @@ "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Provided Domain", - "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.", + "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan - no need to bring your own.", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", "domainPickerManual": "Manual", @@ -2418,7 +2418,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Usage Disclosure", - "description": "Select the license tier that accurately reflects your intended use. The Personal License permits free use of the Software for individual, non-commercial or small-scale commercial activities with annual gross revenue under $100,000 USD. Any use beyond these limits — including use within a business, organization, or other revenue-generating environment — requires a valid Enterprise License and payment of the applicable licensing fee. All users, whether Personal or Enterprise, must comply with the Fossorial Commercial License Terms." + "description": "Select the license tier that accurately reflects your intended use. The Personal License permits free use of the Software for individual, non-commercial or small-scale commercial activities with annual gross revenue under $100,000 USD. Any use beyond these limits - including use within a business, organization, or other revenue-generating environment - requires a valid Enterprise License and payment of the applicable licensing fee. All users, whether Personal or Enterprise, must comply with the Fossorial Commercial License Terms." }, "trialPeriodInformation": { "title": "Trial Period Information", @@ -3010,7 +3010,7 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "One request per batch, body is a JSON array. Compatible with most generic webhooks and Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "One request per batch, body is newline-delimited JSON — one object per line, no outer array. Required by Splunk HEC, Elastic / OpenSearch, and Grafana Loki.", + "httpDestFormatNdjsonDescription": "One request per batch, body is newline-delimited JSON - one object per line, no outer array. Required by Splunk HEC, Elastic / OpenSearch, and Grafana Loki.", "httpDestFormatSingleTitle": "One Event Per Request", "httpDestFormatSingleDescription": "Sends a separate HTTP POST for each individual event. Use only for endpoints that cannot handle batches.", "httpDestLogTypesTitle": "Log Types", diff --git a/messages/es-ES.json b/messages/es-ES.json index 34c4cc970..0fa9201c8 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", - "domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan — no necesitas traer el tuyo propio.", + "domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan - no necesitas traer el tuyo propio.", "domainPickerVerified": "Verificado", "domainPickerUnverified": "Sin verificar", "domainPickerManual": "Manual", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgación de uso", - "description": "Seleccione el nivel de licencia que refleje con precisión su uso previsto. La Licencia Personal permite el uso libre del Software para actividades comerciales individuales, no comerciales o de pequeña escala con ingresos brutos anuales inferiores a $100,000 USD. Cualquier uso más allá de estos límites — incluyendo el uso dentro de una empresa, organización, u otro entorno de generación de ingresos — requiere una Licencia Empresarial válida y el pago de la cuota de licencia aplicable. Todos los usuarios, ya sean personales o empresariales, deben cumplir con las Condiciones de Licencia Comercial Fossorial." + "description": "Seleccione el nivel de licencia que refleje con precisión su uso previsto. La Licencia Personal permite el uso libre del Software para actividades comerciales individuales, no comerciales o de pequeña escala con ingresos brutos anuales inferiores a $100,000 USD. Cualquier uso más allá de estos límites - incluyendo el uso dentro de una empresa, organización, u otro entorno de generación de ingresos - requiere una Licencia Empresarial válida y el pago de la cuota de licencia aplicable. Todos los usuarios, ya sean personales o empresariales, deben cumplir con las Condiciones de Licencia Comercial Fossorial." }, "trialPeriodInformation": { "title": "Información del período de prueba", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "Matriz JSON", "httpDestFormatJsonArrayDescription": "Una petición por lote, cuerpo es una matriz JSON. Compatible con la mayoría de los webhooks y Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea — un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.", + "httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea - un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.", "httpDestFormatSingleTitle": "Un evento por solicitud", "httpDestFormatSingleDescription": "Envía un HTTP POST separado para cada evento individual. Úsalo sólo para los extremos que no pueden manejar lotes.", "httpDestLogTypesTitle": "Tipos de Log", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 6b2efec27..419701b5f 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1993,7 +1993,7 @@ "description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires", "introTitle": "Pangolin auto-hébergé géré", "introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.", - "introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :", + "introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin - vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :", "benefitSimplerOperations": { "title": "Opérations plus simples", "description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite." @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "domainPickerProvidedDomain": "Domaine fourni", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", - "domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan — plus besoin de fournir le vôtre.", + "domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan - plus besoin de fournir le vôtre.", "domainPickerVerified": "Vérifié", "domainPickerUnverified": "Non vérifié", "domainPickerManual": "Manuel", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgation d'utilisation", - "description": "Sélectionnez le niveau de licence qui correspond exactement à votre utilisation prévue. La Licence Personnelle autorise l'utilisation libre du Logiciel pour des activités commerciales individuelles, non commerciales ou à petite échelle avec un revenu annuel brut inférieur à 100 000 USD. Toute utilisation au-delà de ces limites — y compris l'utilisation au sein d'une entreprise, d'une organisation, ou tout autre environnement générateur de revenus — nécessite une licence d’entreprise valide et le paiement des droits de licence applicables. Tous les utilisateurs, qu'ils soient personnels ou d'entreprise, doivent se conformer aux conditions de licence commerciale Fossorial." + "description": "Sélectionnez le niveau de licence qui correspond exactement à votre utilisation prévue. La Licence Personnelle autorise l'utilisation libre du Logiciel pour des activités commerciales individuelles, non commerciales ou à petite échelle avec un revenu annuel brut inférieur à 100 000 USD. Toute utilisation au-delà de ces limites - y compris l'utilisation au sein d'une entreprise, d'une organisation, ou tout autre environnement générateur de revenus - nécessite une licence d’entreprise valide et le paiement des droits de licence applicables. Tous les utilisateurs, qu'ils soient personnels ou d'entreprise, doivent se conformer aux conditions de licence commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informations sur la période d'essai", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "Tableau JSON", "httpDestFormatJsonArrayDescription": "Une requête par lot, le corps est un tableau JSON. Compatible avec la plupart des webhooks génériques et des datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Une requête par lot, body est un JSON délimité par une nouvelle ligne — un objet par ligne, pas de tableau extérieur. Requis par Splunk HEC, Elastic / OpenSearch, et Grafana Loki.", + "httpDestFormatNdjsonDescription": "Une requête par lot, body est un JSON délimité par une nouvelle ligne - un objet par ligne, pas de tableau extérieur. Requis par Splunk HEC, Elastic / OpenSearch, et Grafana Loki.", "httpDestFormatSingleTitle": "Un événement par demande", "httpDestFormatSingleDescription": "Envoie un POST HTTP séparé pour chaque événement individuel. Utilisé uniquement pour les terminaux qui ne peuvent pas gérer des lots.", "httpDestLogTypesTitle": "Types de logs", diff --git a/messages/it-IT.json b/messages/it-IT.json index 6a771b5a3..e761ea55f 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1993,7 +1993,7 @@ "description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra", "introTitle": "Managed Self-Hosted Pangolin", "introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.", - "introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:", + "introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin - i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:", "benefitSimplerOperations": { "title": "Operazioni più semplici", "description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella." @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "domainPickerProvidedDomain": "Dominio Fornito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", - "domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano — non è necessario portare il proprio.", + "domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano - non è necessario portare il proprio.", "domainPickerVerified": "Verificato", "domainPickerUnverified": "Non Verificato", "domainPickerManual": "Manuale", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Trasparenza Di Utilizzo", - "description": "Seleziona il livello di licenza che rispecchia accuratamente il tuo utilizzo previsto. La Licenza Personale consente l'uso gratuito del Software per le attività commerciali individuali, non commerciali o su piccola scala con entrate lorde annue inferiori a $100.000 USD. Qualsiasi uso oltre questi limiti — compreso l'uso all'interno di un'azienda, organizzazione, o altro ambiente generatore di entrate — richiede una licenza Enterprise valida e il pagamento della tassa di licenza applicabile. Tutti gli utenti, siano essi personali o aziendali, devono rispettare i termini di licenza commerciale Fossorial." + "description": "Seleziona il livello di licenza che rispecchia accuratamente il tuo utilizzo previsto. La Licenza Personale consente l'uso gratuito del Software per le attività commerciali individuali, non commerciali o su piccola scala con entrate lorde annue inferiori a $100.000 USD. Qualsiasi uso oltre questi limiti - compreso l'uso all'interno di un'azienda, organizzazione, o altro ambiente generatore di entrate - richiede una licenza Enterprise valida e il pagamento della tassa di licenza applicabile. Tutti gli utenti, siano essi personali o aziendali, devono rispettare i termini di licenza commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informazioni Periodo Di Prova", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "Una richiesta per lotto, corpo è un array JSON. Compatibile con la maggior parte dei webhooks generici e Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Una richiesta per lotto, corpo è newline-delimited JSON — un oggetto per linea, nessun array esterno. Richiesto da Splunk HEC, Elastic / OpenSearch, e Grafana Loki.", + "httpDestFormatNdjsonDescription": "Una richiesta per lotto, corpo è newline-delimited JSON - un oggetto per linea, nessun array esterno. Richiesto da Splunk HEC, Elastic / OpenSearch, e Grafana Loki.", "httpDestFormatSingleTitle": "Un Evento Per Richiesta", "httpDestFormatSingleDescription": "Invia un HTTP POST separato per ogni singolo evento. Usa solo per gli endpoint che non possono gestire i batch.", "httpDestLogTypesTitle": "Tipi Di Log", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index b444d9f4d..f394fa2d6 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "domainPickerProvidedDomain": "제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인", - "domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. — 별도로 도메인을 준비할 필요 없습니다.", + "domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. - 별도로 도메인을 준비할 필요 없습니다.", "domainPickerVerified": "검증됨", "domainPickerUnverified": "검증되지 않음", "domainPickerManual": "수동", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "사용 공개", - "description": "당신의 의도된 사용에 정확히 맞는 라이선스 등급을 선택하세요. 개인 라이선스는 연간 총 수익 100,000 USD 이하의 개인, 비상업적 또는 소규모 상업 활동을 위한 소프트웨어의 무료 사용을 허용합니다. 이러한 제한을 넘는 모든 사용 — 비즈니스, 조직 또는 기타 수익 창출 환경 내에서의 사용 — 은 유효한 엔터프라이즈 라이선스 및 해당 라이선스 수수료의 지불이 필요합니다. 개인 또는 기업 사용자는 모두 Fossorial 상용 라이선스 조건을 준수해야 합니다." + "description": "당신의 의도된 사용에 정확히 맞는 라이선스 등급을 선택하세요. 개인 라이선스는 연간 총 수익 100,000 USD 이하의 개인, 비상업적 또는 소규모 상업 활동을 위한 소프트웨어의 무료 사용을 허용합니다. 이러한 제한을 넘는 모든 사용 - 비즈니스, 조직 또는 기타 수익 창출 환경 내에서의 사용 - 은 유효한 엔터프라이즈 라이선스 및 해당 라이선스 수수료의 지불이 필요합니다. 개인 또는 기업 사용자는 모두 Fossorial 상용 라이선스 조건을 준수해야 합니다." }, "trialPeriodInformation": { "title": "시험 기간 정보", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON 배열", "httpDestFormatJsonArrayDescription": "각 배치마다 요청 하나씩, 본문은 JSON 배열입니다. 대부분의 일반 웹훅 및 Datadog과 호환됩니다.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "각 배치마다 요청 하나씩, 본문은 줄 구분 JSON — 한 라인에 하나의 객체가 있으며 외부 배열이 없습니다. Splunk HEC, Elastic / OpenSearch, Grafana Loki에 필요합니다.", + "httpDestFormatNdjsonDescription": "각 배치마다 요청 하나씩, 본문은 줄 구분 JSON - 한 라인에 하나의 객체가 있으며 외부 배열이 없습니다. Splunk HEC, Elastic / OpenSearch, Grafana Loki에 필요합니다.", "httpDestFormatSingleTitle": "각 요청 당 하나의 이벤트", "httpDestFormatSingleDescription": "각 개별 이벤트에 대해 별도의 HTTP POST를 전송합니다. 배치를 처리할 수 없는 엔드포인트에만 사용하세요.", "httpDestLogTypesTitle": "로그 유형", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 91593503a..d8bd93680 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON liste", "httpDestFormatJsonArrayDescription": "Én forespørsel per batch, innholdet er en JSON-liste. Kompatibel med de mest generiske webhooks og Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Én forespørsel per sats, innholdet er nytt avgrenset JSON — et objekt per linje, ingen ytterarray. Kreves av Splunk HEC, Elastisk/OpenSearch, og Grafana Loki.", + "httpDestFormatNdjsonDescription": "Én forespørsel per sats, innholdet er nytt avgrenset JSON - et objekt per linje, ingen ytterarray. Kreves av Splunk HEC, Elastisk/OpenSearch, og Grafana Loki.", "httpDestFormatSingleTitle": "En hendelse per forespørsel", "httpDestFormatSingleDescription": "Sender en separat HTTP POST for hver enkelt hendelse. Bruk bare for endepunkter som ikke kan håndtere batcher.", "httpDestLogTypesTitle": "Logg typer", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 987e08419..fce7c836f 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "domainPickerProvidedDomain": "Opgegeven domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", - "domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen — je hoeft er zelf geen mee te brengen.", + "domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen - je hoeft er zelf geen mee te brengen.", "domainPickerVerified": "Geverifieerd", "domainPickerUnverified": "Ongeverifieerd", "domainPickerManual": "Handleiding", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index eb4b4af2f..41b10b7fb 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1993,7 +1993,7 @@ "description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami", "introTitle": "Zarządzany samowystarczalny Pangolin", "introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.", - "introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:", + "introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin - tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:", "benefitSimplerOperations": { "title": "Uproszczone operacje", "description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju." @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "domainPickerProvidedDomain": "Dostarczona domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", - "domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu — nie ma potrzeby przynoszenia własnej.", + "domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu - nie ma potrzeby przynoszenia własnej.", "domainPickerVerified": "Zweryfikowano", "domainPickerUnverified": "Niezweryfikowane", "domainPickerManual": "Podręcznik", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "Tablica JSON", "httpDestFormatJsonArrayDescription": "Jedna prośba na partię, treść jest tablicą JSON. Kompatybilna z najbardziej ogólnymi webhookami i Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Jedno żądanie na partię, ciałem jest plik JSON rozdzielony na newline-delimited — jeden obiekt na wiersz, bez tablicy zewnętrznej. Wymagane przez Splunk HEC, Elastic / OpenSesearch i Grafana Loki.", + "httpDestFormatNdjsonDescription": "Jedno żądanie na partię, ciałem jest plik JSON rozdzielony na newline-delimited - jeden obiekt na wiersz, bez tablicy zewnętrznej. Wymagane przez Splunk HEC, Elastic / OpenSesearch i Grafana Loki.", "httpDestFormatSingleTitle": "Jedno wydarzenie na żądanie", "httpDestFormatSingleDescription": "Wysyła oddzielny POST HTTP dla każdego zdarzenia. Użyj tylko dla punktów końcowych, które nie mogą obsługiwać partii.", "httpDestLogTypesTitle": "Typy logów", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index a16101e43..df7ef9f17 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1993,7 +1993,7 @@ "description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos", "introTitle": "Pangolin Auto-Hospedado Gerenciado", "introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.", - "introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:", + "introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin - seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:", "benefitSimplerOperations": { "title": "Operações simples", "description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade." @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "domainPickerProvidedDomain": "Domínio fornecido", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", - "domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano — não há necessidade de trazer o seu próprio.", + "domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano - não há necessidade de trazer o seu próprio.", "domainPickerVerified": "Verificada", "domainPickerUnverified": "Não verificado", "domainPickerManual": "Manual", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgação de uso", - "description": "Selecione o nível de licença que reflete corretamente seu uso pretendido. A Licença Pessoal permite o uso livre do Software para atividades comerciais individuais, não comerciais ou em pequena escala com rendimento bruto anual inferior a 100.000 USD. Qualquer uso além destes limites — incluindo uso dentro de um negócio, organização, ou outro ambiente gerador de receitas — requer uma Licença Enterprise válida e o pagamento da taxa aplicável de licenciamento. Todos os usuários, pessoais ou empresariais, devem cumprir os Termos da Licença Comercial Fossorial." + "description": "Selecione o nível de licença que reflete corretamente seu uso pretendido. A Licença Pessoal permite o uso livre do Software para atividades comerciais individuais, não comerciais ou em pequena escala com rendimento bruto anual inferior a 100.000 USD. Qualquer uso além destes limites - incluindo uso dentro de um negócio, organização, ou outro ambiente gerador de receitas - requer uma Licença Enterprise válida e o pagamento da taxa aplicável de licenciamento. Todos os usuários, pessoais ou empresariais, devem cumprir os Termos da Licença Comercial Fossorial." }, "trialPeriodInformation": { "title": "Informações do Período de Avaliação", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "Matriz JSON", "httpDestFormatJsonArrayDescription": "Um pedido por lote, o corpo é um array JSON. Compatível com a maioria dos webhooks genéricos e Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Um pedido por lote, o corpo é um JSON delimitado por nova-linha — um objeto por linha, sem array exterior. Requerido pelo Splunk HEC, Elástico / OpenSearch, e Grafana Loki.", + "httpDestFormatNdjsonDescription": "Um pedido por lote, o corpo é um JSON delimitado por nova-linha - um objeto por linha, sem array exterior. Requerido pelo Splunk HEC, Elástico / OpenSearch, e Grafana Loki.", "httpDestFormatSingleTitle": "Um Evento por Requisição", "httpDestFormatSingleDescription": "Envia um POST HTTP separado para cada evento. Utilize apenas para endpoints que não podem manipular lotes.", "httpDestLogTypesTitle": "Tipos de log", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 279f8b1a8..7a8bee8b9 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -56,7 +56,7 @@ "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", - "sitesBannerDescription": "Сайт — это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", + "sitesBannerDescription": "Сайт - это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", "sitesBannerButtonText": "Установить сайт", "approvalsBannerTitle": "Одобрить или запретить доступ к устройству", "approvalsBannerDescription": "Просмотрите и подтвердите или отклоните запросы на доступ к устройству от пользователей. Когда требуется подтверждение устройства, пользователи должны получить одобрение администратора, прежде чем их устройства смогут подключиться к ресурсам вашей организации.", @@ -163,7 +163,7 @@ "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", "proxyResourcesBannerTitle": "Общедоступный доступ через веб", - "proxyResourcesBannerDescription": "Общедоступные ресурсы — это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", + "proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", @@ -371,7 +371,7 @@ "provisioningKeysUpdated": "Ключ подготовки обновлен", "provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.", "provisioningKeysBannerTitle": "Ключи подготовки сайта", - "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.", + "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске - нет необходимости настраивать отдельные учетные данные для каждого сайта.", "provisioningKeysBannerButtonText": "Узнать больше", "pendingSitesBannerTitle": "Ожидающие сайты", "pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.", @@ -1993,7 +1993,7 @@ "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", "introTitle": "Управляемый Само-Хост Панголина", "introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.", - "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:", + "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:", "benefitSimplerOperations": { "title": "Более простые операции", "description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки." @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "domainPickerProvidedDomain": "Домен предоставлен", "domainPickerFreeProvidedDomain": "Бесплатный домен", - "domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план — не нужно приносить свой собственный.", + "domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план - не нужно приносить свой собственный.", "domainPickerVerified": "Подтверждено", "domainPickerUnverified": "Не подтверждено", "domainPickerManual": "Ручной", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Раскрытие", - "description": "Выберите уровень лицензии, который точно отражает ваше предполагаемое использование. Личная Лицензия разрешает свободное использование Программного Обеспечения для частной, некоммерческой или малой коммерческой деятельности с годовым валовым доходом до $100 000 USD. Любое использование сверх этих пределов — включая использование в бизнесе, организацию, или другой приносящей доход среде — требует действительной лицензии предприятия и уплаты соответствующей лицензионной платы. Все пользователи, будь то Личные или Предприятия, обязаны соблюдать условия коммерческой лицензии Fossoral." + "description": "Выберите уровень лицензии, который точно отражает ваше предполагаемое использование. Личная Лицензия разрешает свободное использование Программного Обеспечения для частной, некоммерческой или малой коммерческой деятельности с годовым валовым доходом до $100 000 USD. Любое использование сверх этих пределов - включая использование в бизнесе, организацию, или другой приносящей доход среде - требует действительной лицензии предприятия и уплаты соответствующей лицензионной платы. Все пользователи, будь то Личные или Предприятия, обязаны соблюдать условия коммерческой лицензии Fossoral." }, "trialPeriodInformation": { "title": "Информация о пробном периоде", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index e38b93ca8..68de3399d 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1993,7 +1993,7 @@ "description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu", "introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin", "introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.", - "introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:", + "introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz - tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:", "benefitSimplerOperations": { "title": "Daha basit işlemler", "description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız." @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Kullanım Açıklaması", - "description": "Kullanım amacınızı doğru bir şekilde yansıtan lisans seviyesini seçin. Kişisel Lisans, yazılımın bireysel, ticari olmayan veya yıllık geliri 100,000 ABD Dolarının altında olan küçük ölçekli ticari faaliyetlerde ücretsiz kullanılmasına izin verir. Bu sınırların ötesinde kullanım — bir işletme, organizasyon veya diğer gelir getirici ortamlarda kullanım dahil olmak üzere — geçerli bir Kurumsal Lisans ve ilgili lisans ücretinin ödenmesini gerektirir. Tüm kullanıcılar, ister Kişisel ister Kurumsal, Fossorial Ticari Lisans Şartlarına uymalıdır." + "description": "Kullanım amacınızı doğru bir şekilde yansıtan lisans seviyesini seçin. Kişisel Lisans, yazılımın bireysel, ticari olmayan veya yıllık geliri 100,000 ABD Dolarının altında olan küçük ölçekli ticari faaliyetlerde ücretsiz kullanılmasına izin verir. Bu sınırların ötesinde kullanım - bir işletme, organizasyon veya diğer gelir getirici ortamlarda kullanım dahil olmak üzere - geçerli bir Kurumsal Lisans ve ilgili lisans ücretinin ödenmesini gerektirir. Tüm kullanıcılar, ister Kişisel ister Kurumsal, Fossorial Ticari Lisans Şartlarına uymalıdır." }, "trialPeriodInformation": { "title": "Deneme Süresi Bilgileri", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 761f39524..955ad7096 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1993,7 +1993,7 @@ "description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器", "introTitle": "托管自托管的潘戈林公司", "introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。", - "introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:", + "introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:", "benefitSimplerOperations": { "title": "简单的操作", "description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。" @@ -2118,7 +2118,7 @@ "selectDomainForOrgAuthPage": "选择组织认证页面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免费提供的域", - "domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中—无需自带域名。", + "domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中-无需自带域名。", "domainPickerVerified": "已验证", "domainPickerUnverified": "未验证", "domainPickerManual": "手动", @@ -2296,7 +2296,7 @@ "alerts": { "commercialUseDisclosure": { "title": "使用情况披露", - "description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境——需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。" + "description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境--需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。" }, "trialPeriodInformation": { "title": "试用期信息", @@ -2881,7 +2881,7 @@ "httpDestFormatJsonArrayTitle": "JSON 数组", "httpDestFormatJsonArrayDescription": "每批一个请求,实体是一个 JSON 数组。与大多数通用的 Web 钩子和数据兼容。", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON ——每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", + "httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON --每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", "httpDestFormatSingleTitle": "每个请求一个事件", "httpDestFormatSingleDescription": "为每个事件单独发送一个 HTTP POST。仅用于无法处理批量的端点。", "httpDestLogTypesTitle": "日志类型", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index cf7c25ced..1ef8061e2 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1763,7 +1763,7 @@ "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", "introTitle": "託管式自架 Pangolin", "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", - "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", + "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", "benefitSimplerOperations": { "title": "簡單的操作", "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" @@ -2035,7 +2035,7 @@ "alerts": { "commercialUseDisclosure": { "title": "使用情況披露", - "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" + "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境--需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" }, "trialPeriodInformation": { "title": "試用期資訊", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f39a125a3..b091eb20b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -189,9 +189,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { targetId: integer("targetId").references(() => targets.targetId, { onDelete: "cascade" }), - orgId: varchar("orgId").references(() => orgs.orgId, { - onDelete: "cascade" - }), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 22144a2c6..f30331d64 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -209,11 +209,14 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), - targetId: integer("targetId") - .references(() => targets.targetId, { onDelete: "cascade" }), - orgId: text("orgId").references(() => orgs.orgId, { + targetId: integer("targetId").references(() => targets.targetId, { onDelete: "cascade" }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() @@ -294,7 +297,7 @@ export const siteResources = sqliteTable("siteResources", { onDelete: "set null" }), subdomain: text("subdomain"), - fullDomain: text("fullDomain"), + fullDomain: text("fullDomain") }); export const networks = sqliteTable("networks", { diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index c01a99f3e..8a0cf7631 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -83,7 +83,7 @@ function formatDataItems( .replace(/([A-Z])/g, " $1") .replace(/^./, (s) => s.toUpperCase()) .trim(), - value: String(value ?? "—") + value: String(value ?? "-") })); } @@ -137,4 +137,4 @@ export const AlertNotification = ({ eventType, orgId, data }: Props) => { ); }; -export default AlertNotification; \ No newline at end of file +export default AlertNotification; diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index 44472c8a6..82154ab7d 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -32,7 +32,7 @@ export const EnterpriseEditionKeyGenerated = ({ }: EnterpriseEditionKeyGeneratedProps) => { const previewText = personalUseOnly ? "Your Enterprise Edition key for personal use is ready" - : "Thank you for your purchase — your Enterprise Edition key is ready"; + : "Thank you for your purchase - your Enterprise Edition key is ready"; return ( diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index b213ec9be..40c5a5bf7 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -825,7 +825,7 @@ async function handleSubnetProxyTargetUpdates( // Check if this client still has access to another resource // on this specific site with the same destination. We scope // by siteId (via siteNetworks) rather than networkId because - // removePeerData operates per-site — a resource on a different + // removePeerData operates per-site - a resource on a different // site sharing the same network should not block removal here. const destinationStillInUse = await trx .select() @@ -980,7 +980,7 @@ export async function rebuildClientAssociationsFromClient( ) : []; - // Group by siteId for site-level associations — look up via siteNetworks since + // Group by siteId for site-level associations - look up via siteNetworks since // siteResources no longer carries a direct siteId column. const networkIds = Array.from( new Set( @@ -1459,7 +1459,7 @@ async function handleMessagesForClientResources( // Check if this client still has access to another resource // on this specific site with the same destination. We scope // by siteId (via siteNetworks) rather than networkId because - // removePeerData operates per-site — a resource on a different + // removePeerData operates per-site - a resource on a different // site sharing the same network should not block removal here. const destinationStillInUse = await trx .select() diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 6ef3c45b5..c8fcfafdc 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -1011,7 +1011,7 @@ export class TraefikConfigManager { ); if (!isUnused) { - // Domain is still active — remove from pending deletion if it was queued + // Domain is still active - remove from pending deletion if it was queued if (this.pendingDeletion.has(dirName)) { logger.info( `Certificate ${dirName} is active again, cancelling pending deletion` @@ -1021,7 +1021,7 @@ export class TraefikConfigManager { continue; } - // Domain is unused — add to pending deletion or decrement its counter + // Domain is unused - add to pending deletion or decrement its counter if (!this.pendingDeletion.has(dirName)) { const graceCycles = 3; logger.info( @@ -1036,7 +1036,7 @@ export class TraefikConfigManager { ); this.pendingDeletion.set(dirName, remaining); } else { - // Grace period expired — actually delete now + // Grace period expired - actually delete now this.pendingDeletion.delete(dirName); const domainDir = path.join(certsPath, dirName); diff --git a/server/lib/traefik/pathEncoding.test.ts b/server/lib/traefik/pathEncoding.test.ts index 83d53a039..f0318807a 100644 --- a/server/lib/traefik/pathEncoding.test.ts +++ b/server/lib/traefik/pathEncoding.test.ts @@ -24,7 +24,7 @@ function encodePath(path: string | null | undefined): string { /** * Exact replica of the OLD key computation from upstream main. - * Uses sanitize() for paths — this is what had the collision bug. + * Uses sanitize() for paths - this is what had the collision bug. */ function oldKeyComputation( resourceId: number, @@ -44,7 +44,7 @@ function oldKeyComputation( /** * Replica of the NEW key computation from our fix. - * Uses encodePath() for paths — collision-free. + * Uses encodePath() for paths - collision-free. */ function newKeyComputation( resourceId: number, @@ -195,11 +195,11 @@ function runTests() { true, "/a/b and /a-b MUST have different keys" ); - console.log(" PASS: collision fix — /a/b vs /a-b have different keys"); + console.log(" PASS: collision fix - /a/b vs /a-b have different keys"); passed++; } - // Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key + // Test 9: demonstrate the old bug - old code maps /a/b and /a-b to same key { const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null); const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null); @@ -208,11 +208,11 @@ function runTests() { oldKeyDash, "old code MUST have this collision (confirms the bug exists)" ); - console.log(" PASS: confirmed old code bug — /a/b and /a-b collided"); + console.log(" PASS: confirmed old code bug - /a/b and /a-b collided"); passed++; } - // Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it + // Test 10: /api/v1 and /api-v1 - old code collision, new code fixes it { const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null); const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null); @@ -229,11 +229,11 @@ function runTests() { true, "new code must separate /api/v1 and /api-v1" ); - console.log(" PASS: collision fix — /api/v1 vs /api-v1"); + console.log(" PASS: collision fix - /api/v1 vs /api-v1"); passed++; } - // Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed + // Test 11: /app.v2 and /app/v2 and /app-v2 - three-way collision fixed { const a = newKeyComputation(1, "/app.v2", "prefix", null, null); const b = newKeyComputation(1, "/app/v2", "prefix", null, null); @@ -245,14 +245,14 @@ function runTests() { "three paths must produce three unique keys" ); console.log( - " PASS: collision fix — three-way /app.v2, /app/v2, /app-v2" + " PASS: collision fix - three-way /app.v2, /app/v2, /app-v2" ); passed++; } // ── Edge cases ─────────────────────────────────────────────────── - // Test 12: same path in different resources — always separate + // Test 12: same path in different resources - always separate { const key1 = newKeyComputation(1, "/api", "prefix", null, null); const key2 = newKeyComputation(2, "/api", "prefix", null, null); @@ -261,11 +261,11 @@ function runTests() { true, "different resources with same path must have different keys" ); - console.log(" PASS: edge case — same path, different resources"); + console.log(" PASS: edge case - same path, different resources"); passed++; } - // Test 13: same resource, different pathMatchType — separate keys + // Test 13: same resource, different pathMatchType - separate keys { const exact = newKeyComputation(1, "/api", "exact", null, null); const prefix = newKeyComputation(1, "/api", "prefix", null, null); @@ -274,11 +274,11 @@ function runTests() { true, "exact vs prefix must have different keys" ); - console.log(" PASS: edge case — same path, different match types"); + console.log(" PASS: edge case - same path, different match types"); passed++; } - // Test 14: same resource and path, different rewrite config — separate keys + // Test 14: same resource and path, different rewrite config - separate keys { const noRewrite = newKeyComputation(1, "/api", "prefix", null, null); const withRewrite = newKeyComputation( @@ -293,7 +293,7 @@ function runTests() { true, "with vs without rewrite must have different keys" ); - console.log(" PASS: edge case — same path, different rewrite config"); + console.log(" PASS: edge case - same path, different rewrite config"); passed++; } @@ -308,7 +308,7 @@ function runTests() { paths.length, "special URL chars must produce unique keys" ); - console.log(" PASS: edge case — special URL characters in paths"); + console.log(" PASS: edge case - special URL characters in paths"); passed++; } diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index f640a6859..faa45b08e 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -144,7 +144,7 @@ async function pushCertUpdateToAffectedNewts( await cache.del(`cert:${resource.fullDomain}`); } - // Generate target once — same cert applies to all sites for this resource + // Generate target once - same cert applies to all sites for this resource const newTargets = await generateSubnetProxyTargetV2( resource, resourceClients @@ -157,7 +157,7 @@ async function pushCertUpdateToAffectedNewts( continue; } - // Construct the old targets — same routing shape but with the previous cert/key. + // Construct the old targets - same routing shape but with the previous cert/key. // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the // semantics correct so the update message accurately reflects what changed. const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({ diff --git a/server/private/lib/logConnectionAudit.ts b/server/private/lib/logConnectionAudit.ts index 8cc3a1e52..039b75ec9 100644 --- a/server/private/lib/logConnectionAudit.ts +++ b/server/private/lib/logConnectionAudit.ts @@ -153,7 +153,7 @@ export async function flushConnectionLogToDb(): Promise { ); } - // Stop processing further batches from this snapshot — they will + // Stop processing further batches from this snapshot - they will // be picked up via the re-queued records on the next flush. const remaining = snapshot.slice(i + INSERT_BATCH_SIZE); if (remaining.length > 0) { @@ -180,7 +180,7 @@ const flushTimer = setInterval(async () => { }, FLUSH_INTERVAL_MS); // Calling unref() means this timer will not keep the Node.js event loop alive -// on its own — the process can still exit normally when there is no other work +// on its own - the process can still exit normally when there is no other work // left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly // before process.exit(), so no data is lost. flushTimer.unref(); @@ -223,7 +223,7 @@ export function logConnectionAudit(record: ConnectionLogRecord): void { buffer.push(record); if (buffer.length >= MAX_BUFFERED_RECORDS) { - // Fire and forget — errors are handled inside flushConnectionLogToDb + // Fire and forget - errors are handled inside flushConnectionLogToDb flushConnectionLogToDb().catch((error) => { logger.error( "Unexpected error during size-triggered connection log flush:", @@ -231,4 +231,4 @@ export function logConnectionAudit(record: ConnectionLogRecord): void { ); }); } -} \ No newline at end of file +} diff --git a/server/private/lib/logStreaming/providers/HttpLogDestination.ts b/server/private/lib/logStreaming/providers/HttpLogDestination.ts index dde7bd695..337a58f1f 100644 --- a/server/private/lib/logStreaming/providers/HttpLogDestination.ts +++ b/server/private/lib/logStreaming/providers/HttpLogDestination.ts @@ -37,7 +37,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * * **Payload formats** (controlled by `config.format`): * - * - `json_array` (default) — one POST per batch, body is a JSON array: + * - `json_array` (default) - one POST per batch, body is a JSON array: * ```json * [ * { "event": "request", "timestamp": "2024-01-01T00:00:00.000Z", "data": { … } }, @@ -46,7 +46,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * ``` * `Content-Type: application/json` * - * - `ndjson` — one POST per batch, body is newline-delimited JSON (one object + * - `ndjson` - one POST per batch, body is newline-delimited JSON (one object * per line, no outer array). Required by Splunk HEC, Elastic/OpenSearch, * and Grafana Loki: * ``` @@ -55,7 +55,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * ``` * `Content-Type: application/x-ndjson` * - * - `json_single` — one POST **per event**, body is a plain JSON object. + * - `json_single` - one POST **per event**, body is a plain JSON object. * Use only for endpoints that cannot handle batches at all. * * With a body template each event is rendered through the template before @@ -319,4 +319,4 @@ function epochSecondsToIso(epochSeconds: number): string { function escapeJsonString(value: string): string { // JSON.stringify produces `""` – strip the outer quotes. return JSON.stringify(value).slice(1, -1); -} \ No newline at end of file +} diff --git a/server/private/lib/logStreaming/types.ts b/server/private/lib/logStreaming/types.ts index 5eed79520..1bcd25a66 100644 --- a/server/private/lib/logStreaming/types.ts +++ b/server/private/lib/logStreaming/types.ts @@ -60,9 +60,9 @@ export type AuthType = "none" | "bearer" | "basic" | "custom"; /** * Controls how the batch of events is serialised into the HTTP request body. * - * - `json_array` – `[{…}, {…}]` — default; one POST per batch wrapped in a + * - `json_array` – `[{…}, {…}]` - default; one POST per batch wrapped in a * JSON array. Works with most generic webhooks and Datadog. - * - `ndjson` – `{…}\n{…}` — newline-delimited JSON, one object per + * - `ndjson` – `{…}\n{…}` - newline-delimited JSON, one object per * line. Required by Splunk HEC, Elastic/OpenSearch, Loki. * - `json_single` – one HTTP POST per event, body is a plain JSON object. * Use only for endpoints that cannot handle batches at all. @@ -131,4 +131,4 @@ export interface DestinationFailureState { nextRetryAt: number; /** Date.now() value of the very first failure in the current streak */ firstFailedAt: number; -} \ No newline at end of file +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 404615a86..fb6e176b8 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -267,7 +267,7 @@ export async function getTraefikConfig( }); }); - // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge + // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge const siteResourcesWithFullDomain = await db .select({ siteResourceId: siteResources.siteResourceId, @@ -1010,7 +1010,7 @@ export async function getTraefikConfig( } } - // HTTPS router — presence of this entry triggers cert generation + // HTTPS router - presence of this entry triggers cert generation config_output.http.routers[siteResourceRouterName] = { entryPoints: [ config.getRawConfig().traefik.https_entrypoint @@ -1022,7 +1022,7 @@ export async function getTraefikConfig( tls }; - // Assets bypass router — lets Next.js static files load without rewrite + // Assets bypass router - lets Next.js static files load without rewrite config_output.http.routers[`${siteResourceRouterName}-assets`] = { entryPoints: [ config.getRawConfig().traefik.https_entrypoint diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index d5b05ac24..b2e6949a1 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, targets, resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -84,12 +84,36 @@ export async function listHealthChecks( const whereClause = and( eq(targetHealthCheck.orgId, orgId), - isNull(targetHealthCheck.targetId) ); const list = await db - .select() + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + name: targetHealthCheck.name, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMethod: targetHealthCheck.hcMethod, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcStatus: targetHealthCheck.hcStatus, + hcTlsServerName: targetHealthCheck.hcTlsServerName, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold, + resourceId: resources.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId + }) .from(targetHealthCheck) + .leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId)) + .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) .where(whereClause) .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .limit(limit) @@ -124,7 +148,10 @@ export async function listHealthChecks( hcStatus: row.hcStatus ?? null, hcTlsServerName: row.hcTlsServerName ?? null, hcHealthyThreshold: row.hcHealthyThreshold ?? null, - hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null + hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null, + resourceId: row.resourceId ?? null, + resourceName: row.resourceName ?? null, + resourceNiceId: row.resourceNiceId ?? null })), pagination: { total: count, diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index dcd897471..eacf3dad4 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -88,11 +88,11 @@ async function dbQueryRows>( ): Promise { const anyDb = db as any; if (typeof anyDb.execute === "function") { - // PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array + // PostgreSQL (node-postgres via Drizzle) - returns { rows: [...] } or an array const result = await anyDb.execute(query); return (Array.isArray(result) ? result : (result.rows ?? [])) as T[]; } - // SQLite (better-sqlite3 via Drizzle) — returns an array directly + // SQLite (better-sqlite3 via Drizzle) - returns an array directly return (await anyDb.all(query)) as T[]; } @@ -106,7 +106,7 @@ function isSQLite(): boolean { * Swaps out the accumulator before writing so that any bandwidth messages * received during the flush are captured in the new accumulator rather than * being lost or causing contention. Sites are updated in chunks via a single - * batch UPDATE per chunk. Failed chunks are discarded — exact per-flush + * batch UPDATE per chunk. Failed chunks are discarded - exact per-flush * accuracy is not critical and re-queuing is not worth the added complexity. * * This function is exported so that the application's graceful-shutdown @@ -125,7 +125,7 @@ export async function flushSiteBandwidthToDb(): Promise { const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent - // writers — deadlock-prevention strategy. + // writers - deadlock-prevention strategy. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) ); @@ -150,7 +150,7 @@ export async function flushSiteBandwidthToDb(): Promise { try { rows = await withDeadlockRetry(async () => { if (isSQLite()) { - // SQLite: one UPDATE per row — no need for batch efficiency here. + // SQLite: one UPDATE per row - no need for batch efficiency here. const results: { orgId: string; pubKey: string }[] = []; for (const [publicKey, { bytesIn, bytesOut }] of chunk) { const result = await dbQueryRows<{ @@ -170,7 +170,7 @@ export async function flushSiteBandwidthToDb(): Promise { return results; } - // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. + // PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk. const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ); @@ -191,7 +191,7 @@ export async function flushSiteBandwidthToDb(): Promise { `Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`, error ); - // Discard the chunk — exact per-flush accuracy is not critical. + // Discard the chunk - exact per-flush accuracy is not critical. continue; } @@ -232,7 +232,7 @@ export async function flushSiteBandwidthToDb(): Promise { totalBandwidth ); if (bandwidthUsage) { - // Fire-and-forget — don't block the flush on limit checking. + // Fire-and-forget - don't block the flush on limit checking. usageService .checkLimitSet( orgId, @@ -298,7 +298,7 @@ export async function updateSiteBandwidth( exitNodeId?: number ): Promise { for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { - // Skip peers that haven't transferred any data — writing zeros to the + // Skip peers that haven't transferred any data - writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts index 429da80c0..d8395c593 100644 --- a/server/routers/healthChecks/types.ts +++ b/server/routers/healthChecks/types.ts @@ -19,6 +19,9 @@ export type ListHealthChecksResponse = { hcTlsServerName: string | null; hcHealthyThreshold: number | null; hcUnhealthyThreshold: number | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; }[]; pagination: { total: number; diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts index f086333e7..2d5d99b09 100644 --- a/server/routers/newt/handleReceiveBandwidthMessage.ts +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -88,7 +88,7 @@ export async function flushBandwidthToDb(): Promise { const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent - // writers — this is the same deadlock-prevention strategy used in the + // writers - this is the same deadlock-prevention strategy used in the // original per-message implementation. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) @@ -143,7 +143,7 @@ const flushTimer = setInterval(async () => { }, FLUSH_INTERVAL_MS); // Calling unref() means this timer will not keep the Node.js event loop alive -// on its own — the process can still exit normally when there is no other work +// on its own - the process can still exit normally when there is no other work // left. The graceful-shutdown path (see server/cleanup.ts) will call // flushBandwidthToDb() explicitly before process.exit(), so no data is lost. flushTimer.unref(); @@ -167,7 +167,7 @@ export const handleReceiveBandwidthMessage: MessageHandler = async ( // Accumulate the incoming data in memory; the periodic timer (and the // shutdown hook) will take care of writing it to the database. for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { - // Skip peers that haven't transferred any data — writing zeros to the + // Skip peers that haven't transferred any data - writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 3343a92fd..4d5fd5de4 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -16,7 +16,7 @@ const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes * Starts the background interval that checks for newt sites that haven't * pinged recently and marks them as offline. For backward compatibility, * a site is only marked offline when there is no active WebSocket connection - * either — so older newt versions that don't send pings but remain connected + * either - so older newt versions that don't send pings but remain connected * continue to be treated as online. */ export const startNewtOfflineChecker = (): void => { @@ -63,7 +63,7 @@ export const startNewtOfflineChecker = (): void => { ); if (isConnected) { logger.debug( - `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` + `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket - keeping site ${staleSite.siteId} online` ); continue; } diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 8f2154c39..56429372d 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -112,7 +112,7 @@ async function flushSitePingsToDb(): Promise { try { const newlyOnlineSites = await withRetry(async () => { - // Only update sites that were offline — these are the + // Only update sites that were offline - these are the // offline→online transitions. .returning() gives us exactly // the site IDs that changed state. const transitioned = await db @@ -249,7 +249,7 @@ async function flushClientPingsToDb(): Promise { } /** - * Flush everything — called by the interval timer and during shutdown. + * Flush everything - called by the interval timer and during shutdown. */ export async function flushPingsToDb(): Promise { await flushSitePingsToDb(); @@ -314,7 +314,7 @@ function isTransientError(error: any): boolean { return true; } - // PostgreSQL deadlock detected — always safe to retry (one winner guaranteed) + // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) if (code === "40P01" || message.includes("deadlock")) { return true; } diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index de68ab2de..cc53e48df 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -249,7 +249,7 @@ export async function registerNewt( dateCreated: moment().toISOString() }); - // Consume the provisioning key — cascade removes siteProvisioningKeyOrg + // Consume the provisioning key - cascade removes siteProvisioningKeyOrg await trx .update(siteProvisioningKeys) .set({ diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index 0eda41e04..05a83a146 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -211,7 +211,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( continue; } - // Trigger the peer add handshake — if the peer was already added this will be a no-op + // Trigger the peer add handshake - if the peer was already added this will be a no-op await initPeerAddHandshake( client.clientId, { @@ -236,4 +236,4 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( } return; -}; \ No newline at end of file +}; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 973155ccc..a31e5179e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -228,7 +228,7 @@ export async function createTarget( healthCheck = await db .insert(targetHealthCheck) .values({ - name: `${targetData.ip}:${targetData.port}`, + orgId: resource.orgId, targetId: newTarget[0].targetId, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 2dc09eedc..f89284389 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -47,7 +47,7 @@ export const messageHandlers: Record = { "ws/round-trip/complete": handleRoundTripMessage }; -// Start the ping accumulator for all builds — it batches per-site online/lastPing +// Start the ping accumulator for all builds - it batches per-site online/lastPing // updates into periodic bulk writes, preventing connection pool exhaustion. startPingAccumulator(); diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index 6eaedff5a..0fc8f95b7 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -22,7 +22,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; function formatBytes(bytes: number | null): string { - if (bytes === null || bytes === undefined) return "—"; + if (bytes === null || bytes === undefined) return "-"; if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); @@ -33,7 +33,7 @@ function formatBytes(bytes: number | null): string { function formatDuration(startedAt: number, endedAt: number | null): string { if (endedAt === null || endedAt === undefined) return "Active"; const durationSec = endedAt - startedAt; - if (durationSec < 0) return "—"; + if (durationSec < 0) return "-"; if (durationSec < 60) return `${durationSec}s`; if (durationSec < 3600) { const m = Math.floor(durationSec / 60); @@ -460,7 +460,7 @@ export default function ConnectionLogsPage() { } return ( - {row.original.resourceName ?? "—"} + {row.original.resourceName ?? "-"} ); } @@ -503,7 +503,7 @@ export default function ConnectionLogsPage() { } return ( - {row.original.clientName ?? "—"} + {row.original.clientName ?? "-"} ); } @@ -538,7 +538,7 @@ export default function ConnectionLogsPage() { ); } - return ; + return -; } }, { @@ -612,23 +612,23 @@ export default function ConnectionLogsPage() {
Session ID:{" "} - {row.sessionId ?? "—"} + {row.sessionId ?? "-"}
Protocol:{" "} - {row.protocol?.toUpperCase() ?? "—"} + {row.protocol?.toUpperCase() ?? "-"}
Source:{" "} - {row.sourceAddr ?? "—"} + {row.sourceAddr ?? "-"}
Destination:{" "} - {row.destAddr ?? "—"} + {row.destAddr ?? "-"}
@@ -638,7 +638,7 @@ export default function ConnectionLogsPage() {
*/} {/*
Resource:{" "} - {row.resourceName ?? "—"} + {row.resourceName ?? "-"} {row.resourceNiceId && ( ({row.resourceNiceId}) @@ -646,7 +646,7 @@ export default function ConnectionLogsPage() { )}
*/}
- Site: {row.siteName ?? "—"} + Site: {row.siteName ?? "-"} {row.siteNiceId && ( ({row.siteNiceId}) @@ -654,7 +654,7 @@ export default function ConnectionLogsPage() { )}
- Site ID: {row.siteId ?? "—"} + Site ID: {row.siteId ?? "-"}
Started At:{" "} @@ -662,7 +662,7 @@ export default function ConnectionLogsPage() { ? new Date( row.startedAt * 1000 ).toLocaleString() - : "—"} + : "-"}
Ended At:{" "} @@ -676,7 +676,7 @@ export default function ConnectionLogsPage() {
{/*
Resource ID:{" "} - {row.siteResourceId ?? "—"} + {row.siteResourceId ?? "-"}
*/}
diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx index 7e48d7566..5192a9c9b 100644 --- a/src/app/[orgId]/settings/logs/streaming/page.tsx +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -434,7 +434,7 @@ export default function StreamingDestinationsPage() { disabled={!isEnterprise} /> ))} - {/* Add card is always clickable — paywall is enforced inside the picker */} + {/* Add card is always clickable - paywall is enforced inside the picker */}
)} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index b0e044699..03426ef1f 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -341,19 +341,6 @@ function ProxyResourceTargetsForm({ header: () => {t("healthCheck")}, cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; - const isEnabled = row.original.hcEnabled; - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "green"; - case "unhealthy": - return "red"; - case "unknown": - default: - return "secondary"; - } - }; const getStatusText = (status: string) => { switch (status) { @@ -367,19 +354,7 @@ function ProxyResourceTargetsForm({ } }; - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return ; - case "unhealthy": - return ; - case "unknown": - default: - return null; - } - }; - - return ( + return (
{row.original.siteType === "newt" ? ( + ) : ( - )} diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index b7cff202a..ab97197a3 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -425,7 +425,7 @@ export default function Page() { setRemoteExitNodeOptions(exitNodeOptions); if (exitNodeOptions.length === 0) { - // No remote exit nodes available — remove local option and default to newt + // No remote exit nodes available - remove local option and default to newt setTunnelTypes((prev: any) => prev.filter((item: any) => item.id !== "local") ); @@ -434,7 +434,7 @@ export default function Page() { } } catch (error) { console.error("Failed to fetch remote exit nodes:", error); - // If fetch fails, no remote exit nodes available — remove local option and default to newt + // If fetch fails, no remote exit nodes available - remove local option and default to newt setTunnelTypes((prev: any) => prev.filter((item: any) => item.id !== "local") ); diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index ece2309e2..7f55a46cd 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -49,7 +49,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
) : (
-
+
{t("offline")}
)} diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c32208321..36f8caa78 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -133,7 +133,7 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string { return "bg-yellow-500"; case "allOffline": default: - return "bg-gray-500"; + return "bg-neutral-500"; } } @@ -188,7 +188,7 @@ function ClientResourceSitesStatusCell({ "h-2 w-2 shrink-0 rounded-full", isOnline ? "bg-green-500" - : "bg-gray-500" + : "bg-neutral-500" )} /> diff --git a/src/components/ExitNodeInfoCard.tsx b/src/components/ExitNodeInfoCard.tsx index 63dff644d..5f50d892a 100644 --- a/src/components/ExitNodeInfoCard.tsx +++ b/src/components/ExitNodeInfoCard.tsx @@ -32,7 +32,7 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) { ) : (
-
+
{t("offline")}
)} diff --git a/src/components/ExitNodesTable.tsx b/src/components/ExitNodesTable.tsx index 5c39f409e..67d819a47 100644 --- a/src/components/ExitNodesTable.tsx +++ b/src/components/ExitNodesTable.tsx @@ -146,7 +146,7 @@ export default function ExitNodesTable({ } else { return ( -
+
{t("offline")}
); diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index 597be7c0b..f575784f4 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -61,6 +61,9 @@ export type HealthCheckRow = { hcTlsServerName: string | null; hcHealthyThreshold: number | null; hcUnhealthyThreshold: number | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; }; export type HealthCheckCredenzaProps = diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index f6f32dd34..8ee7a82d2 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -10,6 +10,7 @@ import { SelectTrigger, SelectValue } from "@/components/ui/select"; +import { StrategySelect } from "@app/components/StrategySelect"; import { Switch } from "@/components/ui/switch"; import { HeadersInput } from "@app/components/HeadersInput"; import { @@ -103,22 +104,27 @@ export function HealthCheckFormFields({ render={({ field }) => ( {t("healthCheckStrategy")} - + + + handleChange("hcMode", value, field.onChange) + } + /> + )} diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index eff3a6d22..ed5296c82 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -19,17 +19,17 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; -import { Span } from "next/dist/trace"; +import Link from "next/link"; type StandaloneHealthChecksTableProps = { orgId: string; }; function formatTarget(row: HealthCheckRow): string { - if (!row.hcHostname) return "—"; + if (!row.hcHostname) return "-"; if (row.hcMode === "tcp") { if (!row.hcPort) return row.hcHostname; return `${row.hcHostname}:${row.hcPort}`; @@ -154,7 +154,7 @@ export default function HealthChecksTable({ ), cell: ({ row }) => ( - {row.original.hcMode?.toUpperCase() ?? "—"} + {row.original.hcMode?.toUpperCase() ?? "-"} ) }, @@ -166,6 +166,27 @@ export default function HealthChecksTable({ ), cell: ({ row }) => {formatTarget(row.original)} }, + { + id: "resource", + friendlyName: "Resource", + header: () => ( + Resource + ), + cell: ({ row }) => { + const r = row.original; + if (!r.resourceId || !r.resourceName || !r.resourceNiceId) { + return -; + } + return ( + + + + ); + } + }, { id: "health", friendlyName: t("standaloneHcColumnHealth"), @@ -191,7 +212,7 @@ export default function HealthChecksTable({ } else { return ( -
+
{healthLabel.unknown}
); diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index e647f7dd1..5d7ece74e 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -36,7 +36,7 @@ export default function LocaleSwitcherSelect({ }); // Persist locale to the database (fire-and-forget) api.post("/user/locale", { locale }).catch(() => { - // Silently ignore errors — cookie is already set as fallback + // Silently ignore errors - cookie is already set as fallback }); } diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 9c1da5b4d..4ef22c83d 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -293,7 +293,7 @@ export default function MachineClientsTable({ } else { return ( -
+
{t("disconnected")}
); diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index a1ed6f354..a6625037d 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -204,7 +204,7 @@ export default function PendingSitesTable({ } else { return ( -
+
{t("offline")}
); diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 36a507ea9..8006612a9 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -79,7 +79,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : ( -
+
Offline
)} diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index c0a9b36b1..2b40d379b 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -52,7 +52,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { ) : (
-
+
{t("offline")}
)} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 606630a50..7fa635b87 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -212,7 +212,7 @@ export default function SitesTable({ } else { return ( -
+
{t("offline")}
); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 5542029a6..88e495406 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -427,7 +427,7 @@ export default function UserDevicesTable({ } else { return ( -
+
{t("disconnected")}
); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 17cc10f11..f658f9fee 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -291,6 +291,9 @@ export const orgQueries = { hcTlsServerName: string | null; hcHealthyThreshold: number | null; hcUnhealthyThreshold: number | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; }[]; pagination: { total: number; diff --git a/src/services/locale.ts b/src/services/locale.ts index 81be42bc1..2d3e1d21f 100644 --- a/src/services/locale.ts +++ b/src/services/locale.ts @@ -17,14 +17,14 @@ export async function getUserLocale(): Promise { return cookieLocale as Locale; } - // No cookie found — try to restore from user's saved locale in DB + // No cookie found - try to restore from user's saved locale in DB try { const res = await internal.get("/user", await authCookieHeader()); const userLocale = res.data?.data?.locale; if (userLocale && locales.includes(userLocale as Locale)) { // Try to cache in a cookie so subsequent requests skip the API // call. cookies().set() is only permitted in Server Actions and - // Route Handlers — not during rendering — so we isolate it so + // Route Handlers - not during rendering - so we isolate it so // that a write failure doesn't prevent the locale from being // returned for the current request. try { @@ -40,7 +40,7 @@ export async function getUserLocale(): Promise { return userLocale as Locale; } } catch { - // User not logged in or API unavailable — fall through + // User not logged in or API unavailable - fall through } const headerList = await headers(); From c1782a26501c773e1720ef0f125bd7beba29320c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 18:25:25 -0700 Subject: [PATCH 078/176] Add uptime tracking --- server/db/pg/schema/schema.ts | 16 + server/db/sqlite/schema/schema.ts | 15 + server/routers/external.ts | 14 + server/routers/newt/offlineChecker.ts | 26 +- server/routers/newt/pingAccumulator.ts | 9 +- server/routers/site/getStatusHistory.ts | 305 ++++++++++++++++++ server/routers/site/index.ts | 1 + .../target/handleHealthcheckStatusMessage.ts | 11 +- .../settings/sites/[niceId]/general/page.tsx | 15 + src/components/HealthChecksTable.tsx | 12 + src/components/SitesTable.tsx | 12 + src/components/UptimeBar.tsx | 208 ++++++++++++ src/components/UptimeMiniBar.tsx | 128 ++++++++ src/lib/queries.ts | 26 +- 14 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 server/routers/site/getStatusHistory.ts create mode 100644 src/components/UptimeBar.tsx create mode 100644 src/components/UptimeMiniBar.tsx diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b091eb20b..bc9a72c09 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1159,3 +1159,19 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type Network = InferSelectModel; + +export const statusHistory = pgTable("statusHistory", { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull(), +}, (table) => [ + index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), + index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), +]); + +export type StatusHistory = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f30331d64..d7c947347 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1181,6 +1181,20 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { }) }); +export const statusHistory = sqliteTable("statusHistory", { + id: integer("id").primaryKey({ autoIncrement: true }), + entityType: text("entityType").notNull(), // "site" | "healthCheck" + entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks + timestamp: integer("timestamp").notNull(), // unix epoch seconds +}, (table) => [ + index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), + index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), +]); + export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { messageId: integer("messageId").primaryKey({ autoIncrement: true }), wsClientId: text("clientId"), @@ -1258,3 +1272,4 @@ export type DeviceWebAuthCode = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type StatusHistory = InferSelectModel; diff --git a/server/routers/external.ts b/server/routers/external.ts index d7729bca5..7f9f2bdc4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -285,6 +285,20 @@ authenticated.get( site.listContainers ); +authenticated.get( + "/site/:siteId/status-history", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.getSiteStatusHistory +); + +authenticated.get( + "/target/:targetId/health-check/status-history", + verifyTargetAccess, + verifyUserHasAction(ActionsEnum.getTarget), + site.getHealthCheckStatusHistory +); + // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 4d5fd5de4..426d80323 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -1,4 +1,4 @@ -import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; +import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db"; import { hasActiveConnections, } from "#dynamic/routers/ws"; @@ -77,6 +77,14 @@ export const startNewtOfflineChecker = (): void => { .set({ online: false }) .where(eq(sites.siteId, staleSite.siteId)); + await db.insert(statusHistory).values({ + entityType: "site", + entityId: staleSite.siteId, + orgId: staleSite.orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000), + }).execute(); + const healthChecksOnSite = await db .select() .from(targetHealthCheck) @@ -147,6 +155,14 @@ export const startNewtOfflineChecker = (): void => { .set({ online: false }) .where(eq(sites.siteId, site.siteId)); + await db.insert(statusHistory).values({ + entityType: "site", + entityId: site.siteId, + orgId: site.orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000), + }).execute(); + await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); } else if ( lastBandwidthUpdate >= wireguardOfflineThreshold && @@ -161,6 +177,14 @@ export const startNewtOfflineChecker = (): void => { .set({ online: true }) .where(eq(sites.siteId, site.siteId)); + await db.insert(statusHistory).values({ + entityType: "site", + entityId: site.siteId, + orgId: site.orgId, + status: "online", + timestamp: Math.floor(Date.now() / 1000), + }).execute(); + await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); } } diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 56429372d..b63bf97d3 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -1,5 +1,5 @@ import { db } from "@server/db"; -import { sites, clients, olms } from "@server/db"; +import { sites, clients, olms, statusHistory } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; @@ -147,6 +147,13 @@ async function flushSitePingsToDb(): Promise { }, "flushSitePingsToDb"); for (const site of newlyOnlineSites) { + await db.insert(statusHistory).values({ + entityType: "site", + entityId: site.siteId, + orgId: site.orgId, + status: "online", + timestamp: Math.floor(Date.now() / 1000), + }).execute(); await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); } } catch (error) { diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts new file mode 100644 index 000000000..d17fa83a9 --- /dev/null +++ b/server/routers/site/getStatusHistory.ts @@ -0,0 +1,305 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const siteParamsSchema = z.object({ + siteId: z.string().transform((v) => parseInt(v, 10)), +}); + +const healthCheckParamsSchema = z.object({ + targetHealthCheckId: z.string().transform((v) => parseInt(v, 10)), +}); + +const querySchema = z + .object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + }) + .pipe( + z.object({ + days: z.number().int().min(1).max(365), + }) + ); + +export interface DayBucket { + date: string; // ISO date "YYYY-MM-DD" + uptimePercent: number; // 0-100 + totalDowntimeSeconds: number; + downtimeWindows: { start: number; end: number | null; status: string }[]; + status: "good" | "degraded" | "bad" | "no_data"; +} + +export interface StatusHistoryResponse { + entityType: string; + entityId: number; + days: DayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +function computeBuckets( + events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[], + days: number +): { buckets: DayBucket[]; totalDowntime: number } { + const nowSec = Math.floor(Date.now() / 1000); + const buckets: DayBucket[] = []; + let totalDowntime = 0; + + for (let d = 0; d < days; d++) { + const dayStartSec = nowSec - (days - d) * 86400; + const dayEndSec = dayStartSec + 86400; + + const dayEvents = events.filter( + (e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec + ); + + // Determine the status at the start of this day (last event before dayStart) + const lastBeforeDay = [...events] + .filter((e) => e.timestamp < dayStartSec) + .at(-1); + + let currentStatus = lastBeforeDay?.status ?? null; + + const windows: { start: number; end: number | null; status: string }[] = []; + let dayDowntime = 0; + + let windowStart = dayStartSec; + let windowStatus = currentStatus; + + for (const evt of dayEvents) { + if (windowStatus !== null && windowStatus !== evt.status) { + const windowEnd = evt.timestamp; + const isDown = + windowStatus === "offline" || + windowStatus === "unhealthy" || + windowStatus === "unknown"; + if (isDown) { + dayDowntime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus, + }); + } + } + windowStart = evt.timestamp; + windowStatus = evt.status; + } + + // Close the final window at the end of the day (or now if day hasn't ended) + if (windowStatus !== null) { + const finalEnd = Math.min(dayEndSec, nowSec); + const isDown = + windowStatus === "offline" || + windowStatus === "unhealthy" || + windowStatus === "unknown"; + if (isDown && finalEnd > windowStart) { + dayDowntime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus, + }); + } + } + + totalDowntime += dayDowntime; + + const effectiveDayLength = Math.max( + 0, + Math.min(dayEndSec, nowSec) - dayStartSec + ); + const uptimePct = + effectiveDayLength > 0 + ? Math.max( + 0, + ((effectiveDayLength - dayDowntime) / + effectiveDayLength) * + 100 + ) + : 100; + + const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + + let status: DayBucket["status"] = "no_data"; + if (currentStatus !== null || dayEvents.length > 0) { + if (uptimePct >= 99) status = "good"; + else if (uptimePct >= 50) status = "degraded"; + else status = "bad"; + } + + buckets.push({ + date: dateStr, + uptimePercent: Math.round(uptimePct * 100) / 100, + totalDowntimeSeconds: dayDowntime, + downtimeWindows: windows, + status, + }); + } + + return { buckets, totalDowntime }; +} + +export async function getSiteStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = siteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "site"; + const entityId = parsedParams.data.siteId; + const { days } = parsedQuery.data; + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + return response(res, { + data: { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime, + }, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} + +export async function getHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = healthCheckParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "healthCheck"; + const entityId = parsedParams.data.targetHealthCheckId; + const { days } = parsedQuery.data; + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + return response(res, { + data: { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime, + }, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK, + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c14..00fdeda91 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -1,4 +1,5 @@ export * from "./getSite"; +export * from "./getStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 87f47c17b..cc290c131 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -1,4 +1,4 @@ -import { db, targets, resources, sites, targetHealthCheck } from "@server/db"; +import { db, targets, resources, sites, targetHealthCheck, statusHistory } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -137,6 +137,15 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); + // Log the state change to status history + await db.insert(statusHistory).values({ + entityType: "healthCheck", + entityId: targetCheck.targetHealthCheckId, + orgId: targetCheck.orgId || targetCheck.resourceOrgId, + status: healthStatus.status, + timestamp: Math.floor(Date.now() / 1000), + }).execute(); + // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { await fireHealthCheckHealthyAlert( diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 71dc32e70..93114c5b2 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -1,5 +1,7 @@ "use client"; +import UptimeBar from "@app/components/UptimeBar"; + import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -223,6 +225,19 @@ export default function GeneralPage() { + + + Uptime + + Site availability over the last 90 days. + + + + {site?.siteId && ( + + )} + + ); } diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index ed5296c82..81299fcb4 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -1,5 +1,7 @@ "use client"; +import UptimeMiniBar from "@app/components/UptimeMiniBar"; + import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { HealthCheckRow @@ -219,6 +221,16 @@ export default function HealthChecksTable({ } } }, + { + id: "uptime", + friendlyName: "Uptime", + header: () => Uptime (30d), + cell: ({ row }) => { + return ( + + ); + } + }, { accessorKey: "hcEnabled", friendlyName: t("alertingColumnEnabled"), diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 7fa635b87..68fbc0cac 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,6 +1,7 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import UptimeMiniBar from "@app/components/UptimeMiniBar"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; @@ -222,6 +223,17 @@ export default function SitesTable({ } } }, + { + id: "uptime", + friendlyName: "Uptime", + header: () => Uptime (30d), + cell: ({ row }) => { + const originalRow = row.original; + return ( + + ); + } + }, { accessorKey: "mbIn", friendlyName: t("dataIn"), diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx new file mode 100644 index 000000000..be88a4a21 --- /dev/null +++ b/src/components/UptimeBar.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; + +function formatDuration(seconds: number): string { + if (seconds === 0) return "0s"; + if (seconds < 60) return `${Math.round(seconds)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.round(seconds % 60); + if (h > 0) return s > 0 ? `${h}h ${m}m ${s}s` : `${h}h ${m}m`; + if (m > 0 && s > 0) return `${m}m ${s}s`; + return `${m}m`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr + "T00:00:00").toLocaleDateString([], { + month: "short", + day: "numeric", + year: "numeric" + }); +} + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit" + }); +} + +const barColorClass: Record = { + good: "bg-green-500", + degraded: "bg-yellow-500", + bad: "bg-red-500", + no_data: "bg-zinc-700" +}; + +type UptimeBarProps = { + siteId?: number; + targetId?: number; + days?: number; + title?: string; + className?: string; +}; + +export default function UptimeBar({ + siteId, + targetId, + days = 90, + title, + className +}: UptimeBarProps) { + const api = createApiClient(useEnvContext()); + + const siteQuery = useQuery({ + ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), + enabled: siteId != null, + meta: { api } + }); + + const hcQuery = useQuery({ + ...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }), + enabled: targetId != null && siteId == null, + meta: { api } + }); + + const { data, isLoading } = siteId != null ? siteQuery : hcQuery; + + if (isLoading) { + return ( +
+ {title && ( +
{title}
+ )} +
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+ {/* Header row */} +
+ {title && ( + {title} + )} +
+ {!allNoData && ( + <> + + + {data.overallUptimePercent.toFixed(2)}% + {" "} + uptime + + {data.totalDowntimeSeconds > 0 && ( + + + {formatDuration( + data.totalDowntimeSeconds + )} + {" "} + downtime + + )} + + )} + {allNoData && ( + + No data available + + )} +
+
+ + {/* Bar row */} +
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+ {day.status !== "no_data" && ( +
+ Uptime:{" "} + + {day.uptimePercent.toFixed(1)}% + +
+ )} + {day.totalDowntimeSeconds > 0 && ( +
+ Downtime:{" "} + + {formatDuration( + day.totalDowntimeSeconds + )} + +
+ )} + {day.downtimeWindows.length > 0 && ( +
+ {day.downtimeWindows.map((w, wi) => ( +
+ {formatTime(w.start)} + {w.end + ? ` – ${formatTime(w.end)}` + : " – ongoing"}{" "} + + ({w.status}) + +
+ ))} +
+ )} + {day.status === "no_data" && ( +
+ No monitoring data +
+ )} +
+ + ))} +
+ + {/* Date labels */} +
+ {days} days ago + Today +
+
+ ); +} \ No newline at end of file diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx new file mode 100644 index 000000000..b92a9d765 --- /dev/null +++ b/src/components/UptimeMiniBar.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; + +function formatDuration(seconds: number): string { + if (seconds === 0) return "0s"; + if (seconds < 60) return `${Math.round(seconds)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.round(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0 && s > 0) return `${m}m ${s}s`; + return `${m}m`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr + "T00:00:00").toLocaleDateString([], { + month: "short", + day: "numeric" + }); +} + +const barColorClass: Record = { + good: "bg-green-500", + degraded: "bg-yellow-500", + bad: "bg-red-500", + no_data: "bg-zinc-700" +}; + +type UptimeMiniBarProps = { + siteId?: number; + targetId?: number; + days?: number; +}; + +export default function UptimeMiniBar({ + siteId, + targetId, + days = 30 +}: UptimeMiniBarProps) { + const api = createApiClient(useEnvContext()); + + const siteQuery = useQuery({ + ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), + enabled: siteId != null, + meta: { api } + }); + + const hcQuery = useQuery({ + ...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }), + enabled: targetId != null && siteId == null, + meta: { api } + }); + + const { data, isLoading } = siteId != null ? siteQuery : hcQuery; + + if (isLoading) { + return ( +
+
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+ +
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+
+ {day.status === "no_data" + ? "No data" + : `${day.uptimePercent.toFixed(1)}% uptime`} +
+ {day.totalDowntimeSeconds > 0 && ( +
+ Down:{" "} + {formatDuration(day.totalDowntimeSeconds)} +
+ )} +
+ + ))} +
+ + {allNoData + ? "No data" + : `${data.overallUptimePercent.toFixed(1)}%`} + +
+ ); +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f658f9fee..5e1d6ea38 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,4 +1,5 @@ import { build } from "@server/build"; +import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; @@ -304,7 +305,30 @@ export const orgQueries = { >(`/org/${orgId}/health-checks`, { signal }); return res.data.data.healthChecks; } - }) + }), + siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) => + queryOptions({ + queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site/${siteId}/status-history?days=${days}`, { signal }); + return res.data.data; + }, + refetchInterval: 60_000, + }), + + healthCheckStatusHistory: ({ targetId, days = 90 }: { targetId: number; days?: number }) => + queryOptions({ + queryKey: ["HC_STATUS_HISTORY", targetId, days] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/target/${targetId}/health-check/status-history?days=${days}`, { signal }); + return res.data.data; + }, + refetchInterval: 60_000, + }), }; export const logAnalyticsFiltersSchema = z.object({ From f932cc7acaf63948f38d02052fbae416c556f506 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 20:55:21 -0700 Subject: [PATCH 079/176] Fix status history and show on the health check --- server/db/pg/schema/schema.ts | 29 ++- server/db/sqlite/schema/schema.ts | 20 +- server/lib/statusHistory.ts | 133 ++++++++++ server/middlewares/verifyDomainAccess.ts | 2 +- server/private/routers/external.ts | 12 + .../routers/healthChecks/getStatusHistory.ts | 93 +++++++ server/private/routers/healthChecks/index.ts | 1 + server/routers/external.ts | 7 - server/routers/site/getStatusHistory.ts | 232 +----------------- src/components/HealthChecksTable.tsx | 2 +- src/components/UptimeBar.tsx | 12 +- src/components/UptimeMiniBar.tsx | 12 +- src/lib/queries.ts | 40 ++- 13 files changed, 319 insertions(+), 276 deletions(-) create mode 100644 server/lib/statusHistory.ts create mode 100644 server/private/routers/healthChecks/getStatusHistory.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bc9a72c09..f064ed906 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1092,6 +1092,20 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { complete: boolean("complete").notNull().default(false) }); +export const statusHistory = pgTable("statusHistory", { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull(), +}, (table) => [ + index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), + index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), +]); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1159,19 +1173,4 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type Network = InferSelectModel; - -export const statusHistory = pgTable("statusHistory", { - id: serial("id").primaryKey(), - entityType: varchar("entityType").notNull(), - entityId: integer("entityId").notNull(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: varchar("status").notNull(), - timestamp: integer("timestamp").notNull(), -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); - export type StatusHistory = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index d7c947347..00994fa2a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1181,6 +1181,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { }) }); +export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { + messageId: integer("messageId").primaryKey({ autoIncrement: true }), + wsClientId: text("clientId"), + messageType: text("messageType"), + sentAt: integer("sentAt").notNull(), + receivedAt: integer("receivedAt"), + error: text("error"), + complete: integer("complete", { mode: "boolean" }).notNull().default(false) +}); + export const statusHistory = sqliteTable("statusHistory", { id: integer("id").primaryKey({ autoIncrement: true }), entityType: text("entityType").notNull(), // "site" | "healthCheck" @@ -1195,16 +1205,6 @@ export const statusHistory = sqliteTable("statusHistory", { index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), ]); -export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { - messageId: integer("messageId").primaryKey({ autoIncrement: true }), - wsClientId: text("clientId"), - messageType: text("messageType"), - sentAt: integer("sentAt").notNull(), - receivedAt: integer("receivedAt"), - error: text("error"), - complete: integer("complete", { mode: "boolean" }).notNull().default(false) -}); - export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts new file mode 100644 index 000000000..001a0b93b --- /dev/null +++ b/server/lib/statusHistory.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; + +export const statusHistoryQuerySchema = z + .object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + }) + .pipe( + z.object({ + days: z.number().int().min(1).max(365), + }) + ); + +export interface StatusHistoryDayBucket { + date: string; // ISO date "YYYY-MM-DD" + uptimePercent: number; // 0-100 + totalDowntimeSeconds: number; + downtimeWindows: { start: number; end: number | null; status: string }[]; + status: "good" | "degraded" | "bad" | "no_data"; +} + +export interface StatusHistoryResponse { + entityType: string; + entityId: number; + days: StatusHistoryDayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +export function computeBuckets( + events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[], + days: number +): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { + const nowSec = Math.floor(Date.now() / 1000); + const buckets: StatusHistoryDayBucket[] = []; + let totalDowntime = 0; + + for (let d = 0; d < days; d++) { + const dayStartSec = nowSec - (days - d) * 86400; + const dayEndSec = dayStartSec + 86400; + + const dayEvents = events.filter( + (e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec + ); + + // Determine the status at the start of this day (last event before dayStart) + const lastBeforeDay = [...events] + .filter((e) => e.timestamp < dayStartSec) + .at(-1); + + const currentStatus = lastBeforeDay?.status ?? null; + + const windows: { start: number; end: number | null; status: string }[] = []; + let dayDowntime = 0; + + let windowStart = dayStartSec; + let windowStatus = currentStatus; + + for (const evt of dayEvents) { + if (windowStatus !== null && windowStatus !== evt.status) { + const windowEnd = evt.timestamp; + const isDown = + windowStatus === "offline" || + windowStatus === "unhealthy" || + windowStatus === "unknown"; + if (isDown) { + dayDowntime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus, + }); + } + } + windowStart = evt.timestamp; + windowStatus = evt.status; + } + + // Close the final window at the end of the day (or now if day hasn't ended) + if (windowStatus !== null) { + const finalEnd = Math.min(dayEndSec, nowSec); + const isDown = + windowStatus === "offline" || + windowStatus === "unhealthy" || + windowStatus === "unknown"; + if (isDown && finalEnd > windowStart) { + dayDowntime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus, + }); + } + } + + totalDowntime += dayDowntime; + + const effectiveDayLength = Math.max( + 0, + Math.min(dayEndSec, nowSec) - dayStartSec + ); + const uptimePct = + effectiveDayLength > 0 + ? Math.max( + 0, + ((effectiveDayLength - dayDowntime) / + effectiveDayLength) * + 100 + ) + : 100; + + const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + + let status: StatusHistoryDayBucket["status"] = "no_data"; + if (currentStatus !== null || dayEvents.length > 0) { + if (uptimePct >= 99) status = "good"; + else if (uptimePct >= 50) status = "degraded"; + else status = "bad"; + } + + buckets.push({ + date: dateStr, + uptimePercent: Math.round(uptimePct * 100) / 100, + totalDowntimeSeconds: dayDowntime, + downtimeWindows: windows, + status, + }); + } + + return { buckets, totalDowntime }; +} diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index c9ecf42e0..d37f6725d 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -15,7 +15,7 @@ export async function verifyDomainAccess( try { const userId = req.user!.userId; const domainId = - req.params.domainId || req.body.apiKeyId || req.query.apiKeyId; + req.params.domainId; const orgId = req.params.orgId; if (!userId) { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 153f1e839..f7a4c71ab 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -657,6 +657,7 @@ authenticated.delete( authenticated.get( "/org/:orgId/event-streaming-destinations", + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), eventStreamingDestination.listEventStreamingDestinations @@ -692,6 +693,7 @@ authenticated.delete( authenticated.get( "/org/:orgId/alert-rules", + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listAlertRules), alertRule.listAlertRules @@ -699,6 +701,7 @@ authenticated.get( authenticated.get( "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.getAlertRule), alertRule.getAlertRule @@ -706,6 +709,7 @@ authenticated.get( authenticated.get( "/org/:orgId/health-checks", + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listHealthChecks), healthChecks.listHealthChecks @@ -738,3 +742,11 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteHealthCheck), healthChecks.deleteHealthCheck ); + +authenticated.get( + "/org/:orgId/health-check/:healthCheckId/status-history", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getTarget), + healthChecks.getHealthCheckStatusHistory +); diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts new file mode 100644 index 000000000..f010c8ed7 --- /dev/null +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + computeBuckets, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const healthCheckParamsSchema = z.object({ + healthCheckId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = healthCheckParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "healthCheck"; + const entityId = parsedParams.data.healthCheckId + const { days } = parsedQuery.data; + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + return response(res, { + data: { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }, + success: true, + error: false, + message: "Status history 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/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts index 5f5c796f3..665ae5cca 100644 --- a/server/private/routers/healthChecks/index.ts +++ b/server/private/routers/healthChecks/index.ts @@ -15,3 +15,4 @@ export * from "./listHealthChecks"; export * from "./createHealthCheck"; export * from "./updateHealthCheck"; export * from "./deleteHealthCheck"; +export * from "./getStatusHistory"; diff --git a/server/routers/external.ts b/server/routers/external.ts index 7f9f2bdc4..34bbe4f88 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -292,13 +292,6 @@ authenticated.get( site.getSiteStatusHistory ); -authenticated.get( - "/target/:targetId/health-check/status-history", - verifyTargetAccess, - verifyUserHasAction(ActionsEnum.getTarget), - site.getHealthCheckStatusHistory -); - // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index d17fa83a9..f1717c8a9 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -7,147 +7,16 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { + computeBuckets, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; const siteParamsSchema = z.object({ - siteId: z.string().transform((v) => parseInt(v, 10)), + siteId: z.string().transform((v) => parseInt(v, 10)) }); -const healthCheckParamsSchema = z.object({ - targetHealthCheckId: z.string().transform((v) => parseInt(v, 10)), -}); - -const querySchema = z - .object({ - days: z - .string() - .optional() - .transform((v) => (v ? parseInt(v, 10) : 90)), - }) - .pipe( - z.object({ - days: z.number().int().min(1).max(365), - }) - ); - -export interface DayBucket { - date: string; // ISO date "YYYY-MM-DD" - uptimePercent: number; // 0-100 - totalDowntimeSeconds: number; - downtimeWindows: { start: number; end: number | null; status: string }[]; - status: "good" | "degraded" | "bad" | "no_data"; -} - -export interface StatusHistoryResponse { - entityType: string; - entityId: number; - days: DayBucket[]; - overallUptimePercent: number; - totalDowntimeSeconds: number; -} - -function computeBuckets( - events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[], - days: number -): { buckets: DayBucket[]; totalDowntime: number } { - const nowSec = Math.floor(Date.now() / 1000); - const buckets: DayBucket[] = []; - let totalDowntime = 0; - - for (let d = 0; d < days; d++) { - const dayStartSec = nowSec - (days - d) * 86400; - const dayEndSec = dayStartSec + 86400; - - const dayEvents = events.filter( - (e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec - ); - - // Determine the status at the start of this day (last event before dayStart) - const lastBeforeDay = [...events] - .filter((e) => e.timestamp < dayStartSec) - .at(-1); - - let currentStatus = lastBeforeDay?.status ?? null; - - const windows: { start: number; end: number | null; status: string }[] = []; - let dayDowntime = 0; - - let windowStart = dayStartSec; - let windowStatus = currentStatus; - - for (const evt of dayEvents) { - if (windowStatus !== null && windowStatus !== evt.status) { - const windowEnd = evt.timestamp; - const isDown = - windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; - if (isDown) { - dayDowntime += windowEnd - windowStart; - windows.push({ - start: windowStart, - end: windowEnd, - status: windowStatus, - }); - } - } - windowStart = evt.timestamp; - windowStatus = evt.status; - } - - // Close the final window at the end of the day (or now if day hasn't ended) - if (windowStatus !== null) { - const finalEnd = Math.min(dayEndSec, nowSec); - const isDown = - windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; - if (isDown && finalEnd > windowStart) { - dayDowntime += finalEnd - windowStart; - windows.push({ - start: windowStart, - end: finalEnd, - status: windowStatus, - }); - } - } - - totalDowntime += dayDowntime; - - const effectiveDayLength = Math.max( - 0, - Math.min(dayEndSec, nowSec) - dayStartSec - ); - const uptimePct = - effectiveDayLength > 0 - ? Math.max( - 0, - ((effectiveDayLength - dayDowntime) / - effectiveDayLength) * - 100 - ) - : 100; - - const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); - - let status: DayBucket["status"] = "no_data"; - if (currentStatus !== null || dayEvents.length > 0) { - if (uptimePct >= 99) status = "good"; - else if (uptimePct >= 50) status = "degraded"; - else status = "bad"; - } - - buckets.push({ - date: dateStr, - uptimePercent: Math.round(uptimePct * 100) / 100, - totalDowntimeSeconds: dayDowntime, - downtimeWindows: windows, - status, - }); - } - - return { buckets, totalDowntime }; -} - export async function getSiteStatusHistory( req: Request, res: Response, @@ -163,7 +32,7 @@ export async function getSiteStatusHistory( ) ); } - const parsedQuery = querySchema.safeParse(req.query); + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( @@ -208,98 +77,17 @@ export async function getSiteStatusHistory( entityId, days: buckets, overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime, + totalDowntimeSeconds: totalDowntime }, success: true, error: false, message: "Status history retrieved successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (error) { logger.error(error); return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred" - ) + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } - -export async function getHealthCheckStatusHistory( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = healthCheckParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - const parsedQuery = querySchema.safeParse(req.query); - if (!parsedQuery.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedQuery.error).toString() - ) - ); - } - - const entityType = "healthCheck"; - const entityId = parsedParams.data.targetHealthCheckId; - const { days } = parsedQuery.data; - - const nowSec = Math.floor(Date.now() / 1000); - const startSec = nowSec - days * 86400; - - const events = await db - .select() - .from(statusHistory) - .where( - and( - eq(statusHistory.entityType, entityType), - eq(statusHistory.entityId, entityId), - gte(statusHistory.timestamp, startSec) - ) - ) - .orderBy(asc(statusHistory.timestamp)); - - const { buckets, totalDowntime } = computeBuckets(events, days); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max( - 0, - ((totalWindow - totalDowntime) / totalWindow) * 100 - ) - : 100; - - return response(res, { - data: { - entityType, - entityId, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime, - }, - success: true, - error: false, - message: "Status history retrieved successfully", - status: HttpCode.OK, - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred" - ) - ); - } -} \ No newline at end of file diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 81299fcb4..a314070d4 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -227,7 +227,7 @@ export default function HealthChecksTable({ header: () => Uptime (30d), cell: ({ row }) => { return ( - + ); } }, diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index be88a4a21..d2f29b760 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -45,16 +45,18 @@ const barColorClass: Record = { }; type UptimeBarProps = { + orgId?: string; siteId?: number; - targetId?: number; + healthCheckId?: number; days?: number; title?: string; className?: string; }; export default function UptimeBar({ + orgId, siteId, - targetId, + healthCheckId, days = 90, title, className @@ -68,8 +70,8 @@ export default function UptimeBar({ }); const hcQuery = useQuery({ - ...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }), - enabled: targetId != null && siteId == null, + ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), + enabled: healthCheckId != null && siteId == null, meta: { api } }); @@ -205,4 +207,4 @@ export default function UptimeBar({
); -} \ No newline at end of file +} diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b92a9d765..f668ac023 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -37,14 +37,16 @@ const barColorClass: Record = { }; type UptimeMiniBarProps = { + orgId?: string; siteId?: number; - targetId?: number; + healthCheckId?: number; days?: number; }; export default function UptimeMiniBar({ + orgId, siteId, - targetId, + healthCheckId, days = 30 }: UptimeMiniBarProps) { const api = createApiClient(useEnvContext()); @@ -56,8 +58,8 @@ export default function UptimeMiniBar({ }); const hcQuery = useQuery({ - ...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }), - enabled: targetId != null && siteId == null, + ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), + enabled: healthCheckId != null && siteId == null, meta: { api } }); @@ -125,4 +127,4 @@ export default function UptimeMiniBar({
); -} \ No newline at end of file +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5e1d6ea38..dbd1e0bfb 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,5 +1,4 @@ import { build } from "@server/build"; -import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; @@ -29,6 +28,7 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import { StatusHistoryResponse } from "@server/middlewares/statusHistory"; export type ProductUpdate = { link: string | null; @@ -306,7 +306,13 @@ export const orgQueries = { return res.data.data.healthChecks; } }), - siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) => + siteStatusHistory: ({ + siteId, + days = 90 + }: { + siteId: number; + days?: number; + }) => queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryFn: async ({ signal, meta }) => { @@ -314,21 +320,35 @@ export const orgQueries = { AxiosResponse >(`/site/${siteId}/status-history?days=${days}`, { signal }); return res.data.data; - }, - refetchInterval: 60_000, + } }), - healthCheckStatusHistory: ({ targetId, days = 90 }: { targetId: number; days?: number }) => + healthCheckStatusHistory: ({ + orgId, + healthCheckId, + days = 90 + }: { + orgId: string; + healthCheckId: number; + days?: number; + }) => queryOptions({ - queryKey: ["HC_STATUS_HISTORY", targetId, days] as const, + queryKey: [ + "HC_STATUS_HISTORY", + orgId, + healthCheckId, + days + ] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/target/${targetId}/health-check/status-history?days=${days}`, { signal }); + >( + `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, + { signal } + ); return res.data.data; - }, - refetchInterval: 60_000, - }), + } + }) }; export const logAnalyticsFiltersSchema = z.object({ From 3645cc57591dd6d9fbe7455b9631abe01a49b9e7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 21:27:06 -0700 Subject: [PATCH 080/176] Update websocket to be consistant with streaming --- server/private/lib/alerts/sendAlertWebhook.ts | 10 +- server/private/lib/alerts/types.ts | 4 + .../routers/alertRule/createAlertRule.ts | 5 +- .../private/routers/alertRule/getAlertRule.ts | 29 ++- .../routers/alertRule/updateAlertRule.ts | 5 +- .../alert-rule-editor/AlertRuleFields.tsx | 214 ++++++++++++++++-- src/lib/alertRuleForm.ts | 55 +++-- src/lib/queries.ts | 2 +- 8 files changed, 275 insertions(+), 49 deletions(-) diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index a1cb79c60..52c687cbc 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -55,7 +55,7 @@ export async function sendAlertWebhook( let response: Response; try { response = await fetch(url, { - method: "POST", + method: webhookConfig.method ?? "POST", headers, body, signal: controller.signal @@ -128,5 +128,13 @@ function buildHeaders(webhookConfig: WebhookAlertConfig): Record break; } + if (webhookConfig.headers) { + for (const { key, value } of webhookConfig.headers) { + if (key.trim()) { + headers[key.trim()] = value; + } + } + } + return headers; } \ No newline at end of file diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index 626c2710f..e79db2ef5 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -41,6 +41,10 @@ export interface WebhookAlertConfig { customHeaderName?: string; /** Custom header value – used when authType === "custom" */ customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; } // --------------------------------------------------------------------------- diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index fa547661b..40408d898 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -28,6 +28,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -247,11 +249,12 @@ export async function createAlertRule( } if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config ?? null, + config: wa.config != null ? encrypt(wa.config, serverSecret) : null, enabled: wa.enabled })) ); diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index a493cd279..5d307316b 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -29,6 +29,9 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { WebhookAlertConfig } from "@server/lib/alerts/types"; const paramsSchema = z .object({ @@ -64,6 +67,7 @@ export type GetAlertRuleResponse = { webhookUrl: string; enabled: boolean; lastSentAt: number | null; + config: WebhookAlertConfig | null; }[]; }; @@ -172,12 +176,25 @@ export async function getAlertRule( siteIds: siteRows.map((r) => r.siteId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, - webhookActions: webhooks.map((w) => ({ - webhookActionId: w.webhookActionId, - webhookUrl: w.webhookUrl, - enabled: w.enabled, - lastSentAt: w.lastSentAt ?? null - })) + webhookActions: webhooks.map((w) => { + let parsedConfig: WebhookAlertConfig | null = null; + if (w.config) { + try { + const serverSecret = config.getRawConfig().server.secret!; + const decrypted = decrypt(w.config, serverSecret); + parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch { + // best-effort – return null if decryption fails + } + } + return { + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) }, success: true, error: false, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 4cbd3795a..add031dc4 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -29,6 +29,8 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -302,11 +304,12 @@ export async function updateAlertRule( .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config ?? null, + config: wa.config != null ? encrypt(wa.config, serverSecret) : null, enabled: wa.enabled })) ); diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 824fc1b10..8af7e780c 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -30,6 +30,11 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { + RadioGroup, + RadioGroupItem +} from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { @@ -322,8 +327,12 @@ export function ActionBlock({ type: "webhook", url: "", method: "POST", - headers: [{ key: "", value: "" }], - secret: "" + headers: [], + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" }); } }} @@ -580,26 +589,187 @@ function WebhookActionFields({ )} /> - ( - - {t("alertingWebhookSecret")} - - - - - - )} - /> + {/* Authentication */} +
+
+ +

+ {t("httpDestAuthDescription")} +

+
+ ( + + + + {/* None */} +
+ +
+ +

+ {t("httpDestAuthNoneDescription")} +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ {t("httpDestAuthBearerDescription")} +

+
+ {field.value === "bearer" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ {t("httpDestAuthBasicDescription")} +

+
+ {field.value === "basic" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ {t("httpDestAuthCustomDescription")} +

+
+ {field.value === "custom" && ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ )} +
+
+
+
+ +
+ )} + /> +
); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 51a95f760..2756ca165 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -34,7 +34,11 @@ export type AlertRuleFormAction = url: string; method: string; headers: { key: string; value: string }[]; - secret: string; + authType: "none" | "bearer" | "basic" | "custom"; + bearerToken: string; + basicCredentials: string; + customHeaderName: string; + customHeaderValue: string; }; export type AlertRuleFormValues = { @@ -95,6 +99,15 @@ export type AlertRuleApiResponse = { webhookUrl: string; enabled: boolean; lastSentAt: number | null; + config: { + authType: string; + bearerToken?: string; + basicCredentials?: string; + customHeaderName?: string; + customHeaderValue?: string; + headers?: { key: string; value: string }[]; + method?: string; + } | null; }[]; }; @@ -155,7 +168,11 @@ export function buildFormSchema(t: (k: string) => string) { value: z.string() }) ), - secret: z.string() + authType: z.enum(["none", "bearer", "basic", "custom"]), + bearerToken: z.string(), + basicCredentials: z.string(), + customHeaderName: z.string(), + customHeaderValue: z.string() }) ]) ) @@ -293,12 +310,19 @@ export function apiResponseToFormValues( // Each webhook action becomes its own form webhook action for (const w of rule.webhookActions) { + const cfg = w.config; actions.push({ type: "webhook", url: w.webhookUrl, - method: "POST", - headers: [{ key: "", value: "" }], - secret: "" + method: cfg?.method ?? "POST", + headers: cfg?.headers?.length + ? cfg.headers + : [{ key: "", value: "" }], + authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", + bearerToken: cfg?.bearerToken ?? "", + basicCredentials: cfg?.basicCredentials ?? "", + customHeaderName: cfg?.customHeaderName ?? "", + customHeaderValue: cfg?.customHeaderValue ?? "" }); } @@ -352,18 +376,15 @@ export function formValuesToApiPayload( webhookActions.push({ webhookUrl: action.url.trim(), enabled: true, - // Encode any headers / secret as config JSON if present - ...(action.secret.trim() || - action.headers.some((h) => h.key.trim()) - ? { - config: JSON.stringify({ - secret: action.secret.trim() || undefined, - headers: action.headers.filter( - (h) => h.key.trim() - ) - }) - } - : {}) + config: JSON.stringify({ + authType: action.authType, + bearerToken: action.bearerToken || undefined, + basicCredentials: action.basicCredentials || undefined, + customHeaderName: action.customHeaderName || undefined, + customHeaderValue: action.customHeaderValue || undefined, + headers: action.headers.filter((h) => h.key.trim()), + method: action.method + }) }); } } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index dbd1e0bfb..7380bfd66 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -28,7 +28,7 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { StatusHistoryResponse } from "@server/middlewares/statusHistory"; +import { StatusHistoryResponse } from "@server/lib/statusHistory"; export type ProductUpdate = { link: string | null; From bd89867ecb88bb0082aaa6417e066044c3a53127 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 21:42:48 -0700 Subject: [PATCH 081/176] Fix form not updating correctly --- messages/en-US.json | 3 +- .../alert-rule-editor/AlertRuleFields.tsx | 32 +++++++++++-------- .../AlertRuleGraphEditor.tsx | 11 +++++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9dd4f1262..3daef96e4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3030,5 +3030,6 @@ "httpDestUpdateFailed": "Failed to update destination", "httpDestCreateFailed": "Failed to create destination", "followRedirects": "Follow Redirects", - "followRedirectsDescription": "Automatically follow HTTP redirects for requests." + "followRedirectsDescription": "Automatically follow HTTP redirects for requests.", + "alertingErrorWebhookUrl": "Please enter a valid URL for the webhook." } diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 8af7e780c..2037d50a8 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -281,6 +281,7 @@ export function ActionBlock({ control, form, onRemove, + onUpdate, canRemove }: { orgId: string; @@ -288,10 +289,11 @@ export function ActionBlock({ control: Control; form: UseFormReturn; onRemove: () => void; + onUpdate: (val: AlertRuleFormAction) => void; canRemove: boolean; }) { const t = useTranslations(); - const type = form.watch(`actions.${index}.type`); + const type = useWatch({ control, name: `actions.${index}.type` }); return (
{canRemove && ( @@ -316,14 +318,14 @@ export function ActionBlock({ onValueChange={(v) => { const nt = v as AlertRuleFormAction["type"]; if (nt === "notify") { - form.setValue(`actions.${index}`, { + onUpdate({ type: "notify", userTags: [], roleTags: [], emailTags: [] }); } else { - form.setValue(`actions.${index}`, { + onUpdate({ type: "webhook", url: "", method: "POST", @@ -418,9 +420,9 @@ function NotifyActionFields({ [orgRoles] ); - const userTags = (form.watch(`actions.${index}.userTags`) ?? []) as Tag[]; - const roleTags = (form.watch(`actions.${index}.roleTags`) ?? []) as Tag[]; - const emailTags = (form.watch(`actions.${index}.emailTags`) ?? []) as Tag[]; + const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[]; + const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[]; + const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[]; return (
@@ -445,7 +447,8 @@ function NotifyActionFields({ : newTags; form.setValue( `actions.${index}.userTags`, - next as Tag[] + next as Tag[], + { shouldDirty: true } ); }} enableAutocomplete={true} @@ -480,7 +483,8 @@ function NotifyActionFields({ : newTags; form.setValue( `actions.${index}.roleTags`, - next as Tag[] + next as Tag[], + { shouldDirty: true } ); }} enableAutocomplete={true} @@ -511,7 +515,8 @@ function NotifyActionFields({ : updater; form.setValue( `actions.${index}.emailTags`, - next as Tag[] + next as Tag[], + { shouldDirty: true } ); }} activeTagIndex={emailActiveIdx} @@ -786,7 +791,7 @@ function WebhookHeadersField({ }) { const t = useTranslations(); const headers = - form.watch(`actions.${index}.headers` as const) ?? []; + (useWatch({ control, name: `actions.${index}.headers` as const }) ?? []); return (
{t("alertingWebhookHeaders")} @@ -826,7 +831,8 @@ function WebhookHeadersField({ ) ?? []; form.setValue( `actions.${index}.headers`, - cur.filter((__, i) => i !== hi) + cur.filter((__, i) => i !== hi), + { shouldDirty: true } ); }} > @@ -844,7 +850,7 @@ function WebhookHeadersField({ form.setValue(`actions.${index}.headers`, [ ...cur, { key: "", value: "" } - ]); + ], { shouldDirty: true }); }} > @@ -1008,4 +1014,4 @@ export function AlertRuleTriggerFields({ )} /> ); -} \ No newline at end of file +} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 5dcdb7cf2..e5ccf469b 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -316,7 +316,7 @@ export default function AlertRuleGraphEditor({ defaultValues: initialValues ?? defaultFormValues() }); - const { fields, append, remove } = useFieldArray({ + const { fields, append, remove, update } = useFieldArray({ control: form.control, name: "actions" }); @@ -687,7 +687,11 @@ export default function AlertRuleGraphEditor({ value: "" } ], - secret: "" + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" }); } setSelectedStep( @@ -706,6 +710,9 @@ export default function AlertRuleGraphEditor({ onRemove={() => remove(index) } + onUpdate={(val) => + update(index, val) + } canRemove /> ))} From f74791111e51bddf4e6a9c1dc4307511d4bfe942 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 15:14:01 -0700 Subject: [PATCH 082/176] Paywalling --- messages/en-US.json | 10 +- server/lib/billing/tierMatrix.ts | 8 +- .../settings/alerting/[ruleId]/page.tsx | 5 + .../[orgId]/settings/alerting/create/page.tsx | 5 + .../[orgId]/settings/logs/streaming/page.tsx | 90 ++++- src/components/AlertingRulesTable.tsx | 10 +- src/components/HealthCheckFormFields.tsx | 311 ++++++++++++++---- src/components/HealthChecksTable.tsx | 15 +- .../AlertRuleGraphEditor.tsx | 47 ++- 9 files changed, 418 insertions(+), 83 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3daef96e4..26fbe5572 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2953,9 +2953,9 @@ "streamingHttpWebhookTitle": "HTTP Webhook", "streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.", "streamingS3Title": "Amazon S3", - "streamingS3Description": "Stream events to an S3-compatible object storage bucket. Contact support to enable this destination.", + "streamingS3Description": "Stream events to an S3-compatible object storage bucket.", "streamingDatadogTitle": "Datadog", - "streamingDatadogDescription": "Forward events directly to your Datadog account. Contact support to enable this destination.", + "streamingDatadogDescription": "Forward events directly to your Datadog account.", "streamingTypePickerDescription": "Choose a destination type to get started.", "streamingFailedToLoad": "Failed to load destinations", "streamingUnexpectedError": "An unexpected error occurred.", @@ -3031,5 +3031,9 @@ "httpDestCreateFailed": "Failed to create destination", "followRedirects": "Follow Redirects", "followRedirectsDescription": "Automatically follow HTTP redirects for requests.", - "alertingErrorWebhookUrl": "Please enter a valid URL for the webhook." + "alertingErrorWebhookUrl": "Please enter a valid URL for the webhook.", + "healthCheckStrategyHttp": "Validates connectivity and checks the HTTP response status.", + "healthCheckStrategyTcp": "Verifies TCP connectivity only, without inspecting the response.", + "healthCheckStrategySnmp": "Makes an SNMP get request to check the health of network devices and infrastructure.", + "healthCheckStrategyIcmp": "Uses ICMP echo requests (pings) to check if a resource is reachable and responsive." } diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index d64ed1b56..5ae57c8a7 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -21,7 +21,9 @@ export enum TierFeature { SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SIEM = "siem", // handle downgrade by disabling SIEM integrations HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources - DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces + DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces + StandaloneHealthChecks = "standaloneHealthChecks", + AlertingRules = "alertingRules" } export const tierMatrix: Record = { @@ -60,5 +62,7 @@ export const tierMatrix: Record = { [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SIEM]: ["enterprise"], [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], - [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"] + [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"], + [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"] }; diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index c9ef938d5..34afceaab 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -4,7 +4,9 @@ import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGra import { apiResponseToFormValues } from "@app/lib/alertRuleForm"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; @@ -21,6 +23,8 @@ export default function EditAlertRulePage() { const alertRuleId = parseInt(ruleIdParam, 10); const api = createApiClient(useEnvContext()); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); const [formValues, setFormValues] = useState(undefined); @@ -73,6 +77,7 @@ export default function EditAlertRulePage() { alertRuleId={alertRuleId} initialValues={formValues} isNew={false} + disabled={!isPaid} /> ); } diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx index fc5c51660..babc018fa 100644 --- a/src/app/[orgId]/settings/alerting/create/page.tsx +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -2,17 +2,22 @@ import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; import { defaultFormValues } from "@app/lib/alertRuleForm"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useParams } from "next/navigation"; export default function NewAlertRulePage() { const params = useParams(); const orgId = params.orgId as string; + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); return ( ); } \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx index 5192a9c9b..069059868 100644 --- a/src/app/[orgId]/settings/logs/streaming/page.tsx +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -22,7 +22,8 @@ import { } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; import { Switch } from "@app/components/ui/switch"; -import { Globe, MoreHorizontal, Plus } from "lucide-react"; +import { Globe, MoreHorizontal, Plus, ExternalLink, KeyRound } from "lucide-react"; +import Link from "next/link"; import { AxiosResponse } from "axios"; import { build } from "@server/build"; import Image from "next/image"; @@ -181,6 +182,65 @@ interface DestinationTypePickerProps { isPaywalled?: boolean; } +const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922"; +const CONTACT_URL = "https://pangolin.net/contact"; + +function ContactSalesDialog({ + open, + onOpenChange +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const t = useTranslations(); + return ( + + + + {t("streamingAddDestination")} + + +
+
+
+ + + Contact sales to enable this feature.{" "} + + Book a demo + + + {" or "} + + contact us + + + . + +
+
+
+
+ + + + + +
+
+ ); +} + function DestinationTypePicker({ open, onOpenChange, @@ -189,6 +249,17 @@ function DestinationTypePicker({ }: DestinationTypePickerProps) { const t = useTranslations(); const [selected, setSelected] = useState("http"); + const [contactSalesOpen, setContactSalesOpen] = useState(false); + + const ENTERPRISE_ONLY_TYPES: DestinationType[] = ["s3", "datadog"]; + + function handleOptionSelect(type: DestinationType) { + if (ENTERPRISE_ONLY_TYPES.includes(type)) { + setContactSalesOpen(true); + } else { + onSelect(type); + } + } const destinationTypeOptions: ReadonlyArray< StrategyOption @@ -203,7 +274,6 @@ function DestinationTypePicker({ id: "s3", title: t("streamingS3Title"), description: t("streamingS3Description"), - disabled: true, icon: ( + @@ -255,7 +329,12 @@ function DestinationTypePicker({ { + setSelected(type); + if (ENTERPRISE_ONLY_TYPES.includes(type)) { + setContactSalesOpen(true); + } + }} cols={1} />
@@ -265,7 +344,7 @@ function DestinationTypePicker({ + + + + + ); +} type HealthCheckFormFieldsProps = { form: UseFormReturn; @@ -40,10 +112,19 @@ export function HealthCheckFormFields({ watchedMode }: HealthCheckFormFieldsProps) { const t = useTranslations(); + const [contactSalesOpen, setContactSalesOpen] = useState(false); const showFields = hideEnabledField || watchedEnabled; - const handleChange = (fieldName: string, value: any, fieldOnChange: (v: any) => void) => { + const handleChange = ( + fieldName: string, + value: any, + fieldOnChange: (v: any) => void + ) => { + if (fieldName === "hcMode" && UNIMPLEMENTED_MODES.includes(value)) { + setContactSalesOpen(true); + return; + } fieldOnChange(value); if (onFieldChange) { onFieldChange(fieldName, value); @@ -52,6 +133,10 @@ export function HealthCheckFormFields({ return ( <> + {/* Name */} {showNameField && ( @@ -86,7 +173,11 @@ export function HealthCheckFormFields({ - handleChange("hcEnabled", value, field.onChange) + handleChange( + "hcEnabled", + value, + field.onChange + ) } /> @@ -103,25 +194,41 @@ export function HealthCheckFormFields({ name="hcMode" render={({ field }) => ( - {t("healthCheckStrategy")} + + {t("healthCheckStrategy")} + - handleChange("hcMode", value, field.onChange) + handleChange( + "hcMode", + value, + field.onChange + ) } /> @@ -138,7 +245,9 @@ export function HealthCheckFormFields({ name="hcHostname" render={({ field }) => ( - {t("healthHostname")} + + {t("healthHostname")} + { - const value = e.target.value; - handleChange("hcPort", value, field.onChange); + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); }} /> @@ -185,23 +299,35 @@ export function HealthCheckFormFields({ name="hcScheme" render={({ field }) => ( - {t("healthScheme")} + + {t("healthScheme")} + @@ -213,7 +339,9 @@ export function HealthCheckFormFields({ name="hcHostname" render={({ field }) => ( - {t("healthHostname")} + + {t("healthHostname")} + { - const value = e.target.value; - handleChange("hcPort", value, field.onChange); + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); }} /> @@ -266,23 +399,39 @@ export function HealthCheckFormFields({ {t("httpMethod")} @@ -294,7 +443,9 @@ export function HealthCheckFormFields({ name="hcPath" render={({ field }) => ( - {t("healthCheckPath")} + + {t("healthCheckPath")} + ( - {t("timeoutSeconds")} + + {t("timeoutSeconds")} + { - const value = parseInt(e.target.value); - handleChange("hcTimeout", value, field.onChange); + const value = parseInt( + e.target.value + ); + handleChange( + "hcTimeout", + value, + field.onChange + ); }} /> @@ -347,8 +506,14 @@ export function HealthCheckFormFields({ type="number" {...field} onChange={(e) => { - const value = parseInt(e.target.value); - handleChange("hcTimeout", value, field.onChange); + const value = parseInt( + e.target.value + ); + handleChange( + "hcTimeout", + value, + field.onChange + ); }} /> @@ -365,14 +530,22 @@ export function HealthCheckFormFields({ name="hcInterval" render={({ field }) => ( - {t("healthyIntervalSeconds")} + + {t("healthyIntervalSeconds")} + { - const value = parseInt(e.target.value); - handleChange("hcInterval", value, field.onChange); + const value = parseInt( + e.target.value + ); + handleChange( + "hcInterval", + value, + field.onChange + ); }} /> @@ -385,13 +558,17 @@ export function HealthCheckFormFields({ name="hcHealthyThreshold" render={({ field }) => ( - {t("healthyThreshold")} + + {t("healthyThreshold")} + { - const value = parseInt(e.target.value); + const value = parseInt( + e.target.value + ); handleChange( "hcHealthyThreshold", value, @@ -413,13 +590,17 @@ export function HealthCheckFormFields({ name="hcUnhealthyInterval" render={({ field }) => ( - {t("unhealthyIntervalSeconds")} + + {t("unhealthyIntervalSeconds")} + { - const value = parseInt(e.target.value); + const value = parseInt( + e.target.value + ); handleChange( "hcUnhealthyInterval", value, @@ -437,13 +618,17 @@ export function HealthCheckFormFields({ name="hcUnhealthyThreshold" render={({ field }) => ( - {t("unhealthyThreshold")} + + {t("unhealthyThreshold")} + { - const value = parseInt(e.target.value); + const value = parseInt( + e.target.value + ); handleChange( "hcUnhealthyThreshold", value, @@ -468,15 +653,24 @@ export function HealthCheckFormFields({ name="hcStatus" render={({ field }) => ( - {t("expectedResponseCodes")} + + {t("expectedResponseCodes")} + { - const val = e.target.value; - const value = val ? parseInt(val) : null; - handleChange("hcStatus", value, field.onChange); + const val = + e.target.value; + const value = val + ? parseInt(val) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); }} /> @@ -489,7 +683,9 @@ export function HealthCheckFormFields({ name="hcTlsServerName" render={({ field }) => ( - {t("tlsServerName")} + + {t("tlsServerName")} + field.onChange(e) + (v) => + field.onChange( + e + ) ) } /> @@ -539,7 +738,9 @@ export function HealthCheckFormFields({ name="hcHeaders" render={({ field }) => ( - {t("customHeaders")} + + {t("customHeaders")} + handleToggleEnabled(r, v)} /> ); @@ -267,6 +275,7 @@ export default function HealthChecksTable({ { setSelected(r); setDeleteOpen(true); @@ -280,6 +289,7 @@ export default function HealthChecksTable({
+ @@ -644,21 +654,25 @@ export default function AlertRuleGraphEditor({ {t("alertingSidebarHint")} - -
- {selectedStep === "source" && ( - - )} - {selectedStep === "trigger" && ( - - )} - {isActionsSidebar && ( -
+ +
+
+ {selectedStep === "source" && ( + + )} + {selectedStep === "trigger" && ( + + )} + {isActionsSidebar && ( +
{t( @@ -719,6 +733,7 @@ export default function AlertRuleGraphEditor({
)}
+
From 008ad0a1de1c31d84124c7a0e5664c636cf17507 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 15:33:26 -0700 Subject: [PATCH 083/176] Showing the paid feature --- messages/en-US.json | 8 + .../[orgId]/settings/logs/streaming/page.tsx | 129 +- src/components/DatadogDestinationCredenza.tsx | 93 ++ src/components/HealthCheckFormFields.tsx | 1126 ++++++++--------- src/components/S3DestinationCredenza.tsx | 93 ++ 5 files changed, 792 insertions(+), 657 deletions(-) create mode 100644 src/components/DatadogDestinationCredenza.tsx create mode 100644 src/components/S3DestinationCredenza.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 26fbe5572..f3fd12900 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2971,6 +2971,14 @@ "httpDestAddTitle": "Add HTTP Destination", "httpDestEditDescription": "Update the configuration for this HTTP event streaming destination.", "httpDestAddDescription": "Configure a new HTTP endpoint to receive your organization's events.", + "S3DestEditTitle": "Edit Destination", + "S3DestAddTitle": "Add S3 Destination", + "S3DestEditDescription": "Update the configuration for this S3 event streaming destination.", + "S3DestAddDescription": "Configure a new S3 endpoint to receive your organization's events.", + "datadogDestEditTitle": "Edit Destination", + "datadogDestAddTitle": "Add Datadog Destination", + "datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.", + "datadogDestAddDescription": "Configure a new Datadog endpoint to receive your organization's events.", "httpDestTabSettings": "Settings", "httpDestTabHeaders": "Headers", "httpDestTabBody": "Body", diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx index 069059868..022a8eb2e 100644 --- a/src/app/[orgId]/settings/logs/streaming/page.tsx +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -22,8 +22,7 @@ import { } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; import { Switch } from "@app/components/ui/switch"; -import { Globe, MoreHorizontal, Plus, ExternalLink, KeyRound } from "lucide-react"; -import Link from "next/link"; +import { Globe, MoreHorizontal, Plus } from "lucide-react"; import { AxiosResponse } from "axios"; import { build } from "@server/build"; import Image from "next/image"; @@ -39,6 +38,8 @@ import { HttpDestinationCredenza, parseHttpConfig } from "@app/components/HttpDestinationCredenza"; +import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza"; +import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza"; import { useTranslations } from "next-intl"; // ── Re-export Destination so the rest of the file can use it ────────────────── @@ -182,65 +183,6 @@ interface DestinationTypePickerProps { isPaywalled?: boolean; } -const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922"; -const CONTACT_URL = "https://pangolin.net/contact"; - -function ContactSalesDialog({ - open, - onOpenChange -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const t = useTranslations(); - return ( - - - - {t("streamingAddDestination")} - - -
-
-
- - - Contact sales to enable this feature.{" "} - - Book a demo - - - {" or "} - - contact us - - - . - -
-
-
-
- - - - - -
-
- ); -} - function DestinationTypePicker({ open, onOpenChange, @@ -249,17 +191,6 @@ function DestinationTypePicker({ }: DestinationTypePickerProps) { const t = useTranslations(); const [selected, setSelected] = useState("http"); - const [contactSalesOpen, setContactSalesOpen] = useState(false); - - const ENTERPRISE_ONLY_TYPES: DestinationType[] = ["s3", "datadog"]; - - function handleOptionSelect(type: DestinationType) { - if (ENTERPRISE_ONLY_TYPES.includes(type)) { - setContactSalesOpen(true); - } else { - onSelect(type); - } - } const destinationTypeOptions: ReadonlyArray< StrategyOption @@ -305,11 +236,6 @@ function DestinationTypePicker({ }, [open]); return ( - <> - @@ -329,12 +255,7 @@ function DestinationTypePicker({ { - setSelected(type); - if (ENTERPRISE_ONLY_TYPES.includes(type)) { - setContactSalesOpen(true); - } - }} + onChange={(type) => setSelected(type)} cols={1} />
@@ -344,7 +265,7 @@ function DestinationTypePicker({ + + + + + ); +} diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index ff57fb000..6f5d528db 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import { UseFormReturn } from "react-hook-form"; import { useTranslations } from "next-intl"; import { Input } from "@/components/ui/input"; @@ -22,78 +21,9 @@ import { FormLabel, FormMessage } from "@/components/ui/form"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { Button } from "@/components/ui/button"; import { ExternalLink, KeyRound } from "lucide-react"; import Link from "next/link"; -const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922"; -const CONTACT_URL = "https://pangolin.net/contact"; -const UNIMPLEMENTED_MODES = ["snmp", "icmp"]; - -function ContactSalesDialog({ - open, - onOpenChange -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - return ( - - - - Coming Soon - - -
-
-
- - - Contact sales to enable this feature.{" "} - - Book a demo - - - {" or "} - - contact us - - - . - -
-
-
-
- - - - - -
-
- ); -} - type HealthCheckFormFieldsProps = { form: UseFormReturn; onFieldChange?: (fieldName: string, value: any) => void; @@ -112,7 +42,6 @@ export function HealthCheckFormFields({ watchedMode }: HealthCheckFormFieldsProps) { const t = useTranslations(); - const [contactSalesOpen, setContactSalesOpen] = useState(false); const showFields = hideEnabledField || watchedEnabled; @@ -121,10 +50,6 @@ export function HealthCheckFormFields({ value: any, fieldOnChange: (v: any) => void ) => { - if (fieldName === "hcMode" && UNIMPLEMENTED_MODES.includes(value)) { - setContactSalesOpen(true); - return; - } fieldOnChange(value); if (onFieldChange) { onFieldChange(fieldName, value); @@ -133,10 +58,6 @@ export function HealthCheckFormFields({ return ( <> - {/* Name */} {showNameField && ( ( - - {t("healthCheckStrategy")} - - {/* Connection fields */} - {watchedMode === "tcp" ? ( -
- ( - - - {t("healthHostname")} - - - - handleChange( - "hcHostname", - e.target.value, - (v) => field.onChange(e) - ) - } - /> - - - - )} - /> - ( - - {t("healthPort")} - - { - const value = - e.target.value; - handleChange( - "hcPort", - value, - field.onChange - ); - }} - /> - - - - )} - /> -
- ) : ( -
- ( - - - {t("healthScheme")} - - - - - )} - /> - ( - - - {t("healthHostname")} - - - - handleChange( - "hcHostname", - e.target.value, - (v) => field.onChange(e) - ) - } - /> - - - - )} - /> - ( - - {t("healthPort")} - - { - const value = - e.target.value; - handleChange( - "hcPort", - value, - field.onChange - ); - }} - /> - - - - )} - /> -
- )} - - {/* HTTP Method + Timeout (shown when not TCP) */} - {watchedMode !== "tcp" && ( -
- ( - - {t("httpMethod")} - - - - )} - /> - ( - - - {t("healthCheckPath")} - - - - handleChange( - "hcPath", - e.target.value, - (v) => field.onChange(e) - ) - } - /> - - - - )} - /> - ( - - - {t("timeoutSeconds")} - - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcTimeout", - value, - field.onChange - ); - }} - /> - - - - )} - /> + contact us + + + . + +
+ )} - {/* TCP timeout (shown only for TCP) */} - {watchedMode === "tcp" && ( - ( - - {t("timeoutSeconds")} - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcTimeout", - value, - field.onChange - ); - }} - /> - - - - )} - /> - )} - - {/* Healthy interval + healthy threshold */} -
- ( - - - {t("healthyIntervalSeconds")} - - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcInterval", - value, - field.onChange - ); - }} - /> - - - - )} - /> - ( - - - {t("healthyThreshold")} - - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcHealthyThreshold", - value, - field.onChange - ); - }} - /> - - - - )} - /> -
- - {/* Unhealthy interval + unhealthy threshold */} -
- ( - - - {t("unhealthyIntervalSeconds")} - - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcUnhealthyInterval", - value, - field.onChange - ); - }} - /> - - - - )} - /> - ( - - - {t("unhealthyThreshold")} - - - { - const value = parseInt( - e.target.value - ); - handleChange( - "hcUnhealthyThreshold", - value, - field.onChange - ); - }} - /> - - - - )} - /> -
- - {/* HTTP-only fields */} - {watchedMode !== "tcp" && ( + {/* Connection fields + all remaining config — hidden for SNMP / ICMP */} + {watchedMode !== "snmp" && watchedMode !== "icmp" && ( <> - {/* Expected Response Codes + TLS Server Name + Follow Redirects */} -
+ {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + + {t("healthHostname")} + + + + handleChange( + "hcHostname", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + + handleChange( + "hcHostname", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ )} + + {/* HTTP Method + Path + Timeout (shown when not TCP) */} + {watchedMode !== "tcp" && ( +
+ ( + + + {t("httpMethod")} + + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + + handleChange( + "hcPath", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("timeoutSeconds")} + + + { + const value = + parseInt( + e.target + .value + ); + handleChange( + "hcTimeout", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ )} + + {/* TCP timeout (shown only for TCP) */} + {watchedMode === "tcp" && ( ( - {t("expectedResponseCodes")} + {t("timeoutSeconds")} { - const val = - e.target.value; - const value = val - ? parseInt(val) - : null; + const value = parseInt( + e.target.value + ); handleChange( - "hcStatus", + "hcTimeout", + value, + field.onChange + ); + }} + /> + + + + )} + /> + )} + + {/* Healthy interval + healthy threshold */} +
+ ( + + + {t("healthyIntervalSeconds")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcInterval", value, field.onChange ); @@ -680,25 +537,26 @@ export function HealthCheckFormFields({ /> ( - {t("tlsServerName")} + {t("healthyThreshold")} + onChange={(e) => { + const value = parseInt( + e.target.value + ); handleChange( - "hcTlsServerName", - e.target.value, - (v) => - field.onChange( - e - ) - ) - } + "hcHealthyThreshold", + value, + field.onChange + ); + }} /> @@ -707,60 +565,200 @@ export function HealthCheckFormFields({ />
- {/* Follow Redirects inline toggle */} - ( - - - {t("followRedirects")} - - - - handleChange( - "hcFollowRedirects", - value, - field.onChange - ) - } - /> - - - )} - /> + {/* Unhealthy interval + unhealthy threshold */} +
+ ( + + + {t("unhealthyIntervalSeconds")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcUnhealthyInterval", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t("unhealthyThreshold")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcUnhealthyThreshold", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
- {/* Custom Headers */} - ( - - - {t("customHeaders")} - - - - handleChange( - "hcHeaders", - value, - field.onChange - ) - } - rows={4} - /> - - - {t("customHeadersDescription")} - - - - )} - /> + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Codes + TLS Server Name */} +
+ ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e.target + .value; + const value = + val + ? parseInt( + val + ) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t("tlsServerName")} + + + + handleChange( + "hcTlsServerName", + e.target + .value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> +
+ + {/* Follow Redirects inline toggle */} + ( + + + {t("followRedirects")} + + + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} )}
diff --git a/src/components/S3DestinationCredenza.tsx b/src/components/S3DestinationCredenza.tsx new file mode 100644 index 000000000..d94293cf0 --- /dev/null +++ b/src/components/S3DestinationCredenza.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Plus, X, KeyRound, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export interface S3DestinationCredenzaProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: any; + orgId: string; + onSaved: () => void; +} + +export function S3DestinationCredenza({ + open, + onOpenChange, + editing, + orgId, + onSaved, +}: S3DestinationCredenzaProps) { + const t = useTranslations(); + + return ( + + + + + {editing + ? t("S3DestEditTitle") + : t("S3DestAddTitle")} + + + {editing + ? t("S3DestEditDescription") + : t("S3DestAddDescription")} + + + + +
+
+
+ + + Contact sales to enable this feature.{" "} + + Book a demo + + + {" or "} + + contact us + + + . + +
+
+
+
+ + + + + + +
+
+ ); +} From 0872fd58182449eb65545935ab80e65e921b08af Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 15:38:38 -0700 Subject: [PATCH 084/176] Make the healch checks tabs --- messages/en-US.json | 6 +- src/components/HealthCheckCredenza.tsx | 926 ++++++++++++++++++++++++- 2 files changed, 916 insertions(+), 16 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f3fd12900..633f84484 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3043,5 +3043,9 @@ "healthCheckStrategyHttp": "Validates connectivity and checks the HTTP response status.", "healthCheckStrategyTcp": "Verifies TCP connectivity only, without inspecting the response.", "healthCheckStrategySnmp": "Makes an SNMP get request to check the health of network devices and infrastructure.", - "healthCheckStrategyIcmp": "Uses ICMP echo requests (pings) to check if a resource is reachable and responsive." + "healthCheckStrategyIcmp": "Uses ICMP echo requests (pings) to check if a resource is reachable and responsive.", + "healthCheckTabStrategy": "Strategy", + "healthCheckTabConnection": "Connection", + "healthCheckTabAdvanced": "Advanced", + "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature." } diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index f575784f4..ac109710e 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -5,8 +5,27 @@ import { Button } from "@/components/ui/button"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Form } from "@/components/ui/form"; -import { HealthCheckFormFields } from "@app/components/HealthCheckFormFields"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { Credenza, CredenzaBody, @@ -21,6 +40,8 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import { ExternalLink, KeyRound } from "lucide-react"; +import Link from "next/link"; export type HealthCheckConfig = { hcEnabled: boolean; @@ -294,6 +315,17 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { } }; + const handleChange = ( + fieldName: string, + value: any, + fieldOnChange: (v: any) => void + ) => { + fieldOnChange(value); + if (mode === "autoSave") { + handleFieldChange(fieldName, value); + } + }; + const onSubmit = async (values: FormValues) => { if (mode !== "submit") return; const { initialValues, onSaved } = props; @@ -362,6 +394,10 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { }) : t("standaloneHcDescription"); + const showFields = mode === "submit" || watchedEnabled; + const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp"; + const isTcp = watchedMode === "tcp"; + return ( @@ -378,20 +414,880 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { ? form.handleSubmit(onSubmit) : undefined } - className="space-y-6" > - + + {/* ── Strategy tab ──────────────────────── */} +
+ {/* Name (submit mode only) */} + {mode === "submit" && ( + ( + + + {t( + "standaloneHcNameLabel" + )} + + + + + + + )} + /> + )} + + {/* Enable toggle (autoSave mode only) */} + {mode === "autoSave" && ( + ( + +
+ + {t( + "enableHealthChecks" + )} + +
+ + + handleChange( + "hcEnabled", + value, + field.onChange + ) + } + /> + +
+ )} + /> + )} + + {/* Strategy picker */} + {showFields && ( + ( + + + + handleChange( + "hcMode", + value, + field.onChange + ) + } + /> + + + + )} + /> + )} + + {/* Contact-sales banner for SNMP / ICMP */} + {showFields && isSnmpOrIcmp && ( +
+
+
+ + + Contact sales to enable + this feature.{" "} + + Book a demo + + + {" or "} + + contact us + + + . + +
+
+
+ )} +
+ + {/* ── Connection tab ────────────────────── */} +
+ {!showFields && ( +

+ {t("enableHealthChecks")} +

+ )} + + {showFields && !isSnmpOrIcmp && ( + <> + {/* Scheme / Hostname / Port */} + {isTcp ? ( +
+ ( + + + {t( + "healthHostname" + )} + + + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t( + "healthScheme" + )} + + + + + )} + /> + ( + + + {t( + "healthHostname" + )} + + + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Method / Path / Timeout (HTTP) */} + {!isTcp && ( +
+ ( + + + {t( + "httpMethod" + )} + + + + + )} + /> + ( + + + {t( + "healthCheckPath" + )} + + + + handleChange( + "hcPath", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "timeoutSeconds" + )} + + + + handleChange( + "hcTimeout", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Timeout for TCP */} + {isTcp && ( + ( + + + {t( + "timeoutSeconds" + )} + + + + handleChange( + "hcTimeout", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + )} + + )} + + {showFields && isSnmpOrIcmp && ( +

+ {t("healthCheckStrategyNotAvailable")} +

+ )} +
+ + {/* ── Advanced tab ──────────────────────── */} +
+ {!showFields && ( +

+ {t("enableHealthChecks")} +

+ )} + + {showFields && !isSnmpOrIcmp && ( + <> + {/* Healthy interval + threshold */} +
+ ( + + + {t( + "healthyIntervalSeconds" + )} + + + + handleChange( + "hcInterval", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthyThreshold" + )} + + + + handleChange( + "hcHealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ + {/* Unhealthy interval + threshold */} +
+ ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + + handleChange( + "hcUnhealthyInterval", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + ( + + + {t( + "unhealthyThreshold" + )} + + + + handleChange( + "hcUnhealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ + {/* HTTP-only advanced fields */} + {!isTcp && ( + <> + {/* Expected status + TLS server name */} +
+ ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e + .target + .value; + const value = + val + ? parseInt( + val + ) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t( + "tlsServerName" + )} + + + + handleChange( + "hcTlsServerName", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> +
+ + {/* Follow redirects */} + ( + + + {t( + "followRedirects" + )} + + + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + + + )} + /> + + {/* Custom headers */} + ( + + + {t( + "customHeaders" + )} + + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} + + )} + + {showFields && isSnmpOrIcmp && ( +

+ {t("healthCheckStrategyNotAvailable")} +

+ )} +
+
From 74165aa1cc1e859630b1d08002bb9994c1affb00 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 17:01:55 -0700 Subject: [PATCH 085/176] Cleaning up ui --- messages/en-US.json | 4 +- src/components/ContactSalesBanner.tsx | 39 + src/components/DatadogDestinationCredenza.tsx | 34 +- src/components/HealthCheckCredenza.tsx | 1619 +++++++++-------- src/components/HealthChecksTable.tsx | 2 +- src/components/S3DestinationCredenza.tsx | 36 +- 6 files changed, 863 insertions(+), 871 deletions(-) create mode 100644 src/components/ContactSalesBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 633f84484..89ef232c5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1848,8 +1848,8 @@ "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", - "customHeaders": "Custom Headers", - "customHeadersDescription": "Headers new line separated: Header-Name: value", + "customHeaders": "Custom Request Headers", + "customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value", "headersValidationError": "Headers must be in the format: Header-Name: value", "saveHealthCheck": "Save Health Check", "healthCheckSaved": "Health Check Saved", diff --git a/src/components/ContactSalesBanner.tsx b/src/components/ContactSalesBanner.tsx new file mode 100644 index 000000000..fedd5e49a --- /dev/null +++ b/src/components/ContactSalesBanner.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { KeyRound, ExternalLink } from "lucide-react"; +import Link from "next/link"; + +export function ContactSalesBanner() { + return ( +
+
+
+ + + Contact sales to enable this feature.{" "} + + Book a demo + + + {" or "} + + contact us + + + . + +
+
+
+ ); +} diff --git a/src/components/DatadogDestinationCredenza.tsx b/src/components/DatadogDestinationCredenza.tsx index b2d74ae0a..c81323e50 100644 --- a/src/components/DatadogDestinationCredenza.tsx +++ b/src/components/DatadogDestinationCredenza.tsx @@ -12,8 +12,7 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { Plus, X, KeyRound, ExternalLink } from "lucide-react"; -import Link from "next/link"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; import { useTranslations } from "next-intl"; export interface DatadogDestinationCredenzaProps { @@ -50,36 +49,7 @@ export function DatadogDestinationCredenza({ -
-
-
- - - Contact sales to enable this feature.{" "} - - Book a demo - - - {" or "} - - contact us - - - . - -
-
-
+
diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index ac109710e..f29fccccd 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -40,8 +40,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; -import { ExternalLink, KeyRound } from "lucide-react"; -import Link from "next/link"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; export type HealthCheckConfig = { hcEnabled: boolean; @@ -273,12 +272,10 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { hcUnhealthyInterval: initialValues.hcUnhealthyInterval ?? 30, hcTimeout: initialValues.hcTimeout ?? 5, - hcHealthyThreshold: - initialValues.hcHealthyThreshold ?? 1, + hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1, hcUnhealthyThreshold: initialValues.hcUnhealthyThreshold ?? 1, - hcFollowRedirects: - initialValues.hcFollowRedirects ?? true, + hcFollowRedirects: initialValues.hcFollowRedirects ?? true, hcTlsServerName: initialValues.hcTlsServerName ?? "", hcStatus: initialValues.hcStatus ?? null, hcHeaders: parsedHeaders @@ -415,477 +412,527 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { : undefined } > - - {/* ── Strategy tab ──────────────────────── */} -
- {/* Name (submit mode only) */} - {mode === "submit" && ( - ( - - - {t( - "standaloneHcNameLabel" - )} - - - - - - - )} - /> + {/* Name (submit mode only) */} + {mode === "submit" && ( + ( + + + {t("standaloneHcNameLabel")} + + + + + + )} + /> + )} - {/* Enable toggle (autoSave mode only) */} - {mode === "autoSave" && ( - ( - -
- - {t( - "enableHealthChecks" - )} - -
- - - handleChange( - "hcEnabled", - value, - field.onChange - ) - } - /> - -
- )} - /> - )} +
+ ( - - - + {/* ── Strategy tab ──────────────────────── */} +
+ {/* Enable toggle (autoSave mode only) */} + {mode === "autoSave" && ( + ( + +
+ + {t( + "enableHealthChecks" + )} + +
+ + - handleChange( - "hcMode", - value, - field.onChange - ) + onCheckedChange={( + value + ) => + handleChange( + "hcEnabled", + value, + field.onChange + ) + } + /> + +
+ )} + /> + )} + + {/* Strategy picker */} + {showFields && ( + ( + + + + handleChange( + "hcMode", + value, + field.onChange + ) + } + /> + + + + )} + /> + )} +
+ + {/* ── Connection tab ────────────────────── */} +
+ {!showFields && ( +

+ {t("enableHealthChecks")} +

+ )} + + {/* Contact-sales banner for SNMP / ICMP */} + {showFields && isSnmpOrIcmp && ( + + )} + + {showFields && !isSnmpOrIcmp && ( + <> + {/* Scheme / Hostname / Port */} + {isTcp ? ( +
+ ( + + + {t( + "healthHostname" + )} + + + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} /> - - - - )} - /> - )} - - {/* Contact-sales banner for SNMP / ICMP */} - {showFields && isSnmpOrIcmp && ( -
-
-
- - - Contact sales to enable - this feature.{" "} - - Book a demo - - - {" or "} - - contact us - - - . - -
-
-
- )} -
- - {/* ── Connection tab ────────────────────── */} -
- {!showFields && ( -

- {t("enableHealthChecks")} -

- )} - - {showFields && !isSnmpOrIcmp && ( - <> - {/* Scheme / Hostname / Port */} - {isTcp ? ( -
- ( - - - {t( - "healthHostname" - )} - - - ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t( + "healthScheme" + )} + + - handleChange( - "hcPort", - e - .target - .value, + "hcScheme", + value, field.onChange ) } - /> - - - - )} - /> -
- ) : ( -
- ( - - - {t( - "healthScheme" - )} - - + + + )} + /> + ( + + + {t( + "healthHostname" + )} + - - - + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> - - - HTTP - - - HTTPS - - - - - - )} - /> - ( - - - {t( - "healthHostname" - )} - - - + + )} + /> + ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Method / Path / Timeout (HTTP) */} + {!isTcp && ( +
+ ( + + + {t( + "httpMethod" + )} + + - handleChange( - "hcPort", - e - .target - .value, + "hcMethod", + value, field.onChange ) } - /> - - - - )} - /> -
- )} - - {/* Method / Path / Timeout (HTTP) */} - {!isTcp && ( -
- ( - - - {t( - "httpMethod" - )} - - - - - )} - /> - ( - - - {t( - "healthCheckPath" - )} - - - - handleChange( - "hcPath", - e - .target - .value, - () => - field.onChange( - e - ) - ) + value={ + field.value } - /> - - - - )} - /> + > + + + + + + + + GET + + + POST + + + HEAD + + + PUT + + + DELETE + + + + + + )} + /> + ( + + + {t( + "healthCheckPath" + )} + + + + handleChange( + "hcPath", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "timeoutSeconds" + )} + + + + handleChange( + "hcTimeout", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Timeout for TCP */} + {isTcp && ( )} /> -
- )} + )} + + )} +
- {/* Timeout for TCP */} - {isTcp && ( - ( - - - {t( - "timeoutSeconds" - )} - - - - handleChange( - "hcTimeout", - parseInt( - e - .target - .value - ), - field.onChange - ) - } - /> - - - - )} - /> - )} - - )} + {/* ── Advanced tab ──────────────────────── */} +
+ {!showFields && ( +

+ {t("enableHealthChecks")} +

+ )} - {showFields && isSnmpOrIcmp && ( -

- {t("healthCheckStrategyNotAvailable")} -

- )} -
+ {/* Contact-sales banner for SNMP / ICMP */} + {showFields && isSnmpOrIcmp && ( + + )} - {/* ── Advanced tab ──────────────────────── */} -
- {!showFields && ( -

- {t("enableHealthChecks")} -

- )} - - {showFields && !isSnmpOrIcmp && ( - <> - {/* Healthy interval + threshold */} -
- ( - - - {t( - "healthyIntervalSeconds" - )} - - - - handleChange( - "hcInterval", - parseInt( - e - .target - .value - ), - field.onChange - ) - } - /> - - - - )} - /> - ( - - - {t( - "healthyThreshold" - )} - - - - handleChange( - "hcHealthyThreshold", - parseInt( - e - .target - .value - ), - field.onChange - ) - } - /> - - - - )} - /> -
- - {/* Unhealthy interval + threshold */} -
- ( - - - {t( - "unhealthyIntervalSeconds" - )} - - - - handleChange( - "hcUnhealthyInterval", - parseInt( - e - .target - .value - ), - field.onChange - ) - } - /> - - - - )} - /> - ( - - - {t( - "unhealthyThreshold" - )} - - - - handleChange( - "hcUnhealthyThreshold", - parseInt( - e - .target - .value - ), - field.onChange - ) - } - /> - - - - )} - /> -
- - {/* HTTP-only advanced fields */} - {!isTcp && ( - <> - {/* Expected status + TLS server name */} -
- ( - - - {t( - "expectedResponseCodes" - )} - - - { - const val = - e - .target - .value; - const value = - val - ? parseInt( - val - ) - : null; - handleChange( - "hcStatus", - value, - field.onChange - ); - }} - /> - - - - )} - /> - ( - - - {t( - "tlsServerName" - )} - - - - handleChange( - "hcTlsServerName", - e - .target - .value, - () => - field.onChange( - e - ) - ) - } - /> - - - - )} - /> -
- - {/* Follow redirects */} + {showFields && !isSnmpOrIcmp && ( + <> + {/* Healthy interval + threshold */} +
( - - - {t( - "followRedirects" - )} - - - - handleChange( - "hcFollowRedirects", - value, - field.onChange - ) - } - /> - - - )} - /> - - {/* Custom headers */} - ( {t( - "customHeaders" + "healthyIntervalSeconds" )} - handleChange( - "hcHeaders", - value, + "hcInterval", + parseInt( + e + .target + .value + ), field.onChange ) } - rows={4} /> - - {t( - "customHeadersDescription" - )} - )} /> - - )} - - )} + ( + + + {t( + "healthyThreshold" + )} + + + + handleChange( + "hcHealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
- {showFields && isSnmpOrIcmp && ( -

- {t("healthCheckStrategyNotAvailable")} -

- )} -
-
+ {/* Unhealthy interval + threshold */} +
+ ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + + handleChange( + "hcUnhealthyInterval", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + ( + + + {t( + "unhealthyThreshold" + )} + + + + handleChange( + "hcUnhealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ + {/* HTTP-only advanced fields */} + {!isTcp && ( + <> + {/* Expected status + TLS server name */} +
+ ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e + .target + .value; + const value = + val + ? parseInt( + val + ) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t( + "tlsServerName" + )} + + + + handleChange( + "hcTlsServerName", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> +
+ + {/* Follow redirects */} + ( + + + {t( + "followRedirects" + )} + + + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + + + )} + /> + + {/* Custom headers */} + ( + + + {t( + "customHeaders" + )} + + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={ + 4 + } + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} + + )} +
+ +
@@ -1318,4 +1331,4 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { ); } -export default HealthCheckCredenza; \ No newline at end of file +export default HealthCheckCredenza; diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 9f1297fef..a5d35b2e0 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -150,7 +150,7 @@ export default function HealthChecksTable({ ), cell: ({ row }) => ( - {row.original.name} + {row.original.name ? row.original.name : "-"} ) }, { diff --git a/src/components/S3DestinationCredenza.tsx b/src/components/S3DestinationCredenza.tsx index d94293cf0..7702e7932 100644 --- a/src/components/S3DestinationCredenza.tsx +++ b/src/components/S3DestinationCredenza.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; + import { Credenza, CredenzaBody, @@ -12,8 +12,7 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { Plus, X, KeyRound, ExternalLink } from "lucide-react"; -import Link from "next/link"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; import { useTranslations } from "next-intl"; export interface S3DestinationCredenzaProps { @@ -50,36 +49,7 @@ export function S3DestinationCredenza({ -
-
-
- - - Contact sales to enable this feature.{" "} - - Book a demo - - - {" or "} - - contact us - - - . - -
-
-
+
From 8214700eaa6660a7bda163a66da71d40af4c8893 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 17:18:15 -0700 Subject: [PATCH 086/176] More refreshing and status history displays --- messages/en-US.json | 7 +- .../lib/alerts/events/resourceEvents.ts | 91 +++++++++++++++ server/routers/external.ts | 7 ++ server/routers/resource/getStatusHistory.ts | 93 +++++++++++++++ server/routers/resource/index.ts | 1 + src/components/ContactSalesBanner.tsx | 13 ++- src/components/HealthChecksTable.tsx | 2 +- src/components/ProxyResourcesTable.tsx | 106 ++++++++++++++++++ src/components/SitesTable.tsx | 11 +- src/components/UptimeMiniBar.tsx | 6 +- 10 files changed, 326 insertions(+), 11 deletions(-) create mode 100644 server/private/lib/alerts/events/resourceEvents.ts create mode 100644 server/routers/resource/getStatusHistory.ts diff --git a/messages/en-US.json b/messages/en-US.json index 89ef232c5..5bb1af511 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contact sales to enable this feature.", + "contactSalesBookDemo": "Book a demo", + "contactSalesOr": "or", + "contactSalesContactUs": "contact us", "setupCreate": "Create the organization, site, and resources", "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", "headerAuthCompatibility": "Extended compatibility", @@ -3047,5 +3051,6 @@ "healthCheckTabStrategy": "Strategy", "healthCheckTabConnection": "Connection", "healthCheckTabAdvanced": "Advanced", - "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature." + "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature.", + "uptime30d": "Uptime (30d)" } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts new file mode 100644 index 000000000..9ede25fe6 --- /dev/null +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -0,0 +1,91 @@ +/* + * 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 logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +/** + * Fire a `health_check_not_healthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckNotHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_not_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 34bbe4f88..a17c88fb1 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,6 +427,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/resource/:resourceId/status-history", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts new file mode 100644 index 000000000..9aa548624 --- /dev/null +++ b/server/routers/resource/getStatusHistory.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + computeBuckets, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const resourceParamsSchema = z.object({ + resourceId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = resourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const entityId = parsedParams.data.resourceId; + const { days } = parsedQuery.data; + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + return response(res, { + data: { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 12e98a70d..6a259d7fe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,3 +32,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./getStatusHistory"; diff --git a/src/components/ContactSalesBanner.tsx b/src/components/ContactSalesBanner.tsx index fedd5e49a..e5cb87d83 100644 --- a/src/components/ContactSalesBanner.tsx +++ b/src/components/ContactSalesBanner.tsx @@ -2,32 +2,35 @@ import { KeyRound, ExternalLink } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export function ContactSalesBanner() { + const t = useTranslations(); + return (
- Contact sales to enable this feature.{" "} + {t("contactSalesEnable")}{" "} - Book a demo + {t("contactSalesBookDemo")} - {" or "} + {" " + t("contactSalesOr") + " "} - contact us + {t("contactSalesContactUs")} . @@ -36,4 +39,4 @@ export function ContactSalesBanner() {
); -} +} \ No newline at end of file diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index a5d35b2e0..a09af2da1 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -229,7 +229,7 @@ export default function HealthChecksTable({ { id: "uptime", friendlyName: "Uptime", - header: () => Uptime (30d), + header: () => {t("uptime30d")}, cell: ({ row }) => { return ( diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index fbb544ddf..2990445b0 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -19,6 +19,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; +import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; import { ArrowDown01Icon, @@ -37,6 +38,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { + useEffect, useOptimistic, useRef, useState, @@ -47,6 +49,13 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import type { StatusHistoryResponse } from "@server/lib/statusHistory"; export type TargetHealth = { targetId: number; @@ -161,6 +170,13 @@ export default function ProxyResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 10_000); + return () => clearInterval(interval); + }, []); + const refreshData = () => { startTransition(() => { try { @@ -322,6 +338,82 @@ export default function ProxyResourcesTable({ ); } + function ResourceStatusHistory({ + resourceId, + api + }: { + resourceId: number; + api: ReturnType; + }) { + const { data: history, isLoading: loading } = useQuery({ + queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, 30], + queryFn: async ({ signal }) => { + const res = await api.get( + `/resource/${resourceId}/status-history`, + { + params: { days: 30 }, + signal + } + ); + return (res.data.data ?? res.data) as StatusHistoryResponse; + }, + staleTime: 5 * 60 * 1000, + meta: { api } + }); + + if (loading) { + return ( +
+ {Array.from({ length: 90 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (!history) return null; + + return ( +
+ +
+ {history.days.map((bucket, i) => { + const colorClass = + bucket.status === "good" + ? "bg-green-500" + : bucket.status === "degraded" + ? "bg-yellow-500" + : bucket.status === "bad" + ? "bg-red-500" + : "bg-muted"; + return ( + + +
+ + + + {bucket.date}:{" "} + {bucket.uptimePercent}% uptime + + + + ); + })} +
+ + + {history.overallUptimePercent.toFixed(1)}% uptime + +
+ ); + } + const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -422,6 +514,20 @@ export default function ProxyResourcesTable({ return statusOrder[statusA] - statusOrder[statusB]; } }, + { + id: "statusHistory", + friendlyName: t("statusHistory"), + header: () => {t("statusHistory")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + ); + } + }, { accessorKey: "domain", friendlyName: t("access"), diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 68fbc0cac..ffec95283 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -30,7 +30,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useEffect } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -85,6 +85,13 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 10_000); + return () => clearInterval(interval); + }, []); + const booleanSearchFilterSchema = z .enum(["true", "false"]) .optional() @@ -226,7 +233,7 @@ export default function SitesTable({ { id: "uptime", friendlyName: "Uptime", - header: () => Uptime (30d), + header: () => {t("uptime30d")}, cell: ({ row }) => { const originalRow = row.original; return ( diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index f668ac023..5685b9ca5 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -54,13 +54,15 @@ export default function UptimeMiniBar({ const siteQuery = useQuery({ ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), enabled: siteId != null, - meta: { api } + meta: { api }, + staleTime: 5 * 60 * 1000 }); const hcQuery = useQuery({ ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), enabled: healthCheckId != null && siteId == null, - meta: { api } + meta: { api }, + staleTime: 5 * 60 * 1000 }); const { data, isLoading } = siteId != null ? siteQuery : hcQuery; From df8104fe56f932cefd21efb088163fb8f276beb4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 17:25:51 -0700 Subject: [PATCH 087/176] Write the resource status as well --- .../target/handleHealthcheckStatusMessage.ts | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index cc290c131..a049e3224 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -1,4 +1,11 @@ -import { db, targets, resources, sites, targetHealthCheck, statusHistory } from "@server/db"; +import { + db, + targets, + resources, + sites, + targetHealthCheck, + statusHistory +} from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -88,6 +95,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( orgId: targetHealthCheck.orgId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId, resourceOrgId: resources.orgId, + resourceId: resources.resourceId, name: targetHealthCheck.name, hcStatus: targetHealthCheck.hcHealth }) @@ -134,8 +142,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( | "healthy" | "unhealthy" }) - .where(eq(targetHealthCheck.targetId, targetIdNum)) - .execute(); + .where(eq(targetHealthCheck.targetId, targetIdNum)); // Log the state change to status history await db.insert(statusHistory).values({ @@ -143,8 +150,49 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( entityId: targetCheck.targetHealthCheckId, orgId: targetCheck.orgId || targetCheck.resourceOrgId, status: healthStatus.status, - timestamp: Math.floor(Date.now() / 1000), - }).execute(); + timestamp: Math.floor(Date.now() / 1000) + }); + + if (targetCheck.resourceId) { + // Log the state change to status history for the resource as well + // so we can show the resource status along with the site + + // if the status is healthy we should check if ALL of the targets on the resource are currently healthy and if not then dont mark the resource as healthy yet, we want to wait until all targets are healthy to mark the resource as healthy + let status = healthStatus.status; + if (healthStatus.status === "healthy") { + const otherTargets = await db + .select({ hcHealth: targetHealthCheck.hcHealth }) + .from(targets) + .innerJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where( + and( + eq(targets.resourceId, targetCheck.resourceId), + eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated + ) + ); + + const allHealthy = otherTargets.every( + (t) => t.hcHealth === "healthy" + ); + if (!allHealthy) { + logger.debug( + `Not marking resource ${targetCheck.resourceId} as healthy because not all targets are healthy` + ); + status = "unhealthy"; + } + } + + await db.insert(statusHistory).values({ + entityType: "resource", + entityId: targetCheck.resourceId, + orgId: targetCheck.orgId || targetCheck.resourceOrgId, + status: status, + timestamp: Math.floor(Date.now() / 1000) + }); + } // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { From a5b8a44e78e59695652405f98686b5c792c64942 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 17:40:00 -0700 Subject: [PATCH 088/176] Add the status to the resources and ajust location --- .../resources/proxy/[niceId]/general/page.tsx | 14 ++++++++++ .../settings/sites/[niceId]/general/page.tsx | 26 +++++++++---------- src/components/ProxyResourcesTable.tsx | 2 +- src/components/UptimeBar.tsx | 15 +++++++++-- src/lib/queries.ts | 17 ++++++++++++ 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 9589f6a2e..5f47e1938 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -62,6 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import UptimeBar from "@app/components/UptimeBar"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -578,6 +579,19 @@ export default function GeneralForm() { return ( <> + + + Uptime + + Site availability over the last 90 days. + + + + {resource?.resourceId && ( + + )} + + diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 93114c5b2..3527e41cb 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -113,6 +113,19 @@ export default function GeneralPage() { return ( + + + Uptime + + Site availability over the last 90 days. + + + + {site?.siteId && ( + + )} + + @@ -225,19 +238,6 @@ export default function GeneralPage() { - - - Uptime - - Site availability over the last 90 days. - - - - {site?.siteId && ( - - )} - - ); } diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 2990445b0..119ba9cab 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -516,7 +516,7 @@ export default function ProxyResourcesTable({ }, { id: "statusHistory", - friendlyName: t("statusHistory"), + friendlyName: t("uptime30d"), header: () => {t("statusHistory")}, cell: ({ row }) => { const resourceRow = row.original; diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index d2f29b760..636d536e9 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -47,6 +47,7 @@ const barColorClass: Record = { type UptimeBarProps = { orgId?: string; siteId?: number; + resourceId?: number; healthCheckId?: number; days?: number; title?: string; @@ -56,6 +57,7 @@ type UptimeBarProps = { export default function UptimeBar({ orgId, siteId, + resourceId, healthCheckId, days = 90, title, @@ -71,11 +73,20 @@ export default function UptimeBar({ const hcQuery = useQuery({ ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), - enabled: healthCheckId != null && siteId == null, + enabled: healthCheckId != null && siteId == null && resourceId == null, meta: { api } }); - const { data, isLoading } = siteId != null ? siteQuery : hcQuery; + const resourceQuery = useQuery({ + ...orgQueries.resourceStatusHistory({ resourceId, days }), + enabled: resourceId != null && siteId == null && healthCheckId == null, + meta: { api } + }); + + const { data, isLoading } = + siteId != null ? siteQuery : + resourceId != null ? resourceQuery : + hcQuery; if (isLoading) { return ( diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7380bfd66..c4b0a4bce 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -323,6 +323,23 @@ export const orgQueries = { } }), + resourceStatusHistory: ({ + resourceId, + days = 90 + }: { + resourceId?: number; + days?: number; + }) => + queryOptions({ + queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/status-history?days=${days}`, { signal }); + return res.data.data; + } + }), + healthCheckStatusHistory: ({ orgId, healthCheckId, From b2d5a1ffdfd27617f143c0cf02c10202e243a28b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Apr 2026 17:45:49 -0700 Subject: [PATCH 089/176] Remove weird extra status history component --- src/components/ProxyResourcesTable.tsx | 84 +------------------------- src/components/UptimeMiniBar.tsx | 16 ++++- 2 files changed, 17 insertions(+), 83 deletions(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 119ba9cab..cb56174f2 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -56,6 +56,7 @@ import { TooltipTrigger } from "@app/components/ui/tooltip"; import type { StatusHistoryResponse } from "@server/lib/statusHistory"; +import UptimeMiniBar from "./UptimeMiniBar"; export type TargetHealth = { targetId: number; @@ -338,82 +339,6 @@ export default function ProxyResourcesTable({ ); } - function ResourceStatusHistory({ - resourceId, - api - }: { - resourceId: number; - api: ReturnType; - }) { - const { data: history, isLoading: loading } = useQuery({ - queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, 30], - queryFn: async ({ signal }) => { - const res = await api.get( - `/resource/${resourceId}/status-history`, - { - params: { days: 30 }, - signal - } - ); - return (res.data.data ?? res.data) as StatusHistoryResponse; - }, - staleTime: 5 * 60 * 1000, - meta: { api } - }); - - if (loading) { - return ( -
- {Array.from({ length: 90 }).map((_, i) => ( -
- ))} -
- ); - } - - if (!history) return null; - - return ( -
- -
- {history.days.map((bucket, i) => { - const colorClass = - bucket.status === "good" - ? "bg-green-500" - : bucket.status === "degraded" - ? "bg-yellow-500" - : bucket.status === "bad" - ? "bg-red-500" - : "bg-muted"; - return ( - - -
- - - - {bucket.date}:{" "} - {bucket.uptimePercent}% uptime - - - - ); - })} -
- - - {history.overallUptimePercent.toFixed(1)}% uptime - -
- ); - } - const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -517,14 +442,11 @@ export default function ProxyResourcesTable({ { id: "statusHistory", friendlyName: t("uptime30d"), - header: () => {t("statusHistory")}, + header: () => {t("uptime30d")}, cell: ({ row }) => { const resourceRow = row.original; return ( - + ); } }, diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index 5685b9ca5..b9574054a 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -39,6 +39,7 @@ const barColorClass: Record = { type UptimeMiniBarProps = { orgId?: string; siteId?: number; + resourceId?: number; healthCheckId?: number; days?: number; }; @@ -46,6 +47,7 @@ type UptimeMiniBarProps = { export default function UptimeMiniBar({ orgId, siteId, + resourceId, healthCheckId, days = 30 }: UptimeMiniBarProps) { @@ -60,12 +62,22 @@ export default function UptimeMiniBar({ const hcQuery = useQuery({ ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), - enabled: healthCheckId != null && siteId == null, + enabled: healthCheckId != null && siteId == null && resourceId == null, meta: { api }, staleTime: 5 * 60 * 1000 }); - const { data, isLoading } = siteId != null ? siteQuery : hcQuery; + const resourceQuery = useQuery({ + ...orgQueries.resourceStatusHistory({ resourceId, days }), + enabled: resourceId != null && siteId == null && healthCheckId == null, + meta: { api }, + staleTime: 5 * 60 * 1000 + }); + + const { data, isLoading } = + siteId != null ? siteQuery : + resourceId != null ? resourceQuery : + hcQuery; if (isLoading) { return ( From 55989c2019f27d9ebcc8bb157fe92b83d479dc9d Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Apr 2026 13:40:50 -0700 Subject: [PATCH 090/176] Add trial system --- messages/en-US.json | 8 + server/auth/actions.ts | 1 + server/db/pg/schema/privateSchema.ts | 2 + server/db/sqlite/schema/privateSchema.ts | 2 + .../emails/templates/NotifyTrialExpiring.tsx | 127 ++++++++++ .../billing/hooks/handleCustomerCreated.ts | 34 ++- server/private/routers/integration.ts | 8 + server/private/routers/org/index.ts | 1 + .../routers/org/sendTrialNotification.ts | 224 ++++++++++++++++++ server/routers/org/createOrg.ts | 5 +- src/app/[orgId]/layout.tsx | 2 + .../settings/(private)/billing/page.tsx | 39 ++- src/components/LayoutSidebar.tsx | 18 ++ src/components/PaidFeaturesAlert.tsx | 3 +- src/components/ShowTrialCard.tsx | 98 ++++++++ src/components/ui/progress-backwards.tsx | 58 +++++ src/contexts/subscriptionStatusContext.ts | 4 + src/providers/SubscriptionStatusProvider.tsx | 17 +- 18 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 server/emails/templates/NotifyTrialExpiring.tsx create mode 100644 server/private/routers/org/sendTrialNotification.ts create mode 100644 src/components/ShowTrialCard.tsx create mode 100644 src/components/ui/progress-backwards.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 5bb1af511..ee895be9f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -23,6 +23,14 @@ "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", + "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", + "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", + "trialActive": "Free Trial Active", + "trialExpired": "Trial Expired", + "trialHasEnded": "Your trial has ended.", + "trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}", + "trialDaysLeftShort": "{days}d left in trial", + "trialGoToBilling": "Go to billing page", "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index fd9c02e93..89ccd7e37 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -123,6 +123,7 @@ export enum ActionsEnum { deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", sendUsageNotification = "sendUsageNotification", + sendTrialNotification = "sendTrialNotification", createRemoteExitNode = "createRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode", getRemoteExitNode = "getRemoteExitNode", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 9007013b1..598127af8 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -90,6 +90,8 @@ export const subscriptions = pgTable("subscriptions", { updatedAt: bigint("updatedAt", { mode: "number" }), version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), + expiresAt: bigint("expiresAt", { mode: "number" }), + trial: boolean("trial").default(false), type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 318a094dd..e6c904485 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -84,6 +84,8 @@ export const subscriptions = sqliteTable("subscriptions", { createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), version: integer("version"), + expiresAt: integer("expiresAt"), + trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), type: text("type") // tier1, tier2, tier3, or license }); diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx new file mode 100644 index 000000000..5e8f0e6a8 --- /dev/null +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +interface Props { + email: string; + orgName: string; + trialEndsAt: string; + daysRemaining: number | null; + billingLink: string; +} + +export const NotifyTrialExpiring = ({ + email, + orgName, + trialEndsAt, + daysRemaining, + billingLink +}: Props) => { + const hasEnded = daysRemaining === null || daysRemaining === 0; + const isLastDay = daysRemaining === 1; + + const previewText = hasEnded + ? `Your trial for ${orgName} has ended.` + : isLastDay + ? `Your trial for ${orgName} ends tomorrow.` + : `Your trial for ${orgName} ends in ${daysRemaining} days.`; + + const heading = hasEnded + ? "Your Trial Has Ended" + : "Your Trial Is Ending Soon"; + + return ( + + + {previewText} + + + + + + {heading} + + Hi there, + + {hasEnded ? ( + <> + + Your free trial for{" "} + {orgName} ended on{" "} + {trialEndsAt}. Your account + has been moved to the free plan, which + includes limited functionality. + + + + Some features and resources may now be + restricted or disconnected. To restore full + access and continue using all the features + you had during your trial, please upgrade to + a paid plan. + + + + You can{" "} + + upgrade your plan here + {" "} + to get back up and running right away. + + + ) : ( + <> + + Just a reminder that your free trial for{" "} + {orgName} will end on{" "} + {trialEndsAt} + {isLastDay + ? " — that's tomorrow!" + : `, in ${daysRemaining} days`} + . + + + + After your trial ends, your account will be + moved to the free plan and some + functionality may be restricted or your + sites may disconnect. + + + + To avoid any interruption to your service, + we encourage you to upgrade before your + trial expires. You can{" "} + + upgrade your plan here + + . + + + )} + + + If you have any questions or need assistance, please + don't hesitate to reach out to our support team. + + + + + + + + + + ); +}; + +export default NotifyTrialExpiring; \ No newline at end of file diff --git a/server/private/routers/billing/hooks/handleCustomerCreated.ts b/server/private/routers/billing/hooks/handleCustomerCreated.ts index 11405f392..66ad3a4fa 100644 --- a/server/private/routers/billing/hooks/handleCustomerCreated.ts +++ b/server/private/routers/billing/hooks/handleCustomerCreated.ts @@ -12,9 +12,10 @@ */ import Stripe from "stripe"; -import { customers, db } from "@server/db"; +import { customers, db, subscriptions } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; export async function handleCustomerCreated( customer: Stripe.Customer @@ -38,14 +39,31 @@ export async function handleCustomerCreated( return; } - await db.insert(customers).values({ - customerId: customer.id, - orgId: customer.metadata.orgId, - email: customer.email || null, - name: customer.name || null, - createdAt: customer.created, - updatedAt: customer.created + await db.transaction(async (trx) => { + await trx.insert(customers).values({ + customerId: customer.id, + orgId: customer.metadata.orgId, + email: customer.email || null, + name: customer.name || null, + createdAt: customer.created, + updatedAt: customer.created + }); + + // Insert a 14-day trial subscription at tier3 + const now = Math.floor(Date.now() / 1000); + const trialExpiresAt = now + 10 * 24 * 60 * 60; + const subscriptionId = `trial-${generateId(15)}`; + await trx.insert(subscriptions).values({ + subscriptionId, + customerId: customer.id, + status: "active", + type: "tier3", + createdAt: now, + expiresAt: trialExpiresAt, + trial: true + }); }); + logger.info(`Customer with ID ${customer.id} created successfully.`); } catch (error) { logger.error( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 8c1ce4d46..0fa526bc0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -48,6 +48,14 @@ authenticated.post( org.sendUsageNotification ); +authenticated.post( + `/org/:orgId/send-trial-notification`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.sendTrialNotification), + logActionAudit(ActionsEnum.sendTrialNotification), + org.sendTrialNotification +); + authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, diff --git a/server/private/routers/org/index.ts b/server/private/routers/org/index.ts index 7a23be693..5dc0faed8 100644 --- a/server/private/routers/org/index.ts +++ b/server/private/routers/org/index.ts @@ -12,3 +12,4 @@ */ export * from "./sendUsageNotifications"; +export * from "./sendTrialNotification"; diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts new file mode 100644 index 000000000..c3b7f6518 --- /dev/null +++ b/server/private/routers/org/sendTrialNotification.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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; +import { eq, and, or } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring"; +import config from "@server/lib/config"; + +const sendTrialNotificationParamsSchema = z.object({ + orgId: z.string() +}); + +const sendTrialNotificationBodySchema = z.object({ + notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]), + orgName: z.string(), + trialEndsAt: z.number(), + billingLink: z.string().optional() +}); + +export type SendTrialNotificationResponse = { + success: boolean; + emailsSent: number; + adminEmails: string[]; +}; + +async function getOrgAdmins(orgId: string) { + const admins = await db + .select({ + userId: users.userId, + email: users.email, + name: users.name, + isOwner: userOrgs.isOwner, + roleName: roles.name, + isAdminRole: roles.isAdmin + }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgs.orgId, orgId), + or(eq(userOrgs.isOwner, true), eq(roles.isAdmin, true)) + ) + ); + + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( + (admin) => admin.email && admin.email.length > 0 + ); + + return orgAdmins; +} + +export async function sendTrialNotification( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = sendTrialNotificationParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = sendTrialNotificationBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } = + parsedBody.data; + + // Verify organization exists + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // Get all admin users for this organization + const orgAdmins = await getOrgAdmins(orgId); + + if (orgAdmins.length === 0) { + logger.warn(`No admin users found for organization ${orgId}`); + return response(res, { + data: { + success: true, + emailsSent: 0, + adminEmails: [] + }, + success: true, + error: false, + message: "No admin users found to notify", + status: HttpCode.OK + }); + } + + const billingLink = + bodyBillingLink ?? + `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; + + const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString( + "en-US", + { year: "numeric", month: "long", day: "numeric" } + ); + + let daysRemaining: number | null; + let subject: string; + + if (notificationType === "trial_ending_5d") { + daysRemaining = 5; + subject = "Your trial ends in 5 days"; + } else if (notificationType === "trial_ending_24h") { + daysRemaining = 1; + subject = "Your trial ends tomorrow"; + } else { + daysRemaining = null; + subject = "Your trial has ended"; + } + + let emailsSent = 0; + const adminEmails: string[] = []; + + for (const admin of orgAdmins) { + if (!admin.email) continue; + + try { + const template = NotifyTrialExpiring({ + email: admin.email, + orgName, + trialEndsAt: trialEndsAtFormatted, + daysRemaining, + billingLink + }); + + await sendEmail(template, { + to: admin.email, + from: config.getNoReplyEmail(), + subject + }); + + emailsSent++; + adminEmails.push(admin.email); + + logger.info( + `Trial notification sent to admin ${admin.email} for org ${orgId}` + ); + } catch (emailError) { + logger.error( + `Failed to send trial notification to ${admin.email}:`, + emailError + ); + // Continue with other admins even if one fails + } + } + + return response(res, { + data: { + success: true, + emailsSent, + adminEmails + }, + success: true, + error: false, + message: `Trial notifications sent to ${emailsSent} administrators`, + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error sending trial notifications:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to send trial notifications" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 88f76c29c..5fccbcd1f 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -12,7 +12,9 @@ import { userOrgRoles, userOrgs, users, - actions + actions, + customers, + subscriptions } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { doCidrsOverlap } from "@server/lib/ip"; import { generateCA } from "@server/lib/sshCA"; import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 8dc28001e..fe0077427 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -21,6 +21,7 @@ import { Layout } from "@app/components/Layout"; import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; import SubscriptionViolation from "@app/components/SubscriptionViolation"; + export default async function OrgLayout(props: { children: React.ReactNode; params: Promise<{ orgId: string }>; @@ -110,6 +111,7 @@ export default async function OrgLayout(props: { {props.children} {build === "saas" && } + ); diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 150725e32..8f714336a 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -219,6 +219,7 @@ export default function BillingPage() { ); const [hasSubscription, setHasSubscription] = useState(false); + const [isTrial, setIsTrial] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState(null); @@ -263,6 +264,7 @@ export default function BillingPage() { setHasSubscription( tierSub.subscription.status === "active" ); + setIsTrial(tierSub.subscription.expiresAt != null); } // Find license subscription @@ -558,7 +560,7 @@ export default function BillingPage() { // Get button label and action for each plan const getPlanAction = (plan: PlanOption) => { if (plan.id === "enterprise") { - if (plan.id === currentPlanId) { + if (plan.id === currentPlanId && !isTrial) { return { label: "Manage Current Plan", action: handleModifySubscription, @@ -597,6 +599,19 @@ export default function BillingPage() { disabled: false }; } + // If this is a trial subscription, show an upgrade button that starts a real checkout + if (isTrial) { + return { + label: "Upgrade", + action: () => { + if (plan.tierType) { + handleStartSubscription(plan.tierType); + } + }, + variant: "default" as const, + disabled: isProblematicState + }; + } return { label: "Manage Current Plan", action: handleModifySubscription, @@ -610,7 +625,8 @@ export default function BillingPage() { ); const planIndex = planOptions.findIndex((p) => p.id === plan.id); - if (planIndex < currentIndex) { + // During a trial, never show a downgrade option — all non-current plans are upgrades + if (!isTrial && planIndex < currentIndex) { return { label: "Downgrade", action: () => { @@ -642,18 +658,23 @@ export default function BillingPage() { label: "Upgrade", action: () => { if (plan.tierType) { - showTierConfirmation( - plan.tierType, - "upgrade", - plan.name, - plan.price + (" " + plan.priceDetail || "") - ); + // During a trial, go straight to checkout instead of the tier-change flow + if (isTrial) { + handleStartSubscription(plan.tierType); + } else { + showTierConfirmation( + plan.tierType, + "upgrade", + plan.name, + plan.price + (" " + plan.priceDetail || "") + ); + } } else { handleModifySubscription(); } }, variant: "outline" as const, - disabled: isProblematicState + disabled: isProblematicState || (isTrial && plan.id == "basic") }; }; diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 779d5eb74..7c3ade008 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -13,6 +13,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useUserContext } from "@app/hooks/useUserContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { cn } from "@app/lib/cn"; import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; @@ -31,6 +32,10 @@ const ProductUpdates = dynamic(() => import("./ProductUpdates"), { ssr: false }); +const ShowTrialCard = dynamic(() => import("./ShowTrialCard"), { + ssr: false +}); + interface LayoutSidebarProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; @@ -55,6 +60,7 @@ export function LayoutSidebar({ const { user } = useUserContext(); const { isUnlocked, licenseStatus } = useLicenseStatusContext(); const { env } = useEnvContext(); + const subscriptionContext = useSubscriptionStatusContext(); const t = useTranslations(); // Fetch pending approval count if we have an orgId and it's not an admin page @@ -122,6 +128,11 @@ export function LayoutSidebar({ const canShowProductUpdates = user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin); + const showTrial = + build === "saas" && + Boolean(orgId) && + subscriptionContext?.isTrial + return (
)} + {showTrial && ( +
+ +
+ )} + {build === "enterprise" && (
)} + {!isSidebarCollapsed && (
{loadFooterLinks() ? ( diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index 95179ea78..933a9f5b9 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -10,7 +10,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { Tier } from "@server/types/Tiers"; import { useParams } from "next/navigation"; -const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; +// const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; +const TIER_ORDER: Tier[] = ["tier2", "tier3", "enterprise"]; const TIER_TRANSLATION_KEYS: Record< Tier, diff --git a/src/components/ShowTrialCard.tsx b/src/components/ShowTrialCard.tsx new file mode 100644 index 000000000..1cc8e79f1 --- /dev/null +++ b/src/components/ShowTrialCard.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { ClockIcon, ArrowRight } from "lucide-react"; +import { ProgressBackwards } from "@app/components/ui/progress-backwards"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useTranslations } from "next-intl"; + +const TRIAL_DURATION_DAYS = 14; + +export default function ShowTrialCard({ + isCollapsed +}: { + isCollapsed?: boolean; +}) { + const context = useSubscriptionStatusContext(); + const params = useParams(); + const orgId = params?.orgId as string | undefined; + const t = useTranslations(); + + const trialExpiresAt = context?.trialExpiresAt ?? null; + + if (trialExpiresAt == null) return null; + + const now = Date.now(); + const remainingMs = trialExpiresAt - now; + const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24))); + const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000; + const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)); + // Inverted: full bar at start, drains to empty as trial ends + const displayPct = 100 - progressPct; + + const billingHref = orgId ? `/${orgId}/settings/billing` : "/"; + + if (isCollapsed) { + return ( + + + + + + + + +

+ {remainingDays === 0 + ? t("trialExpired") + : t("trialDaysLeftShort", { days: remainingDays })} +

+
+
+
+ ); + } + + return ( + +
+ +

+ {remainingDays === 0 + ? t("trialExpired") + : t("trialActive")} +

+
+
+ + + {remainingDays === 0 + ? t("trialHasEnded") + : t("trialDaysRemaining", { count: remainingDays })} + +
+ {t("trialGoToBilling")} + +
+
+ + ); +} diff --git a/src/components/ui/progress-backwards.tsx b/src/components/ui/progress-backwards.tsx new file mode 100644 index 000000000..e2482f0e2 --- /dev/null +++ b/src/components/ui/progress-backwards.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@app/lib/cn"; +import { cva, type VariantProps } from "class-variance-authority"; + +const progressVariants = cva( + "border relative h-2 w-full overflow-hidden rounded-full", + { + variants: { + variant: { + default: "bg-muted", + success: "bg-muted", + warning: "bg-muted", + danger: "bg-muted" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +const indicatorVariants = cva("h-full w-full flex-1 transition-all", { + variants: { + variant: { + default: "bg-primary", + success: "bg-green-500", + warning: "bg-yellow-500", + danger: "bg-red-500" + } + }, + defaultVariants: { + variant: "default" + } +}); + +type ProgressProps = React.ComponentProps & + VariantProps; + +function ProgressBackwards({ className, value, variant, ...props }: ProgressProps) { + return ( + + + + ); +} + +export { ProgressBackwards }; \ No newline at end of file diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 73503da4f..6ba30fe42 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -10,6 +10,10 @@ type SubscriptionStatusContextType = { subscribed: boolean; /** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */ limitsExceeded: boolean; + /** Unix timestamp (ms) when the trial expires, or null if not in trial. */ + trialExpiresAt: number | null; + /** True if the organization is currently in a trial period. */ + isTrial: boolean; }; const SubscriptionStatusContext = createContext< diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index a105e5d58..e1e781193 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -71,6 +71,19 @@ export function SubscriptionStatusProvider({ const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false; + const trialExpiresAt = (() => { + if (subscriptionStatusState?.subscriptions) { + for (const { subscription } of subscriptionStatusState.subscriptions) { + if (subscription.expiresAt != null) { + return subscription.expiresAt * 1000; // convert seconds to ms + } + } + } + return null; + })(); + + const isTrial = subscriptionStatusState?.subscriptions?.some(({ subscription }) => subscription.trial) ?? false; + return ( {children} From 2dad97cb6b9e99fa63260d1a90b6403a8e041375 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 12:19:21 -0700 Subject: [PATCH 091/176] Add ability to predefine a root api key --- server/setup/ensureRootApiKey.ts | 106 +++++++++++++++++++++++++++++++ server/setup/index.ts | 2 + 2 files changed, 108 insertions(+) create mode 100644 server/setup/ensureRootApiKey.ts diff --git a/server/setup/ensureRootApiKey.ts b/server/setup/ensureRootApiKey.ts new file mode 100644 index 000000000..4cf9c032b --- /dev/null +++ b/server/setup/ensureRootApiKey.ts @@ -0,0 +1,106 @@ +import { db, apiKeys } from "@server/db"; +import { eq } from "drizzle-orm"; +import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; +import moment from "moment"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + +function validateApiKeyId(id: string): boolean { + return /^[a-z0-9]{15}$/.test(id); +} + +function validateApiKeySecret(secret: string): boolean { + return secret.length > 0; +} + +function showRootApiKey(apiKeyId: string, source: string): void { + console.log(`=== ROOT API KEY ${source} ===`); + console.log("API Key ID:", apiKeyId); + console.log( + "The root API key from PANGOLIN_ROOT_API_KEY has been applied." + ); + console.log("Use the full key value (apiKeyId.apiKeySecret) in requests."); + console.log("================================"); +} + +export async function ensureRootApiKey() { + try { + const envApiKey = process.env.PANGOLIN_ROOT_API_KEY; + + if (!envApiKey) { + logger.debug( + "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped." + ); + return; + } + + const parts = envApiKey.split("."); + if (parts.length !== 2) { + throw new Error( + "Invalid format for PANGOLIN_ROOT_API_KEY. Expected format: {apiKeyId}.{apiKeySecret}" + ); + } + + const [apiKeyId, apiKeySecret] = parts; + + if (!validateApiKeyId(apiKeyId)) { + throw new Error( + "Invalid apiKeyId in PANGOLIN_ROOT_API_KEY. Must be 15 lowercase alphanumeric characters." + ); + } + + if (!validateApiKeySecret(apiKeySecret)) { + throw new Error( + "Invalid apiKeySecret in PANGOLIN_ROOT_API_KEY. Secret must not be empty." + ); + } + + const apiKeyHash = await hashPassword(apiKeySecret); + const lastChars = apiKeySecret.slice(-4); + const createdAt = moment().toISOString(); + + const [existingKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + if (existingKey) { + if (!existingKey.isRoot) { + console.warn( + `API key with ID ${apiKeyId} exists but is not a root key. Promoting to root and updating hash.` + ); + } else { + console.warn( + `Overwriting existing root API key hash since PANGOLIN_ROOT_API_KEY is set (apiKeyId: ${apiKeyId})` + ); + } + + await db + .update(apiKeys) + .set({ apiKeyHash, lastChars, isRoot: true }) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + showRootApiKey(apiKeyId, "UPDATED FROM ENVIRONMENT"); + } else { + await db.insert(apiKeys).values({ + apiKeyId, + name: "Root API Key (Environment)", + apiKeyHash, + lastChars, + createdAt, + isRoot: true + }); + + showRootApiKey(apiKeyId, "CREATED FROM ENVIRONMENT"); + } + } catch (error) { + console.error("Failed to ensure root API key:", error); + throw error; + } +} \ No newline at end of file diff --git a/server/setup/index.ts b/server/setup/index.ts index 2dfb633e5..c46e6b8fd 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -2,10 +2,12 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; import { ensureSetupToken } from "./ensureSetupToken"; +import { ensureRootApiKey } from "./ensureRootApiKey"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); await ensureSetupToken(); // ensure setup token exists for initial setup + await ensureRootApiKey(); } From 9a6408d28cd8a21ae3df14e0bd942dddc6e90d69 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 14:57:24 -0700 Subject: [PATCH 092/176] Refresh domains for latest status --- .../settings/domains/[domainId]/page.tsx | 62 +++---------- src/components/DomainPageClient.tsx | 93 +++++++++++++++++++ src/components/DomainsTable.tsx | 53 ++++++----- src/components/RefreshButton.tsx | 12 ++- src/components/RestartDomainButton.tsx | 10 +- src/lib/queries.ts | 49 +++++++++- 6 files changed, 198 insertions(+), 81 deletions(-) create mode 100644 src/components/DomainPageClient.tsx diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 6d08636d1..9f9878967 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,16 +1,8 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainInfoCard from "@app/components/DomainInfoCard"; -import RestartDomainButton from "@app/components/RestartDomainButton"; +import DomainPageClient from "@app/components/DomainPageClient"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { pullEnv } from "@app/lib/pullEnv"; -import { getTranslations } from "next-intl/server"; -import RefreshButton from "@app/components/RefreshButton"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { GetDNSRecordsResponse } from "@server/routers/domain"; -import DNSRecordsTable from "@app/components/DNSRecordTable"; -import DomainCertForm from "@app/components/DomainCertForm"; -import { build } from "@server/build"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -25,8 +17,6 @@ export default async function DomainSettingsPage({ params }: DomainSettingsPageProps) { const { domainId, orgId } = await params; - const t = await getTranslations(); - const env = pullEnv(); let domain: GetDomainResponse | null = null; try { @@ -39,57 +29,27 @@ export default async function DomainSettingsPage({ return null; } - let dnsRecords; + let dnsRecords: GetDNSRecordsResponse | null = null; try { const response = await internal.get( `/org/${orgId}/domain/${domainId}/dns-records`, await authCookieHeader() ); dnsRecords = response.data.data; - } catch (error) { + } catch { return null; } - if (!domain) { + if (!domain || !dnsRecords) { return null; } return ( - <> -
- - {env.flags.usePangolinDns && domain.failed ? ( - - ) : ( - - )} -
-
- {build != "oss" && env.flags.usePangolinDns ? ( - - ) : null} - - - - {domain.type == "wildcard" && !domain.configManaged && ( - - )} -
- + ); -} +} \ No newline at end of file diff --git a/src/components/DomainPageClient.tsx b/src/components/DomainPageClient.tsx new file mode 100644 index 000000000..31527c5b8 --- /dev/null +++ b/src/components/DomainPageClient.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import DNSRecordsTable from "@app/components/DNSRecordTable"; +import RestartDomainButton from "@app/components/RestartDomainButton"; +import RefreshButton from "@app/components/RefreshButton"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainCertForm from "@app/components/DomainCertForm"; +import { build } from "@server/build"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; + +interface DomainPageClientProps { + initialDomain: GetDomainResponse; + initialDnsRecords: GetDNSRecordsResponse; + orgId: string; + domainId: string; +} + +export default function DomainPageClient({ + initialDomain, + initialDnsRecords, + orgId, + domainId +}: DomainPageClientProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + + const { data: domain, refetch: refetchDomain } = useQuery({ + ...domainQueries.getDomain({ orgId, domainId }), + initialData: initialDomain + }); + + const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({ + ...domainQueries.getDNSRecords({ orgId, domainId }), + initialData: initialDnsRecords + }); + + const refetchAll = () => { + refetchDomain(); + refetchDnsRecords(); + }; + + return ( + <> +
+ + {env.flags.usePangolinDns && domain.failed ? ( + + ) : ( + + )} +
+
+ {build !== "oss" && env.flags.usePangolinDns ? ( + + ) : null} + + ({ + ...r, + id: String(r.id) + }))} + type={domain.type} + /> + + {domain.type === "wildcard" && !domain.configManaged && ( + + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index f5cb1ae74..2c3abeb1a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -10,13 +10,12 @@ import { MoreHorizontal, RefreshCw } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; @@ -34,6 +33,10 @@ import { TooltipTrigger } from "./ui/tooltip"; import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { toUnicode } from "punycode"; +import { durationToMs } from "@app/lib/durationToMs"; export type DomainRow = { domainId: string; @@ -59,32 +62,32 @@ export default function DomainsTable({ domains, orgId }: Props) { const [selectedDomain, setSelectedDomain] = useState( null ); - const [isRefreshing, setIsRefreshing] = useState(false); const [restartingDomains, setRestartingDomains] = useState>( new Set() ); const env = useEnvContext(); const api = createApiClient(env); - const router = useRouter(); const t = useTranslations(); const { toast } = useToast(); const { org } = useOrgContext(); + const queryClient = useQueryClient(); - const refreshData = async () => { - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; + const { data: rawDomains, isRefetching, refetch } = useQuery({ + ...orgQueries.domains({ orgId }), + initialData: domains as any, + refetchInterval: durationToMs(10, "seconds") + }); + + const tableData = useMemo( + () => + (rawDomains ?? []).map((d) => ({ + ...d, + baseDomain: toUnicode(d.baseDomain), + type: d.type ?? "", + errorMessage: d.errorMessage ?? null + } as DomainRow)), + [rawDomains] + ); const deleteDomain = async (domainId: string) => { try { @@ -94,7 +97,7 @@ export default function DomainsTable({ domains, orgId }: Props) { description: t("domainDeletedDescription") }); setIsDeleteModalOpen(false); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -114,7 +117,7 @@ export default function DomainsTable({ domains, orgId }: Props) { fallback: "Domain verification restarted successfully" }) }); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -361,16 +364,16 @@ export default function DomainsTable({ domains, orgId }: Props) { open={isCreateModalOpen} setOpen={setIsCreateModalOpen} onCreated={(domain) => { - refreshData(); + refetch(); }} /> setIsCreateModalOpen(true)} - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={refetch} + isRefreshing={isRefetching} /> ); diff --git a/src/components/RefreshButton.tsx b/src/components/RefreshButton.tsx index 3ba7d4f32..67799546f 100644 --- a/src/components/RefreshButton.tsx +++ b/src/components/RefreshButton.tsx @@ -7,7 +7,11 @@ import { Button } from "@app/components/ui/button"; import { useTranslations } from "next-intl"; import { toast } from "@app/hooks/useToast"; -export default function RefreshButton() { +interface RefreshButtonProps { + onRefresh?: () => void; +} + +export default function RefreshButton({ onRefresh }: RefreshButtonProps = {}) { const router = useRouter(); const [isRefreshing, setIsRefreshing] = useState(false); const t = useTranslations(); @@ -16,7 +20,11 @@ export default function RefreshButton() { setIsRefreshing(true); try { await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); + if (onRefresh) { + onRefresh(); + } else { + router.refresh(); + } } catch { toast({ title: t("error"), diff --git a/src/components/RestartDomainButton.tsx b/src/components/RestartDomainButton.tsx index 670f4fa2c..5501ad1ed 100644 --- a/src/components/RestartDomainButton.tsx +++ b/src/components/RestartDomainButton.tsx @@ -12,11 +12,13 @@ import { useTranslations } from "next-intl"; interface RestartDomainButtonProps { orgId: string; domainId: string; + onSuccess?: () => void; } export default function RestartDomainButton({ orgId, - domainId + domainId, + onSuccess }: RestartDomainButtonProps) { const router = useRouter(); const api = createApiClient(useEnvContext()); @@ -35,7 +37,11 @@ export default function RestartDomainButton({ }); // Wait a bit before refreshing to allow the restart to take effect await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); + if (onSuccess) { + onSuccess(); + } else { + router.refresh(); + } } catch (e) { toast({ title: t("error"), diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2fd34e8ac..7a22639fe 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,7 +1,8 @@ import { build } from "@server/build"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { ListClientsResponse } from "@server/routers/client"; -import type { ListDomainsResponse } from "@server/routers/domain"; +import type { ListDomainsResponse, GetDNSRecordsResponse } from "@server/routers/domain"; +import type { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { GetResourceWhitelistResponse, ListResourceNamesResponse, @@ -472,3 +473,49 @@ export const approvalQueries = { } }) }; + +export const domainQueries = { + getDomain: ({ + orgId, + domainId + }: { + orgId: string; + domainId: string; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "DOMAIN", domainId] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/domain/${domainId}`, { signal }); + return res.data.data; + }, + refetchInterval: durationToMs(10, "seconds") + }), + getDNSRecords: ({ + orgId, + domainId + }: { + orgId: string; + domainId: string; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "DOMAIN", + domainId, + "DNS_RECORDS" + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/domain/${domainId}/dns-records`, + { signal } + ); + return res.data.data; + }, + refetchInterval: durationToMs(10, "seconds") + }) +}; From 54adcd2c5600d02b165e050f13726b36aab96070 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 15:07:26 -0700 Subject: [PATCH 093/176] Show the endpoint if its there --- messages/en-US.json | 3 ++- src/components/SiteInfoCard.tsx | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ff09dc4fd..f56bcd81f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2917,5 +2917,6 @@ "idpUnassociateWarning": "This cannot be undone for this organization.", "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", "idpUnassociateMenu": "Unassociate", - "idpDeleteAllOrgsMenu": "Delete" + "idpDeleteAllOrgsMenu": "Delete", + "publicIpEndpoint": "Endpoint" } diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index c0a9b36b1..b075e453d 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { return ( - + {t("identifier")} {site.niceId} @@ -68,6 +68,18 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {getConnectionTypeString(site.type)} + {site.endpoint && ( + + + {t("publicIpEndpoint")} + + + {site.endpoint.includes(":") + ? site.endpoint.substring(0, site.endpoint.lastIndexOf(":")) + : site.endpoint} + + + )} From 3c005c9ab180791015ebf8579abaf14b7fe48dbc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 16:49:45 -0700 Subject: [PATCH 094/176] rename log tabls for clarity and update font --- messages/en-US.json | 36 ++++++++++++++++++------------------ src/app/layout.tsx | 8 ++++++-- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ff09dc4fd..f5193953e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2416,7 +2416,7 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs", + "accessLogs": "Authentication Logs", "exportCsv": "Export CSV", "exportError": "Unknown error when exporting CSV", "exportCsvTooltip": "Within Time Range", @@ -2436,25 +2436,25 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Reason", - "requestLogs": "Request Logs", + "requestLogs": "HTTPS Request Logs", "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", - "actionLogs": "Action Logs", - "sidebarLogsRequest": "Request Logs", - "sidebarLogsAccess": "Access Logs", - "sidebarLogsAction": "Action Logs", + "actionLogs": "Admin Action Logs", + "sidebarLogsRequest": "HTTPS Request Logs", + "sidebarLogsAccess": "Authentication Logs", + "sidebarLogsAction": "Admin Action Logs", "logRetention": "Log Retention", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", - "requestLogsDescription": "View detailed request logs for resources in this organization", + "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", - "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestLabel": "HTTPS Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", - "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessDescription": "How long to retain access logs", - "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionLabel": "Admin Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", - "logRetentionConnectionLabel": "Connection Log Retention", + "logRetentionConnectionLabel": "Network Log Retention", "logRetentionConnectionDescription": "How long to retain connection logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", @@ -2466,9 +2466,9 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "connectionLogs": "Connection Logs", - "connectionLogsDescription": "View connection logs for tunnels in this organization", - "sidebarLogsConnection": "Connection Logs", + "connectionLogs": "Network Logs", + "connectionLogsDescription": "View network session logs handled by sites in this organization", + "sidebarLogsConnection": "Network Logs", "sidebarLogsStreaming": "Streaming", "sourceAddress": "Source Address", "destinationAddress": "Destination Address", @@ -2887,13 +2887,13 @@ "httpDestFormatSingleDescription": "Sends a separate HTTP POST for each individual event. Use only for endpoints that cannot handle batches.", "httpDestLogTypesTitle": "Log Types", "httpDestLogTypesDescription": "Choose which log types are forwarded to this destination. Only enabled log types will be streamed.", - "httpDestAccessLogsTitle": "Access Logs", + "httpDestAccessLogsTitle": "Authentication Logs", "httpDestAccessLogsDescription": "Resource access attempts, including authenticated and denied requests.", - "httpDestActionLogsTitle": "Action Logs", + "httpDestActionLogsTitle": "Admin Action Logs", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", - "httpDestConnectionLogsTitle": "Connection Logs", + "httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", - "httpDestRequestLogsTitle": "Request Logs", + "httpDestRequestLogsTitle": "HTTPS Request Logs", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestSaveChanges": "Save Changes", "httpDestCreateDestination": "Create Destination", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0db1b49bf..9cf66dd28 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; -import { Inter } from "next/font/google"; +import { Inter, Mona_Sans } from "next/font/google"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -36,7 +36,11 @@ const inter = Inter({ subsets: ["latin"] }); -const fontClassName = inter.className; +const monaSans = Mona_Sans({ + subsets: ["latin"] +}); + +const fontClassName = monaSans.className; export default async function RootLayout({ children From 2e8d1701147b4f81f01ccb7890c3320ada918f33 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:05:12 -0700 Subject: [PATCH 095/176] Hide protocol by default --- src/components/ProxyResourcesTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index cb56174f2..dddf1312c 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -378,6 +378,7 @@ export default function ProxyResourcesTable({ { accessorKey: "protocol", friendlyName: t("protocol"), + enableHiding: true, header: () => {t("protocol")}, cell: ({ row }) => { const resourceRow = row.original; @@ -684,7 +685,7 @@ export default function ProxyResourcesTable({ isRefreshing={isRefreshing || isFiltering} isNavigatingToAddPage={isNavigatingToAddPage} enableColumnVisibility - columnVisibility={{ niceId: false }} + columnVisibility={{ niceId: false, protocol: false }} stickyLeftColumn="name" stickyRightColumn="actions" /> From 9f5f89c9eb604db06ab53dcb8331d9281362b013 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:05:47 -0700 Subject: [PATCH 096/176] Remove debug log --- server/setup/ensureRootApiKey.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/setup/ensureRootApiKey.ts b/server/setup/ensureRootApiKey.ts index 4cf9c032b..55f5186b3 100644 --- a/server/setup/ensureRootApiKey.ts +++ b/server/setup/ensureRootApiKey.ts @@ -34,9 +34,9 @@ export async function ensureRootApiKey() { const envApiKey = process.env.PANGOLIN_ROOT_API_KEY; if (!envApiKey) { - logger.debug( - "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped." - ); + // logger.debug( + // "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped." + // ); return; } @@ -103,4 +103,4 @@ export async function ensureRootApiKey() { console.error("Failed to ensure root API key:", error); throw error; } -} \ No newline at end of file +} From 47be3dbdf957fcdbe11e09c39cdf72d309e4183a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 17:06:56 -0700 Subject: [PATCH 097/176] hide add idp button when no mode set --- src/components/IdpGlobalModeBanner.tsx | 16 +++++++++------- src/components/OrgIdpDataTable.tsx | 5 ++++- src/components/OrgIdpTable.tsx | 6 +++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/IdpGlobalModeBanner.tsx b/src/components/IdpGlobalModeBanner.tsx index 9f864b36d..5e2709e6f 100644 --- a/src/components/IdpGlobalModeBanner.tsx +++ b/src/components/IdpGlobalModeBanner.tsx @@ -8,23 +8,25 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; +import type { Env } from "@app/lib/types/env"; + +export function isIdpGlobalModeBannerVisible(env: Env): boolean { + if (build === "saas") { + return false; + } + return env.app.identityProviderMode === undefined; +} export function IdpGlobalModeBanner() { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser, hasEnterpriseLicense } = usePaidStatus(); - const identityProviderModeUndefined = - env.app.identityProviderMode === undefined; const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc); const enterpriseUnlicensed = build === "enterprise" && !hasEnterpriseLicense; - if (build === "saas") { - return null; - } - - if (!identityProviderModeUndefined) { + if (!isIdpGlobalModeBannerVisible(env)) { return null; } diff --git a/src/components/OrgIdpDataTable.tsx b/src/components/OrgIdpDataTable.tsx index 7e3f7ab65..fe15b6cc9 100644 --- a/src/components/OrgIdpDataTable.tsx +++ b/src/components/OrgIdpDataTable.tsx @@ -12,13 +12,15 @@ interface DataTableProps { data: TData[]; onAdd?: () => void; addActions?: DataTableAddAction[]; + addButtonDisabled?: boolean; } export function IdpDataTable({ columns, data, onAdd, - addActions + addActions, + addButtonDisabled }: DataTableProps) { const t = useTranslations(); @@ -33,6 +35,7 @@ export function IdpDataTable({ addButtonText={t("idpAdd")} onAdd={onAdd} addActions={addActions} + addButtonDisabled={addButtonDisabled} enableColumnVisibility={true} stickyRightColumn="actions" /> diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index 0e3a83dc2..1fe61b2fc 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -52,6 +52,7 @@ import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types" import { cn } from "@app/lib/cn"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; export type IdpRow = { idpId: number; @@ -85,13 +86,15 @@ export default function IdpTable({ idps, orgId }: Props) { const [importSubmitting, setImportSubmitting] = useState(false); const [debouncedImportSearch] = useDebounce(importSearchQuery, 150); - const api = createApiClient(useEnvContext()); + const envContext = useEnvContext(); + const api = createApiClient(envContext); const { user } = useUserContext(); const { isPaidUser } = usePaidStatus(); const router = useRouter(); const t = useTranslations(); const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc); + const addIdpDisabled = isIdpGlobalModeBannerVisible(envContext.env); const { data: adminIdpsRaw = [] } = useQuery({ queryKey: ["admin-org-idps", user.userId], @@ -427,6 +430,7 @@ export default function IdpTable({ idps, orgId }: Props) { Date: Mon, 20 Apr 2026 17:19:44 -0700 Subject: [PATCH 098/176] roleIds are numbers --- server/db/pg/schema/privateSchema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 2 +- .../routers/alertRule/createAlertRule.ts | 26 ++-- .../routers/alertRule/updateAlertRule.ts | 8 +- .../settings/alerting/[ruleId]/page.tsx | 1 - src/lib/alertRuleForm.ts | 12 +- src/lib/alertRulesLocalStorage.ts | 129 ------------------ 7 files changed, 27 insertions(+), 153 deletions(-) delete mode 100644 src/lib/alertRulesLocalStorage.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 9007013b1..649993e46 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -528,7 +528,7 @@ export const alertEmailRecipients = pgTable("alertEmailRecipients", { userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: varchar("roleId").references(() => roles.roleId, { + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: varchar("email", { length: 255 }) // external emails not tied to a user diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 318a094dd..435a50e32 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -513,7 +513,7 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { .notNull() .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: text("email") }); diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 40408d898..25ac64afb 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, roles } from "@server/db"; import { alertRules, alertSites, @@ -30,6 +30,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { and, eq } from "drizzle-orm"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -66,7 +67,7 @@ const bodySchema = z .default([]), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), - roleIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), emails: z.array(z.string().email()).optional().default([]), // Webhook actions webhookActions: z.array(webhookActionSchema).optional().default([]) @@ -82,8 +83,7 @@ const bodySchema = z if (isSiteEvent && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "At least one siteId is required for site event types", + message: "At least one siteId is required for site event types", path: ["siteIds"] }); } @@ -108,8 +108,7 @@ const bodySchema = z if (isHcEvent && val.siteIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "siteIds must not be set for health check event types", + message: "siteIds must not be set for health check event types", path: ["siteIds"] }); } @@ -216,7 +215,9 @@ export async function createAlertRule( // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = - userIds.length > 0 || roleIds.length > 0 || emails.length > 0; + userIds.length > 0 || + roleIds.length > 0 || + emails.length > 0; if (hasRecipients) { const [emailActionRow] = await db @@ -228,7 +229,7 @@ export async function createAlertRule( ...userIds.map((userId) => ({ emailActionId: emailActionRow.emailActionId, userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...roleIds.map((roleId) => ({ @@ -240,7 +241,7 @@ export async function createAlertRule( ...emails.map((email) => ({ emailActionId: emailActionRow.emailActionId, userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -254,7 +255,10 @@ export async function createAlertRule( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config != null ? encrypt(wa.config, serverSecret) : null, + config: + wa.config != null + ? encrypt(wa.config, serverSecret) + : null, enabled: wa.enabled })) ); @@ -275,4 +279,4 @@ export async function createAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index add031dc4..398156258 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -70,7 +70,7 @@ const bodySchema = z healthCheckIds: z.array(z.number().int().positive()).optional(), // Recipient arrays - if any are provided the full recipient set is replaced userIds: z.array(z.string().nonempty()).optional(), - roleIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.number()).optional(), emails: z.array(z.string().email()).optional(), // Webhook actions - if provided the full webhook set is replaced webhookActions: z.array(webhookActionSchema).optional() @@ -244,7 +244,7 @@ export async function updateAlertRule( const newRecipients = [ ...(userIds ?? []).map((userId) => ({ userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...(roleIds ?? []).map((roleId) => ({ @@ -254,7 +254,7 @@ export async function updateAlertRule( })), ...(emails ?? []).map((email) => ({ userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -331,4 +331,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 34afceaab..50c612bbf 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -49,7 +49,6 @@ export default function EditAlertRulePage() { }); setFormValues(null); }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [orgId, alertRuleId]); useEffect(() => { diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 2756ca165..b38639f37 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -66,7 +66,7 @@ export type AlertRuleApiPayload = { siteIds: number[]; healthCheckIds: number[]; userIds: string[]; - roleIds: string[]; + roleIds: number[]; emails: string[]; webhookActions: { webhookUrl: string; @@ -91,7 +91,7 @@ export type AlertRuleApiResponse = { recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -297,7 +297,7 @@ export function apiResponseToFormValues( .map((r) => ({ id: r.userId!, text: r.userId! })); const roleTags = rule.recipients .filter((r) => r.roleId != null) - .map((r) => ({ id: r.roleId!, text: r.roleId! })); + .map((r) => ({ id: String(r.roleId!), text: String(r.roleId!) })); const emailTags = rule.recipients .filter((r) => r.email != null) .map((r) => ({ id: r.email!, text: r.email! })); @@ -358,7 +358,7 @@ export function formValuesToApiPayload( // Collect all notify-type actions and merge their recipient lists const allUserIds: string[] = []; - const allRoleIds: string[] = []; + const allRoleIds: number[] = []; const allEmails: string[] = []; const webhookActions: AlertRuleApiPayload["webhookActions"] = []; @@ -366,7 +366,7 @@ export function formValuesToApiPayload( for (const action of values.actions) { if (action.type === "notify") { allUserIds.push(...action.userTags.map((t) => t.id)); - allRoleIds.push(...action.roleTags.map((t) => t.id)); + allRoleIds.push(...action.roleTags.map((t) => Number(t.id))); allEmails.push( ...action.emailTags .map((t) => t.text.trim()) @@ -391,7 +391,7 @@ export function formValuesToApiPayload( // Deduplicate const uniqueUserIds = [...new Set(allUserIds)]; - const uniqueRoleIds = [...new Set(allRoleIds)]; + const uniqueRoleIds: number[] = [...new Set(allRoleIds)]; const uniqueEmails = [...new Set(allEmails)]; return { diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts deleted file mode 100644 index 2471219b0..000000000 --- a/src/lib/alertRulesLocalStorage.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { z } from "zod"; - -const STORAGE_PREFIX = "pangolin:alert-rules:"; - -export const webhookHeaderEntrySchema = z.object({ - key: z.string(), - value: z.string() -}); - -export const alertActionSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userIds: z.array(z.string()), - roleIds: z.array(z.number()), - emails: z.array(z.string()) - }), - z.object({ - type: z.literal("webhook"), - url: z.string().url(), - method: z.string().min(1), - headers: z.array(webhookHeaderEntrySchema), - secret: z.string().optional() - }) -]); - -export const alertSourceSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("site"), - siteIds: z.array(z.number()) - }), - z.object({ - type: z.literal("health_check"), - targetIds: z.array(z.number()) - }) -]); - -export const alertTriggerSchema = z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_unhealthy" -]); - -export const alertRuleSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(255), - enabled: z.boolean(), - createdAt: z.string(), - updatedAt: z.string(), - source: alertSourceSchema, - trigger: alertTriggerSchema, - actions: z.array(alertActionSchema).min(1) -}); - -export type AlertRule = z.infer; -export type AlertAction = z.infer; -export type AlertTrigger = z.infer; - -function storageKey(orgId: string) { - return `${STORAGE_PREFIX}${orgId}`; -} - -export function getRule(orgId: string, ruleId: string): AlertRule | undefined { - return loadRules(orgId).find((r) => r.id === ruleId); -} - -export function loadRules(orgId: string): AlertRule[] { - if (typeof window === "undefined") { - return []; - } - try { - const raw = localStorage.getItem(storageKey(orgId)); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - return []; - } - const out: AlertRule[] = []; - for (const item of parsed) { - const r = alertRuleSchema.safeParse(item); - if (r.success) { - out.push(r.data); - } - } - return out; - } catch { - return []; - } -} - -export function saveRules(orgId: string, rules: AlertRule[]) { - if (typeof window === "undefined") { - return; - } - localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); -} - -export function upsertRule(orgId: string, rule: AlertRule) { - const rules = loadRules(orgId); - const i = rules.findIndex((r) => r.id === rule.id); - if (i >= 0) { - rules[i] = rule; - } else { - rules.push(rule); - } - saveRules(orgId, rules); -} - -export function deleteRule(orgId: string, ruleId: string) { - const rules = loadRules(orgId).filter((r) => r.id !== ruleId); - saveRules(orgId, rules); -} - -export function newRuleId() { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -export function isoNow() { - return new Date().toISOString(); -} From 0a708960808be95fbd519f69bb18a4033bad4ecb Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:37:01 -0700 Subject: [PATCH 099/176] Add toggle types --- server/db/pg/schema/privateSchema.ts | 4 +- server/db/sqlite/schema/privateSchema.ts | 4 +- server/emails/templates/AlertNotification.tsx | 34 +++++- .../lib/alerts/events/healthCheckEvents.ts | 4 +- .../lib/alerts/events/resourceEvents.ts | 4 +- server/private/lib/alerts/sendAlertEmail.ts | 8 +- server/private/lib/alerts/types.ts | 6 +- .../routers/alertRule/createAlertRule.ts | 14 +-- .../private/routers/alertRule/getAlertRule.ts | 47 ++++---- .../routers/alertRule/updateAlertRule.ts | 13 +- src/components/AlertingRulesTable.tsx | 6 +- .../alert-rule-editor/AlertRuleFields.tsx | 12 +- .../AlertRuleGraphEditor.tsx | 4 + src/lib/alertRuleForm.ts | 114 ++++++++---------- 14 files changed, 159 insertions(+), 115 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 649993e46..0764cc6be 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -475,8 +475,10 @@ export const alertRules = pgTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" >() .notNull(), // Nullable depending on eventType diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 435a50e32..f44c0b200 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -467,8 +467,10 @@ export const alertRules = sqliteTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 8a0cf7631..41ea8f746 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -15,8 +15,10 @@ import { export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; interface Props { eventType: AlertEventType; @@ -50,6 +52,15 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Offline", statusColor: "#dc2626" }; + case "site_toggle": + return { + heading: "Site Status Changed", + previewText: "A site in your organization has changed status.", + summary: + "A site in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; case "health_check_healthy": return { heading: "Health Check Recovered", @@ -60,7 +71,7 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Healthy", statusColor: "#16a34a" }; - case "health_check_not_healthy": + case "health_check_unhealthy": return { heading: "Health Check Failing", previewText: @@ -70,6 +81,25 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Not Healthy", statusColor: "#dc2626" }; + case "health_check_toggle": + return { + heading: "Health Check Status Changed", + previewText: + "A health check in your organization has changed status.", + summary: + "A health check in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; + default: + return { + heading: "Alert Notification", + previewText: "An alert event has occurred in your organization.", + summary: + "An alert event has occurred in your organization. Please review the details below and take action if needed.", + statusLabel: "Alert", + statusColor: "#f59e0b" + }; } } diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9ede25fe6..594e27aec 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert( } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `health_check_unhealthy` alert for the given health check. * * Call this after a health check has been detected as failing so that any * matching `alertRules` can dispatch their email and webhook actions. @@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert( ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "health_check_unhealthy", orgId, healthCheckId, data: { diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 9ede25fe6..594e27aec 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert( } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `health_check_unhealthy` alert for the given health check. * * Call this after a health check has been detected as failing so that any * matching `alertRules` can dispatch their email and webhook actions. @@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert( ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "health_check_unhealthy", orgId, healthCheckId, data: { diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index cd78e6e87..afadfed62 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -72,10 +72,14 @@ function buildSubject(context: AlertContext): string { return "[Alert] Site Back Online"; case "site_offline": return "[Alert] Site Offline"; + case "site_toggle": + return "[Alert] Site Toggled"; case "health_check_healthy": return "[Alert] Health Check Recovered"; - case "health_check_not_healthy": + case "health_check_unhealthy": return "[Alert] Health Check Failing"; + case "health_check_toggle": + return "[Alert] Health Check Toggled"; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. @@ -84,4 +88,4 @@ function buildSubject(context: AlertContext): string { return "[Alert] Event Notification"; } } -} \ No newline at end of file +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index e79db2ef5..45f45c035 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -18,8 +18,10 @@ export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; // --------------------------------------------------------------------------- // Webhook authentication config (stored as encrypted JSON in the DB) @@ -60,4 +62,4 @@ export interface AlertContext { healthCheckId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 25ac64afb..eab6d674a 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -30,12 +30,12 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { and, eq } from "drizzle-orm"; -const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; -const HC_EVENT_TYPES = [ +export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const; +export const HC_EVENT_TYPES = [ "health_check_healthy", - "health_check_not_healthy" + "health_check_unhealthy", + "health_check_toggle" ] as const; const paramsSchema = z.strictObject({ @@ -52,10 +52,8 @@ const bodySchema = z .strictObject({ name: z.string().nonempty(), eventType: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 5d307316b..b9c5912a9 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -47,8 +47,10 @@ export type GetAlertRuleResponse = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; @@ -59,7 +61,7 @@ export type GetAlertRuleResponse = { recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -177,24 +179,27 @@ export async function getAlertRule( healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, webhookActions: webhooks.map((w) => { - let parsedConfig: WebhookAlertConfig | null = null; - if (w.config) { - try { - const serverSecret = config.getRawConfig().server.secret!; - const decrypted = decrypt(w.config, serverSecret); - parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig; - } catch { - // best-effort – return null if decryption fails - } - } - return { - webhookActionId: w.webhookActionId, - webhookUrl: w.webhookUrl, - enabled: w.enabled, - lastSentAt: w.lastSentAt ?? null, - config: parsedConfig - }; - }) + let parsedConfig: WebhookAlertConfig | null = null; + if (w.config) { + try { + const serverSecret = + config.getRawConfig().server.secret!; + const decrypted = decrypt(w.config, serverSecret); + parsedConfig = JSON.parse( + decrypted + ) as WebhookAlertConfig; + } catch { + // best-effort – return null if decryption fails + } + } + return { + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) }, success: true, error: false, @@ -207,4 +212,4 @@ export async function getAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 398156258..0ad62647d 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -31,12 +31,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; - -const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; -const HC_EVENT_TYPES = [ - "health_check_healthy", - "health_check_not_healthy" -] as const; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule"; const paramsSchema = z .object({ @@ -57,10 +52,8 @@ const bodySchema = z name: z.string().nonempty().optional(), eventType: z .enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES ]) .optional(), enabled: z.boolean().optional(), diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 33944db39..74b8348fa 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -71,10 +71,14 @@ function triggerLabel( return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); - case "health_check_not_healthy": + case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); default: return rule.eventType; } diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 2037d50a8..678ee3c8c 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -888,7 +888,8 @@ export function AlertRuleSourceFields({ if (next === "site") { if ( curTrigger !== "site_online" && - curTrigger !== "site_offline" + curTrigger !== "site_offline" && + curTrigger !== "site_toggle" ) { setValue("trigger", "site_offline", { shouldValidate: true @@ -896,7 +897,8 @@ export function AlertRuleSourceFields({ } } else if ( curTrigger !== "health_check_healthy" && - curTrigger !== "health_check_unhealthy" + curTrigger !== "health_check_unhealthy" && + curTrigger !== "health_check_toggle" ) { setValue( "trigger", @@ -996,6 +998,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerSiteOffline")} + + {t("alertingTriggerSiteToggle")} + ) : ( <> @@ -1005,6 +1010,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerHcUnhealthy")} + + {t("alertingTriggerHcToggle")} + )} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index e0e8e0a3e..094976b55 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -94,10 +94,14 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); default: return v.trigger; } diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index b38639f37..38a75dc80 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -13,14 +13,16 @@ export const tagSchema = z.object({ // --------------------------------------------------------------------------- // Form-layer types // NOTE: the form uses "health_check_unhealthy" internally; it maps to the -// backend's "health_check_not_healthy" at the API boundary. +// backend's "health_check_unhealthy" at the API boundary. // --------------------------------------------------------------------------- export type AlertTrigger = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_unhealthy"; + | "health_check_unhealthy" + | "health_check_toggle"; export type AlertRuleFormAction = | { @@ -60,8 +62,10 @@ export type AlertRuleApiPayload = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; enabled: boolean; siteIds: number[]; healthCheckIds: number[]; @@ -111,26 +115,6 @@ export type AlertRuleApiResponse = { }[]; }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function triggerToEventType( - trigger: AlertTrigger -): AlertRuleApiPayload["eventType"] { - if (trigger === "health_check_unhealthy") { - return "health_check_not_healthy"; - } - return trigger as AlertRuleApiPayload["eventType"]; -} - -function eventTypeToTrigger(eventType: string): AlertTrigger { - if (eventType === "health_check_not_healthy") { - return "health_check_unhealthy"; - } - return eventType as AlertTrigger; -} - // --------------------------------------------------------------------------- // Zod form schema (for react-hook-form validation) // --------------------------------------------------------------------------- @@ -138,7 +122,9 @@ function eventTypeToTrigger(eventType: string): AlertTrigger { export function buildFormSchema(t: (k: string) => string) { return z .object({ - name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + name: z + .string() + .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), sourceType: z.enum(["site", "health_check"]), siteIds: z.array(z.number()), @@ -146,36 +132,37 @@ export function buildFormSchema(t: (k: string) => string) { trigger: z.enum([ "site_online", "site_offline", + "site_toggle", "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_toggle" ]), - actions: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userTags: z.array(tagSchema), - roleTags: z.array(tagSchema), - emailTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("webhook"), - url: z.string(), - method: z.string(), - headers: z.array( - z.object({ - key: z.string(), - value: z.string() - }) - ), - authType: z.enum(["none", "bearer", "basic", "custom"]), - bearerToken: z.string(), - basicCredentials: z.string(), - customHeaderName: z.string(), - customHeaderValue: z.string() - }) - ]) - ) + actions: z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userTags: z.array(tagSchema), + roleTags: z.array(tagSchema), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + authType: z.enum(["none", "bearer", "basic", "custom"]), + bearerToken: z.string(), + basicCredentials: z.string(), + customHeaderName: z.string(), + customHeaderValue: z.string() + }) + ]) + ) }) .superRefine((val, ctx) => { if (val.actions.length === 0) { @@ -202,10 +189,15 @@ export function buildFormSchema(t: (k: string) => string) { path: ["healthCheckIds"] }); } - const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline", + "site_toggle" + ]; const hcTriggers: AlertTrigger[] = [ "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_toggle" ]; if ( val.sourceType === "site" && @@ -286,7 +278,7 @@ export function defaultFormValues(): AlertRuleFormValues { export function apiResponseToFormValues( rule: AlertRuleApiResponse ): AlertRuleFormValues { - const trigger = eventTypeToTrigger(rule.eventType); + const trigger = rule.eventType; const sourceType = rule.eventType.startsWith("site_") ? "site" : "health_check"; @@ -318,7 +310,9 @@ export function apiResponseToFormValues( headers: cfg?.headers?.length ? cfg.headers : [{ key: "", value: "" }], - authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", + authType: + (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? + "none", bearerToken: cfg?.bearerToken ?? "", basicCredentials: cfg?.basicCredentials ?? "", customHeaderName: cfg?.customHeaderName ?? "", @@ -342,7 +336,7 @@ export function apiResponseToFormValues( sourceType, siteIds: rule.siteIds, healthCheckIds: rule.healthCheckIds, - trigger, + trigger: trigger as AlertTrigger, actions }; } @@ -354,7 +348,7 @@ export function apiResponseToFormValues( export function formValuesToApiPayload( values: AlertRuleFormValues ): AlertRuleApiPayload { - const eventType = triggerToEventType(values.trigger); + const eventType = values.trigger; // Collect all notify-type actions and merge their recipient lists const allUserIds: string[] = []; @@ -368,9 +362,7 @@ export function formValuesToApiPayload( allUserIds.push(...action.userTags.map((t) => t.id)); allRoleIds.push(...action.roleTags.map((t) => Number(t.id))); allEmails.push( - ...action.emailTags - .map((t) => t.text.trim()) - .filter(Boolean) + ...action.emailTags.map((t) => t.text.trim()).filter(Boolean) ); } else if (action.type === "webhook") { webhookActions.push({ From f38069623b8973656b766e4ac2fe3a09918e7794 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:48:44 -0700 Subject: [PATCH 100/176] Add resource --- messages/en-US.json | 9 ++ server/db/pg/schema/privateSchema.ts | 14 ++ server/db/sqlite/schema/privateSchema.ts | 14 ++ server/emails/templates/AlertNotification.tsx | 33 ++++- .../lib/alerts/events/resourceEvents.ts | 92 +++++++++---- server/private/lib/alerts/processAlerts.ts | 19 +++ server/private/lib/alerts/sendAlertEmail.ts | 6 + server/private/lib/alerts/types.ts | 7 +- .../routers/alertRule/createAlertRule.ts | 67 ++++++++- .../private/routers/alertRule/getAlertRule.ts | 16 ++- .../routers/alertRule/listAlertRules.ts | 21 ++- .../routers/alertRule/updateAlertRule.ts | 45 +++++- src/components/AlertingRulesTable.tsx | 13 +- .../alert-rule-editor/AlertRuleFields.tsx | 130 ++++++++++++++++++ .../AlertRuleGraphEditor.tsx | 16 +++ src/lib/alertRuleForm.ts | 53 ++++++- 16 files changed, 511 insertions(+), 44 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 856586907..80e1cb65f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1383,8 +1383,14 @@ "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site toggled", "alertingTriggerHcHealthy": "Health check healthy", "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingTriggerHcToggle": "Health check toggled", + "alertingTriggerResourceHealthy": "Resource healthy", + "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingTriggerResourceToggle": "Resource toggled", + "alertingSourceResource": "Resource", "alertingSectionActions": "Actions", "alertingAddAction": "Add action", "alertingActionNotify": "Email", @@ -1411,12 +1417,15 @@ "alertingRolesSelected": "{count} roles selected", "alertingSummarySites": "Sites ({count})", "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingSummaryResources": "Resources ({count})", "alertingErrorNameRequired": "Enter a name", "alertingErrorActionsMin": "Add at least one action", "alertingErrorPickSites": "Select at least one site", "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorPickResources": "Select at least one resource", "alertingErrorTriggerSite": "Choose a site trigger", "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorTriggerResource": "Choose a resource trigger", "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingConfigureSource": "Configure Source", "alertingConfigureTrigger": "Configure Trigger", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 0764cc6be..f9126f291 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -21,6 +21,7 @@ import { exitNodes, sessions, clients, + resources, siteResources, targetHealthCheck, sites @@ -479,6 +480,9 @@ export const alertRules = pgTable("alertRules", { | "health_check_healthy" | "health_check_unhealthy" | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), // Nullable depending on eventType @@ -509,6 +513,15 @@ export const alertHealthChecks = pgTable("alertHealthChecks", { }) }); +export const alertResources = pgTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + // Separating channels by type avoids the mixed-shape problem entirely export const alertEmailActions = pgTable("alertEmailActions", { emailActionId: serial("emailActionId").primaryKey(), @@ -584,3 +597,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f44c0b200..f903d2955 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,6 +13,7 @@ import { domains, exitNodes, orgs, + resources, roles, sessions, siteResources, @@ -471,6 +472,9 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_healthy" | "health_check_unhealthy" | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), @@ -500,6 +504,15 @@ export const alertHealthChecks = sqliteTable("alertHealthChecks", { }) }); +export const alertResources = sqliteTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + export const alertEmailActions = sqliteTable("alertEmailActions", { emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), alertRuleId: integer("alertRuleId") @@ -561,3 +574,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 41ea8f746..418924650 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -18,7 +18,10 @@ export type AlertEventType = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; interface Props { eventType: AlertEventType; @@ -91,6 +94,34 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Status Changed", statusColor: "#f59e0b" }; + case "resource_healthy": + return { + heading: "Resource Healthy", + previewText: "A resource in your organization is now healthy.", + summary: + "A resource in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "resource_unhealthy": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy. Please review the details below and take action if needed.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; + case "resource_toggle": + return { + heading: "Resource Status Changed", + previewText: + "A resource in your organization has changed status.", + summary: + "A resource in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; default: return { heading: "Alert Notification", diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 594e27aec..5c9b168e8 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -19,73 +19,109 @@ import { processAlerts } from "../processAlerts"; // --------------------------------------------------------------------------- /** - * Fire a `health_check_healthy` alert for the given health check. + * Fire a `resource_healthy` alert for the given resource. * - * Call this after a previously-failing health check has recovered so that any + * Call this after a previously-unhealthy resource has recovered so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckHealthyAlert( +export async function fireResourceHealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_healthy", + eventType: "resource_healthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } /** - * Fire a `health_check_unhealthy` alert for the given health check. + * Fire a `resource_unhealthy` alert for the given resource. * - * Call this after a health check has been detected as failing so that any + * Call this after a resource has been detected as unhealthy so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckNotHealthyAlert( +export async function fireResourceUnhealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_unhealthy", + eventType: "resource_unhealthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } + +/** + * Fire a `resource_toggle` alert for the given resource. + * + * Call this when a resource's enabled/disabled status is toggled so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceToggleAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 04e3f90fd..2ec2eee98 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -17,6 +17,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions, @@ -97,6 +98,24 @@ export async function processAlerts(context: AlertContext): Promise { ) ); rules = rows.map((r) => r.alertRules); + } else if (context.resourceId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertResources, + eq(alertResources.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertResources.resourceId, context.resourceId), + isNull(alertResources.alertRuleId) + ) + ) + ); + rules = rows.map((r) => r.alertRules); } else { rules = []; } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index afadfed62..7a31d47b1 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -80,6 +80,12 @@ function buildSubject(context: AlertContext): string { return "[Alert] Health Check Failing"; case "health_check_toggle": return "[Alert] Health Check Toggled"; + case "resource_healthy": + return "[Alert] Resource Healthy"; + case "resource_unhealthy": + return "[Alert] Resource Unhealthy"; + case "resource_toggle": + return "[Alert] Resource Status Changed"; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index 45f45c035..0679b7ece 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -21,7 +21,10 @@ export type AlertEventType = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; // --------------------------------------------------------------------------- // Webhook authentication config (stored as encrypted JSON in the DB) @@ -60,6 +63,8 @@ export interface AlertContext { siteId?: number; /** Set for health_check_* events */ healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; } diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index eab6d674a..b42750b87 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -37,6 +38,11 @@ export const HC_EVENT_TYPES = [ "health_check_unhealthy", "health_check_toggle" ] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" +] as const; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -53,7 +59,8 @@ const bodySchema = z name: z.string().nonempty(), eventType: z.enum([ ...HC_EVENT_TYPES, - ...SITE_EVENT_TYPES + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), @@ -63,6 +70,10 @@ const bodySchema = z .array(z.number().int().positive()) .optional() .default([]), + resourceIds: z + .array(z.number().int().positive()) + .optional() + .default([]), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), roleIds: z.array(z.number()).optional().default([]), @@ -77,6 +88,9 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); if (isSiteEvent && val.siteIds.length === 0) { ctx.addIssue({ @@ -110,6 +124,46 @@ const bodySchema = z path: ["siteIds"] }); } + + if (isResourceEvent && val.resourceIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types", + path: ["resourceIds"] + }); + } + + if (isResourceEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for site event types", + path: ["resourceIds"] + }); + } + + if (isHcEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for health check event types", + path: ["resourceIds"] + }); + } }); export type CreateAlertRuleResponse = { @@ -169,6 +223,7 @@ export async function createAlertRule( cooldownSeconds, siteIds, healthCheckIds, + resourceIds, userIds, roleIds, emails, @@ -210,6 +265,16 @@ export async function createAlertRule( ); } + // Insert resource associations + if (resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId: rule.alertRuleId, + resourceId + })) + ); + } + // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index b9c5912a9..06a97e880 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { WebhookAlertConfig } from "@server/lib/alerts/types"; +import { WebhookAlertConfig } from "#private/lib/alerts/types"; const paramsSchema = z .object({ @@ -50,7 +51,10 @@ export type GetAlertRuleResponse = { | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; @@ -58,6 +62,7 @@ export type GetAlertRuleResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -130,6 +135,12 @@ export async function getAlertRule( .from(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + // Fetch resource associations + const resourceRows = await db + .select() + .from(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + // Resolve the single email action row for this rule, then collect all // recipients into a flat list. The emailAction pivot row is an internal // implementation detail and is not surfaced to callers. @@ -177,6 +188,7 @@ export async function getAlertRule( updatedAt: rule.updatedAt, siteIds: siteRows.map((r) => r.siteId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), + resourceIds: resourceRows.map((r) => r.resourceId), recipients, webhookActions: webhooks.map((w) => { let parsedConfig: WebhookAlertConfig | null = null; diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index e5e0053c9..7a6c11766 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules, alertSites, alertHealthChecks } from "@server/db"; +import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -55,6 +55,7 @@ export type ListAlertRulesResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }[]; pagination: { total: number; @@ -138,6 +139,14 @@ export async function listAlertRules( ) : []; + const resourceRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertResources) + .where(inArray(alertResources.alertRuleId, ruleIds)) + : []; + // Index by alertRuleId for O(1) lookup when building the response const sitesByRule = new Map(); for (const row of siteRows) { @@ -153,6 +162,13 @@ export async function listAlertRules( healthChecksByRule.set(row.alertRuleId, existing); } + const resourcesByRule = new Map(); + for (const row of resourceRows) { + const existing = resourcesByRule.get(row.alertRuleId) ?? []; + existing.push(row.resourceId); + resourcesByRule.set(row.alertRuleId, existing); + } + return response(res, { data: { alertRules: list.map((rule) => ({ @@ -167,7 +183,8 @@ export async function listAlertRules( updatedAt: rule.updatedAt, siteIds: sitesByRule.get(rule.alertRuleId) ?? [], healthCheckIds: - healthChecksByRule.get(rule.alertRuleId) ?? [] + healthChecksByRule.get(rule.alertRuleId) ?? [], + resourceIds: resourcesByRule.get(rule.alertRuleId) ?? [] })), pagination: { total: count, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 0ad62647d..cd07c0a2e 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule"; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; const paramsSchema = z .object({ @@ -53,7 +54,8 @@ const bodySchema = z eventType: z .enum([ ...HC_EVENT_TYPES, - ...SITE_EVENT_TYPES + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]) .optional(), enabled: z.boolean().optional(), @@ -61,6 +63,7 @@ const bodySchema = z // Source join tables - if provided the full set is replaced siteIds: z.array(z.number().int().positive()).optional(), healthCheckIds: z.array(z.number().int().positive()).optional(), + resourceIds: z.array(z.number().int().positive()).optional(), // Recipient arrays - if any are provided the full recipient set is replaced userIds: z.array(z.string().nonempty()).optional(), roleIds: z.array(z.number()).optional(), @@ -77,6 +80,9 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { ctx.addIssue({ @@ -93,6 +99,22 @@ const bodySchema = z path: ["siteIds"] }); } + + if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } }); export type UpdateAlertRuleResponse = { @@ -168,6 +190,7 @@ export async function updateAlertRule( cooldownSeconds, siteIds, healthCheckIds, + resourceIds, userIds, roleIds, emails, @@ -227,6 +250,22 @@ export async function updateAlertRule( } } + // --- Full-replace resource associations if resourceIds was provided --- + if (resourceIds !== undefined) { + await db + .delete(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + + if (resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId, + resourceId + })) + ); + } + } + // --- Full-replace recipients if any recipient array was provided --- const recipientsProvided = userIds !== undefined || @@ -324,4 +363,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} +} \ No newline at end of file diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 74b8348fa..dcb0bfe79 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -41,6 +41,7 @@ type AlertRuleRow = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }; function ruleHref(orgId: string, ruleId: number) { @@ -53,10 +54,14 @@ function sourceSummary( ) { if ( rule.eventType === "site_online" || - rule.eventType === "site_offline" + rule.eventType === "site_offline" || + rule.eventType === "site_toggle" ) { return t("alertingSummarySites", { count: rule.siteIds.length }); } + if (rule.eventType.startsWith("resource_")) { + return t("alertingSummaryResources", { count: rule.resourceIds.length }); + } return t("alertingSummaryHealthChecks", { count: rule.healthCheckIds.length }); @@ -79,6 +84,12 @@ function triggerLabel( return t("alertingTriggerHcUnhealthy"); case "health_check_toggle": return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return rule.eventType; } diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 678ee3c8c..9ab5d7815 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -275,6 +275,93 @@ function HealthCheckMultiSelect({ ); } +function ResourceMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + ); + + const shown = useMemo(() => { + return resources; + }, [resources]); + + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + + const summary = + value.length === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { count: value.length }); + + return ( + + + + + + + + + + {t("alertingResourcesEmpty")} + + + {shown.map((r) => ( + toggle(r.resourceId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + export function ActionBlock({ orgId, index, @@ -895,6 +982,18 @@ export function AlertRuleSourceFields({ shouldValidate: true }); } + } else if (next === "resource") { + if ( + curTrigger !== "resource_healthy" && + curTrigger !== "resource_unhealthy" && + curTrigger !== "resource_toggle" + ) { + setValue( + "trigger", + "resource_unhealthy", + { shouldValidate: true } + ); + } } else if ( curTrigger !== "health_check_healthy" && curTrigger !== "health_check_unhealthy" && @@ -920,6 +1019,9 @@ export function AlertRuleSourceFields({ {t("alertingSourceHealthCheck")} + + {t("alertingSourceResource")} + @@ -942,6 +1044,22 @@ export function AlertRuleSourceFields({ )} /> + ) : sourceType === "resource" ? ( + ( + + {t("alertingPickResources")} + + + + )} + /> ) : ( + ) : sourceType === "resource" ? ( + <> + + {t("alertingTriggerResourceHealthy")} + + + {t("alertingTriggerResourceUnhealthy")} + + + {t("alertingTriggerResourceToggle")} + + ) : ( <> diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 094976b55..4dfd96863 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -82,6 +82,12 @@ function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) { } return t("alertingSummarySites", { count: v.siteIds.length }); } + if (v.sourceType === "resource") { + if (v.resourceIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummaryResources", { count: v.resourceIds.length }); + } if (v.healthCheckIds.length === 0) { return t("alertingNodeNotConfigured"); } @@ -102,6 +108,12 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { return t("alertingTriggerHcUnhealthy"); case "health_check_toggle": return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return v.trigger; } @@ -338,6 +350,8 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "siteIds" }) ?? []; const wHealthCheckIds = useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; + const wResourceIds = + useWatch({ control: form.control, name: "resourceIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? "site_offline"; @@ -351,6 +365,7 @@ export default function AlertRuleGraphEditor({ sourceType: wSourceType, siteIds: wSiteIds, healthCheckIds: wHealthCheckIds, + resourceIds: wResourceIds, trigger: wTrigger, actions: wActions }), @@ -360,6 +375,7 @@ export default function AlertRuleGraphEditor({ wSourceType, wSiteIds, wHealthCheckIds, + wResourceIds, wTrigger, wActions ] diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 38a75dc80..fd3b3004a 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -22,7 +22,10 @@ export type AlertTrigger = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; export type AlertRuleFormAction = | { @@ -46,9 +49,10 @@ export type AlertRuleFormAction = export type AlertRuleFormValues = { name: string; enabled: boolean; - sourceType: "site" | "health_check"; + sourceType: "site" | "health_check" | "resource"; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; }; @@ -65,10 +69,14 @@ export type AlertRuleApiPayload = { | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; userIds: string[]; roleIds: number[]; emails: string[]; @@ -92,6 +100,7 @@ export type AlertRuleApiResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -126,16 +135,20 @@ export function buildFormSchema(t: (k: string) => string) { .string() .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), - sourceType: z.enum(["site", "health_check"]), + sourceType: z.enum(["site", "health_check", "resource"]), siteIds: z.array(z.number()), healthCheckIds: z.array(z.number()), + resourceIds: z.array(z.number()), trigger: z.enum([ "site_online", "site_offline", "site_toggle", "health_check_healthy", "health_check_unhealthy", - "health_check_toggle" + "health_check_toggle", + "resource_healthy", + "resource_unhealthy", + "resource_toggle" ]), actions: z.array( z.discriminatedUnion("type", [ @@ -189,6 +202,16 @@ export function buildFormSchema(t: (k: string) => string) { path: ["healthCheckIds"] }); } + if ( + val.sourceType === "resource" && + val.resourceIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickResources"), + path: ["resourceIds"] + }); + } const siteTriggers: AlertTrigger[] = [ "site_online", "site_offline", @@ -199,6 +222,11 @@ export function buildFormSchema(t: (k: string) => string) { "health_check_unhealthy", "health_check_toggle" ]; + const resourceTriggers: AlertTrigger[] = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" + ]; if ( val.sourceType === "site" && !siteTriggers.includes(val.trigger) @@ -219,6 +247,16 @@ export function buildFormSchema(t: (k: string) => string) { path: ["trigger"] }); } + if ( + val.sourceType === "resource" && + !resourceTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerResource"), + path: ["trigger"] + }); + } val.actions.forEach((a, i) => { if (a.type === "notify") { if ( @@ -259,6 +297,7 @@ export function defaultFormValues(): AlertRuleFormValues { sourceType: "site", siteIds: [], healthCheckIds: [], + resourceIds: [], trigger: "site_offline", actions: [ { @@ -281,6 +320,8 @@ export function apiResponseToFormValues( const trigger = rule.eventType; const sourceType = rule.eventType.startsWith("site_") ? "site" + : rule.eventType.startsWith("resource_") + ? "resource" : "health_check"; // Collect notify recipients into a single notify action (if any) @@ -336,6 +377,7 @@ export function apiResponseToFormValues( sourceType, siteIds: rule.siteIds, healthCheckIds: rule.healthCheckIds, + resourceIds: rule.resourceIds ?? [], trigger: trigger as AlertTrigger, actions }; @@ -392,6 +434,7 @@ export function formValuesToApiPayload( enabled: values.enabled, siteIds: values.siteIds, healthCheckIds: values.healthCheckIds, + resourceIds: values.resourceIds, userIds: uniqueUserIds, roleIds: uniqueRoleIds, emails: uniqueEmails, From 335de04a4edd9addd3ce46d94094e04a2268c97b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 18:00:22 -0700 Subject: [PATCH 101/176] adjust theme --- messages/en-US.json | 2 +- .../settings/resources/proxy/create/page.tsx | 253 ++++++++++-------- src/app/globals.css | 40 ++- src/components/InternalResourceForm.tsx | 2 +- src/components/LayoutHeader.tsx | 2 +- src/components/LayoutSidebar.tsx | 8 +- src/components/OrgIdpTable.tsx | 17 -- src/components/OrgSelector.tsx | 6 +- src/components/SidebarNav.tsx | 16 +- 9 files changed, 180 insertions(+), 166 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a57c45e6e..26790b29b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2469,7 +2469,7 @@ "connectionLogs": "Network Logs", "connectionLogsDescription": "View network session logs handled by sites in this organization", "sidebarLogsConnection": "Network Logs", - "sidebarLogsStreaming": "Streaming", + "sidebarLogsStreaming": "Event Streaming", "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index f5c20d8cc..51bb9a861 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -776,9 +776,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -804,9 +810,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -1061,7 +1073,7 @@ export default function Page() { : null ); }} - cols={2} + cols={3} /> )} @@ -1118,28 +1130,30 @@ export default function Page() { - = 1 - } - onDomainChange={(res) => { - if (!res) return; + + = 1 + } + onDomainChange={(res) => { + if (!res) return; - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - console.log( - "Domain changed:", - res - ); - }} - /> + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + console.log( + "Domain changed:", + res + ); + }} + /> + ) : ( @@ -1155,98 +1169,101 @@ export default function Page() { -
- { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" - id="tcp-udp-settings-form" - > - ( - - - {t("protocol")} - - - - - )} - /> + + + { + if (e.key === "Enter") { + e.preventDefault(); // block default enter refresh + } + }} + className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" + id="tcp-udp-settings-form" + > + ( + + + {t( + "protocol" + )} + + + + + )} + /> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( + ( + + + {t( + "resourcePortNumber" + )} + + + - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - - + ) => + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + )} + /> + + +
)} diff --git a/src/app/globals.css b/src/app/globals.css index bbb165c28..64db322e3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ :root { --radius: 0.75rem; - --background: oklch(0.985 0 0); + --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823); @@ -22,30 +22,30 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.91 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.88 0.004 286.32); + --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); + --sidebar: #fafafa; --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.705 0.213 47.604); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent: #eaeaea; --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.213 47.604); } .dark { - --background: oklch(0.19 0.006 285.885); + --background: #0d0d0f; --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: #0d0d0f; --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: #0d0d0f; --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.6717 0.1946 41.93); --primary-foreground: oklch(0.98 0.016 73.684); @@ -57,7 +57,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 13%); + --border: oklch(1 0 0 / 18%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376); @@ -65,11 +65,11 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); + --sidebar: #040404; --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.646 0.222 41.116); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent: #131317; --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.646 0.222 41.116); @@ -110,6 +110,15 @@ --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); @@ -166,7 +175,9 @@ p { } @keyframes dot-pulse { - 0%, 80%, 100% { + 0%, + 80%, + 100% { opacity: 0.3; transform: scale(0.8); } @@ -189,7 +200,10 @@ p { /* Only apply custom viewport height on mobile */ @media (max-width: 767px) { .h-screen-safe { - height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */ + height: var( + --vh, + 100vh + ); /* Use CSS variable set by ViewportHeightFix on mobile */ } } } diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index a4a793753..7dc500468 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1176,7 +1176,7 @@ export function InternalResourceForm({ ) )} - + {t( "accessClientSelect" )} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index bef016853..29850f115 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { return (
-
+
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 27d8c2cd8..6e80ffd52 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -125,7 +125,7 @@ export function LayoutSidebar({ return (
@@ -154,7 +154,7 @@ export function LayoutSidebar({
{/* Fade gradient at bottom to indicate scrollable content */} -
+
{isSidebarCollapsed && ( @@ -206,7 +206,7 @@ export function LayoutSidebar({ setHasManualToggle(true); setSidebarStateCookie(false); }} - className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors" + className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors" aria-label={t("sidebarExpand")} > diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index 1fe61b2fc..bdbaafa27 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -187,23 +187,6 @@ export default function IdpTable({ idps, orgId }: Props) { }; const columns: ExtendedColumnDef[] = [ - { - accessorKey: "idpId", - friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } - }, { accessorKey: "name", friendlyName: t("name"), diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index fcbc700a2..5f77582f5 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -76,8 +76,8 @@ export function OrgSelector({ className={cn( "cursor-pointer transition-colors", isCollapsed - ? "w-full h-16 flex items-center justify-center hover:bg-muted" - : "w-full px-5 py-4 hover:bg-muted" + ? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" + : "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" )} > {isCollapsed ? ( @@ -172,7 +172,7 @@ export function OrgSelector({ + ) : ( + + ); + + return ( + <> + + +
+
+ Uptime + + Site availability over the last {days} days. + +
+ {alertButton} +
+
+ + + +
+ + + + + Create Email Alert + + Get notified by email when this{" "} + {siteId ? "site" : "resource"} goes offline or + comes back online. + + + +
+
+ + setName(e.target.value)} + placeholder="Alert name" + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + setUserTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allUsers} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + setRoleTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allRoles} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + setEmailTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+
+ + + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e664009a4..25f96602d 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -267,6 +267,28 @@ export const orgQueries = { } }), + alertRulesForSource: ({ + orgId, + siteId, + resourceId + }: { + orgId: string; + siteId?: number; + resourceId?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "ALERT_RULES", { siteId, resourceId }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) sp.set("resourceId", String(resourceId)); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); + return res.data.data.alertRules; + } + }), + standaloneHealthChecks: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const, From 49b3163bbe6608a05ef25ad22ee4bf1543659b19 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 18:14:30 -0700 Subject: [PATCH 103/176] Making form and lang better --- messages/en-US.json | 8 ++++--- server/private/lib/alerts/sendAlertEmail.ts | 4 ++-- .../alert-rule-editor/AlertRuleFields.tsx | 24 +++++++++---------- .../AlertRuleGraphEditor.tsx | 2 +- src/lib/alertRuleForm.ts | 2 +- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 80e1cb65f..8a9f864c4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1383,13 +1383,15 @@ "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", "alertingTriggerSiteOffline": "Site offline", - "alertingTriggerSiteToggle": "Site toggled", + "alertingTriggerSiteToggle": "Site status changes", "alertingTriggerHcHealthy": "Health check healthy", "alertingTriggerHcUnhealthy": "Health check unhealthy", - "alertingTriggerHcToggle": "Health check toggled", + "alertingTriggerHcToggle": "Health check status changes", "alertingTriggerResourceHealthy": "Resource healthy", "alertingTriggerResourceUnhealthy": "Resource unhealthy", - "alertingTriggerResourceToggle": "Resource toggled", + "alertingSearchHealthChecks": "Search health checks…", + "alertingHealthChecksEmpty": "No health checks available.", + "alertingTriggerResourceToggle": "Resource status changes", "alertingSourceResource": "Resource", "alertingSectionActions": "Actions", "alertingAddAction": "Add action", diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 7a31d47b1..634598158 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -73,13 +73,13 @@ function buildSubject(context: AlertContext): string { case "site_offline": return "[Alert] Site Offline"; case "site_toggle": - return "[Alert] Site Toggled"; + return "[Alert] Site Status Changed"; case "health_check_healthy": return "[Alert] Health Check Recovered"; case "health_check_unhealthy": return "[Alert] Health Check Failing"; case "health_check_toggle": - return "[Alert] Health Check Toggled"; + return "[Alert] Health Check Status Changed"; case "resource_healthy": return "[Alert] Resource Healthy"; case "resource_unhealthy": diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 9ab5d7815..b040530dd 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -978,7 +978,7 @@ export function AlertRuleSourceFields({ curTrigger !== "site_offline" && curTrigger !== "site_toggle" ) { - setValue("trigger", "site_offline", { + setValue("trigger", "site_toggle", { shouldValidate: true }); } @@ -990,7 +990,7 @@ export function AlertRuleSourceFields({ ) { setValue( "trigger", - "resource_unhealthy", + "resource_toggle", { shouldValidate: true } ); } @@ -1001,7 +1001,7 @@ export function AlertRuleSourceFields({ ) { setValue( "trigger", - "health_check_unhealthy", + "health_check_toggle", { shouldValidate: true } ); } @@ -1110,39 +1110,39 @@ export function AlertRuleTriggerFields({ {sourceType === "site" ? ( <> + + {t("alertingTriggerSiteToggle")} + {t("alertingTriggerSiteOnline")} {t("alertingTriggerSiteOffline")} - - {t("alertingTriggerSiteToggle")} - ) : sourceType === "resource" ? ( <> + + {t("alertingTriggerResourceToggle")} + {t("alertingTriggerResourceHealthy")} {t("alertingTriggerResourceUnhealthy")} - - {t("alertingTriggerResourceToggle")} - ) : ( <> + + {t("alertingTriggerHcToggle")} + {t("alertingTriggerHcHealthy")} {t("alertingTriggerHcUnhealthy")} - - {t("alertingTriggerHcToggle")} - )} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 4dfd96863..0b4ea2582 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -354,7 +354,7 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "resourceIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? - "site_offline"; + "site_toggle"; const wActions = useWatch({ control: form.control, name: "actions" }) ?? []; diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index fd3b3004a..4c07c14e2 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -298,7 +298,7 @@ export function defaultFormValues(): AlertRuleFormValues { siteIds: [], healthCheckIds: [], resourceIds: [], - trigger: "site_offline", + trigger: "site_toggle", actions: [ { type: "notify", From 3641969dd4dd6cc5393c92fd11dd7febee077104 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 18:23:43 -0700 Subject: [PATCH 104/176] Remove bruno --- bruno/API Keys/Create API Key.bru | 17 ------- bruno/API Keys/Delete API Key.bru | 11 ----- bruno/API Keys/List API Key Actions.bru | 11 ----- bruno/API Keys/List Org API Keys.bru | 11 ----- bruno/API Keys/List Root API Keys.bru | 11 ----- bruno/API Keys/Set API Key Actions.bru | 17 ------- bruno/API Keys/Set API Key Orgs.bru | 17 ------- bruno/API Keys/folder.bru | 3 -- bruno/Auth/2fa-disable.bru | 18 ------- bruno/Auth/2fa-enable.bru | 17 ------- bruno/Auth/2fa-request.bru | 17 ------- bruno/Auth/change-password.bru | 18 ------- bruno/Auth/login.bru | 18 ------- bruno/Auth/logout.bru | 11 ----- bruno/Auth/reset-password-request.bru | 17 ------- bruno/Auth/reset-password.bru | 19 -------- bruno/Auth/signup.bru | 18 ------- bruno/Auth/verify-email-request.bru | 11 ----- bruno/Auth/verify-email.bru | 17 ------- bruno/Auth/verify-user.bru | 15 ------ bruno/Clients/createClient.bru | 22 --------- bruno/Clients/pickClientDefaults.bru | 11 ----- bruno/IDP/Create OIDC Provider.bru | 22 --------- bruno/IDP/Generate OIDC URL.bru | 11 ----- bruno/IDP/folder.bru | 3 -- bruno/Internal/Traefik Config.bru | 11 ----- bruno/Internal/folder.bru | 3 -- bruno/Newt/Create Newt.bru | 11 ----- bruno/Newt/Get Token.bru | 18 ------- bruno/Olm/createOlm.bru | 15 ------ bruno/Olm/folder.bru | 8 ---- bruno/Orgs/Check Id.bru | 11 ----- bruno/Orgs/listOrgs.bru | 11 ----- .../Remote Exit Node/createRemoteExitNode.bru | 11 ----- bruno/Resources/listResourcesByOrg.bru | 11 ----- bruno/Resources/listResourcesBySite.bru | 16 ------- bruno/Sites/Get Site.bru | 11 ----- bruno/Sites/listSites.bru | 11 ----- bruno/Targets/listTargets.bru | 16 ------- bruno/Test.bru | 11 ----- bruno/Traefik/traefik-config.bru | 11 ----- bruno/Users/adminListUsers.bru | 11 ----- bruno/Users/adminRemoveUser.bru | 11 ----- bruno/Users/getUser.bru | 11 ----- bruno/bruno.json | 13 ----- src/components/AlertingRulesTable.tsx | 33 +++++++++++-- src/components/HealthChecksTable.tsx | 47 +++++++++++++++++-- src/lib/queries.ts | 38 +++++++++++---- 48 files changed, 102 insertions(+), 611 deletions(-) delete mode 100644 bruno/API Keys/Create API Key.bru delete mode 100644 bruno/API Keys/Delete API Key.bru delete mode 100644 bruno/API Keys/List API Key Actions.bru delete mode 100644 bruno/API Keys/List Org API Keys.bru delete mode 100644 bruno/API Keys/List Root API Keys.bru delete mode 100644 bruno/API Keys/Set API Key Actions.bru delete mode 100644 bruno/API Keys/Set API Key Orgs.bru delete mode 100644 bruno/API Keys/folder.bru delete mode 100644 bruno/Auth/2fa-disable.bru delete mode 100644 bruno/Auth/2fa-enable.bru delete mode 100644 bruno/Auth/2fa-request.bru delete mode 100644 bruno/Auth/change-password.bru delete mode 100644 bruno/Auth/login.bru delete mode 100644 bruno/Auth/logout.bru delete mode 100644 bruno/Auth/reset-password-request.bru delete mode 100644 bruno/Auth/reset-password.bru delete mode 100644 bruno/Auth/signup.bru delete mode 100644 bruno/Auth/verify-email-request.bru delete mode 100644 bruno/Auth/verify-email.bru delete mode 100644 bruno/Auth/verify-user.bru delete mode 100644 bruno/Clients/createClient.bru delete mode 100644 bruno/Clients/pickClientDefaults.bru delete mode 100644 bruno/IDP/Create OIDC Provider.bru delete mode 100644 bruno/IDP/Generate OIDC URL.bru delete mode 100644 bruno/IDP/folder.bru delete mode 100644 bruno/Internal/Traefik Config.bru delete mode 100644 bruno/Internal/folder.bru delete mode 100644 bruno/Newt/Create Newt.bru delete mode 100644 bruno/Newt/Get Token.bru delete mode 100644 bruno/Olm/createOlm.bru delete mode 100644 bruno/Olm/folder.bru delete mode 100644 bruno/Orgs/Check Id.bru delete mode 100644 bruno/Orgs/listOrgs.bru delete mode 100644 bruno/Remote Exit Node/createRemoteExitNode.bru delete mode 100644 bruno/Resources/listResourcesByOrg.bru delete mode 100644 bruno/Resources/listResourcesBySite.bru delete mode 100644 bruno/Sites/Get Site.bru delete mode 100644 bruno/Sites/listSites.bru delete mode 100644 bruno/Targets/listTargets.bru delete mode 100644 bruno/Test.bru delete mode 100644 bruno/Traefik/traefik-config.bru delete mode 100644 bruno/Users/adminListUsers.bru delete mode 100644 bruno/Users/adminRemoveUser.bru delete mode 100644 bruno/Users/getUser.bru delete mode 100644 bruno/bruno.json diff --git a/bruno/API Keys/Create API Key.bru b/bruno/API Keys/Create API Key.bru deleted file mode 100644 index 009b4b049..000000000 --- a/bruno/API Keys/Create API Key.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Create API Key - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/api-key - body: json - auth: inherit -} - -body:json { - { - "isRoot": true - } -} diff --git a/bruno/API Keys/Delete API Key.bru b/bruno/API Keys/Delete API Key.bru deleted file mode 100644 index 9285f7889..000000000 --- a/bruno/API Keys/Delete API Key.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Delete API Key - type: http - seq: 2 -} - -delete { - url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj - body: none - auth: inherit -} diff --git a/bruno/API Keys/List API Key Actions.bru b/bruno/API Keys/List API Key Actions.bru deleted file mode 100644 index ae5b721e1..000000000 --- a/bruno/API Keys/List API Key Actions.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List API Key Actions - type: http - seq: 6 -} - -get { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Org API Keys.bru b/bruno/API Keys/List Org API Keys.bru deleted file mode 100644 index 468e964b9..000000000 --- a/bruno/API Keys/List Org API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Org API Keys - type: http - seq: 4 -} - -get { - url: http://localhost:3000/api/v1/org/home-lab/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Root API Keys.bru b/bruno/API Keys/List Root API Keys.bru deleted file mode 100644 index 8ef31b68c..000000000 --- a/bruno/API Keys/List Root API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Root API Keys - type: http - seq: 3 -} - -get { - url: http://localhost:3000/api/v1/root/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/Set API Key Actions.bru b/bruno/API Keys/Set API Key Actions.bru deleted file mode 100644 index 54a35c438..000000000 --- a/bruno/API Keys/Set API Key Actions.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Actions - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: json - auth: inherit -} - -body:json { - { - "actionIds": ["listSites"] - } -} diff --git a/bruno/API Keys/Set API Key Orgs.bru b/bruno/API Keys/Set API Key Orgs.bru deleted file mode 100644 index 3f0676c5b..000000000 --- a/bruno/API Keys/Set API Key Orgs.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Orgs - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs - body: json - auth: inherit -} - -body:json { - { - "orgIds": ["home-lab"] - } -} diff --git a/bruno/API Keys/folder.bru b/bruno/API Keys/folder.bru deleted file mode 100644 index bb8cd5c73..000000000 --- a/bruno/API Keys/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: API Keys -} diff --git a/bruno/Auth/2fa-disable.bru b/bruno/Auth/2fa-disable.bru deleted file mode 100644 index c98539c73..000000000 --- a/bruno/Auth/2fa-disable.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: 2fa-disable - type: http - seq: 6 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/disable - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A", - "code": "377289" - } -} diff --git a/bruno/Auth/2fa-enable.bru b/bruno/Auth/2fa-enable.bru deleted file mode 100644 index a3a01d177..000000000 --- a/bruno/Auth/2fa-enable.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-enable - type: http - seq: 4 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/enable - body: json - auth: none -} - -body:json { - { - "code": "374138" - } -} diff --git a/bruno/Auth/2fa-request.bru b/bruno/Auth/2fa-request.bru deleted file mode 100644 index fcf0c9862..000000000 --- a/bruno/Auth/2fa-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-request - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/request - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A" - } -} diff --git a/bruno/Auth/change-password.bru b/bruno/Auth/change-password.bru deleted file mode 100644 index 7d1c707e5..000000000 --- a/bruno/Auth/change-password.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: change-password - type: http - seq: 9 -} - -post { - url: http://localhost:3000/api/v1/auth/change-password - body: json - auth: none -} - -body:json { - { - "oldPassword": "", - "newPassword": "" - } -} diff --git a/bruno/Auth/login.bru b/bruno/Auth/login.bru deleted file mode 100644 index 3825a2525..000000000 --- a/bruno/Auth/login.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: login - type: http - seq: 1 -} - -post { - url: http://localhost:3000/api/v1/auth/login - body: json - auth: none -} - -body:json { - { - "email": "admin@fosrl.io", - "password": "Password123!" - } -} diff --git a/bruno/Auth/logout.bru b/bruno/Auth/logout.bru deleted file mode 100644 index 623cd47fe..000000000 --- a/bruno/Auth/logout.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: logout - type: http - seq: 3 -} - -post { - url: http://localhost:4000/api/v1/auth/logout - body: none - auth: none -} diff --git a/bruno/Auth/reset-password-request.bru b/bruno/Auth/reset-password-request.bru deleted file mode 100644 index 29c3b89d1..000000000 --- a/bruno/Auth/reset-password-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: reset-password-request - type: http - seq: 10 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password/request - body: json - auth: none -} - -body:json { - { - "email": "milo@pangolin.net" - } -} diff --git a/bruno/Auth/reset-password.bru b/bruno/Auth/reset-password.bru deleted file mode 100644 index 8d567b164..000000000 --- a/bruno/Auth/reset-password.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: reset-password - type: http - seq: 11 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password - body: json - auth: none -} - -body:json { - { - "token": "3uhsbom72dwdhboctwrtntyd6jrlg4jtf5oaxy4k", - "newPassword": "aaaaa-1A", - "code": "6irqCGR3" - } -} diff --git a/bruno/Auth/signup.bru b/bruno/Auth/signup.bru deleted file mode 100644 index bec59235e..000000000 --- a/bruno/Auth/signup.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: signup - type: http - seq: 2 -} - -put { - url: http://localhost:3000/api/v1/auth/signup - body: json - auth: none -} - -body:json { - { - "email": "numbat@pangolin.net", - "password": "Password123!" - } -} diff --git a/bruno/Auth/verify-email-request.bru b/bruno/Auth/verify-email-request.bru deleted file mode 100644 index 72189d1b2..000000000 --- a/bruno/Auth/verify-email-request.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: verify-email-request - type: http - seq: 8 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email/request - body: none - auth: none -} diff --git a/bruno/Auth/verify-email.bru b/bruno/Auth/verify-email.bru deleted file mode 100644 index a06a7108c..000000000 --- a/bruno/Auth/verify-email.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: verify-email - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email - body: json - auth: none -} - -body:json { - { - "code": "50317187" - } -} diff --git a/bruno/Auth/verify-user.bru b/bruno/Auth/verify-user.bru deleted file mode 100644 index 38955449d..000000000 --- a/bruno/Auth/verify-user.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: verify-user - type: http - seq: 4 -} - -get { - url: http://localhost:3001/api/v1/badger/verify-user?sessionId=mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e - body: none - auth: none -} - -params:query { - sessionId: mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e -} diff --git a/bruno/Clients/createClient.bru b/bruno/Clients/createClient.bru deleted file mode 100644 index 7577bb280..000000000 --- a/bruno/Clients/createClient.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: createClient - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/site/1/client - body: json - auth: none -} - -body:json { - { - "siteId": 1, - "name": "test", - "type": "olm", - "subnet": "100.90.129.4/30", - "olmId": "029yzunhx6nh3y5", - "secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6" - } -} diff --git a/bruno/Clients/pickClientDefaults.bru b/bruno/Clients/pickClientDefaults.bru deleted file mode 100644 index 61509c112..000000000 --- a/bruno/Clients/pickClientDefaults.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: pickClientDefaults - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/pick-client-defaults - body: none - auth: none -} diff --git a/bruno/IDP/Create OIDC Provider.bru b/bruno/IDP/Create OIDC Provider.bru deleted file mode 100644 index 23e807cf9..000000000 --- a/bruno/IDP/Create OIDC Provider.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Create OIDC Provider - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/org/home-lab/idp/oidc - body: json - auth: inherit -} - -body:json { - { - "clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys", - "clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3", - "authUrl": "http://localhost:9000/application/o/authorize/", - "tokenUrl": "http://localhost:9000/application/o/token/", - "scopes": ["email", "openid", "profile"], - "userIdentifier": "email" - } -} diff --git a/bruno/IDP/Generate OIDC URL.bru b/bruno/IDP/Generate OIDC URL.bru deleted file mode 100644 index 90443096f..000000000 --- a/bruno/IDP/Generate OIDC URL.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Generate OIDC URL - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/IDP/folder.bru b/bruno/IDP/folder.bru deleted file mode 100644 index fc1369159..000000000 --- a/bruno/IDP/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: IDP -} diff --git a/bruno/Internal/Traefik Config.bru b/bruno/Internal/Traefik Config.bru deleted file mode 100644 index 9fc1c1dcb..000000000 --- a/bruno/Internal/Traefik Config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Traefik Config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: inherit -} diff --git a/bruno/Internal/folder.bru b/bruno/Internal/folder.bru deleted file mode 100644 index 702931ec4..000000000 --- a/bruno/Internal/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Internal -} diff --git a/bruno/Newt/Create Newt.bru b/bruno/Newt/Create Newt.bru deleted file mode 100644 index 56baf89bd..000000000 --- a/bruno/Newt/Create Newt.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Create Newt - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/newt - body: none - auth: none -} diff --git a/bruno/Newt/Get Token.bru b/bruno/Newt/Get Token.bru deleted file mode 100644 index 93d91cc5d..000000000 --- a/bruno/Newt/Get Token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: Get Token - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/auth/newt/get-token - body: json - auth: none -} - -body:json { - { - "newtId": "o0d4rdxq3stnz7b", - "secret": "sy7l09fnaesd03iwrfp9m3qf0ryn19g0zf3dqieaazb4k7vk" - } -} diff --git a/bruno/Olm/createOlm.bru b/bruno/Olm/createOlm.bru deleted file mode 100644 index ca755dea8..000000000 --- a/bruno/Olm/createOlm.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: createOlm - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/olm - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/bruno/Olm/folder.bru b/bruno/Olm/folder.bru deleted file mode 100644 index d245e6d1c..000000000 --- a/bruno/Olm/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: Olm - seq: 15 -} - -auth { - mode: inherit -} diff --git a/bruno/Orgs/Check Id.bru b/bruno/Orgs/Check Id.bru deleted file mode 100644 index 17b63953c..000000000 --- a/bruno/Orgs/Check Id.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Check Id - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/checkId - body: none - auth: none -} diff --git a/bruno/Orgs/listOrgs.bru b/bruno/Orgs/listOrgs.bru deleted file mode 100644 index 89c34d0cb..000000000 --- a/bruno/Orgs/listOrgs.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listOrgs - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Remote Exit Node/createRemoteExitNode.bru b/bruno/Remote Exit Node/createRemoteExitNode.bru deleted file mode 100644 index 1c749a311..000000000 --- a/bruno/Remote Exit Node/createRemoteExitNode.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: createRemoteExitNode - type: http - seq: 1 -} - -put { - url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesByOrg.bru b/bruno/Resources/listResourcesByOrg.bru deleted file mode 100644 index 6efce1b20..000000000 --- a/bruno/Resources/listResourcesByOrg.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listResourcesByOrg - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesBySite.bru b/bruno/Resources/listResourcesBySite.bru deleted file mode 100644 index 81c9cf99b..000000000 --- a/bruno/Resources/listResourcesBySite.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listResourcesBySite - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/resources?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Sites/Get Site.bru b/bruno/Sites/Get Site.bru deleted file mode 100644 index fc2f7e62b..000000000 --- a/bruno/Sites/Get Site.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Get Site - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/test/sites/mexican-mole-lizard-windy - body: none - auth: none -} diff --git a/bruno/Sites/listSites.bru b/bruno/Sites/listSites.bru deleted file mode 100644 index b7912330a..000000000 --- a/bruno/Sites/listSites.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listSites - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Targets/listTargets.bru b/bruno/Targets/listTargets.bru deleted file mode 100644 index 7981eb453..000000000 --- a/bruno/Targets/listTargets.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listTargets - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/resource/web.main.localhost/targets?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Test.bru b/bruno/Test.bru deleted file mode 100644 index 16286ec8c..000000000 --- a/bruno/Test.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Test - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/Traefik/traefik-config.bru b/bruno/Traefik/traefik-config.bru deleted file mode 100644 index a50b7aa15..000000000 --- a/bruno/Traefik/traefik-config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: traefik-config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: none -} diff --git a/bruno/Users/adminListUsers.bru b/bruno/Users/adminListUsers.bru deleted file mode 100644 index cdc410956..000000000 --- a/bruno/Users/adminListUsers.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminListUsers - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/users - body: none - auth: none -} diff --git a/bruno/Users/adminRemoveUser.bru b/bruno/Users/adminRemoveUser.bru deleted file mode 100644 index 9e9f35079..000000000 --- a/bruno/Users/adminRemoveUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminRemoveUser - type: http - seq: 3 -} - -delete { - url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4 - body: none - auth: none -} diff --git a/bruno/Users/getUser.bru b/bruno/Users/getUser.bru deleted file mode 100644 index d86372527..000000000 --- a/bruno/Users/getUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: getUser - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/bruno.json b/bruno/bruno.json deleted file mode 100644 index f19d936a8..000000000 --- a/bruno/bruno.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "1", - "name": "Pangolin", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ], - "presets": { - "requestType": "http", - "requestUrl": "http://localhost:3000/api/v1" - } -} \ No newline at end of file diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index dcb0bfe79..1b34ae2bd 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -24,6 +24,8 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import type { DataTablePaginationState } from "@app/components/ui/data-table"; type AlertingRulesTableProps = { orgId: string; @@ -106,16 +108,39 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(() => { + if (typeof window === "undefined") return 20; + try { + const stored = localStorage.getItem("Org-alerting-rules-table-size"); + if (stored) { + const parsed = parseInt(stored, 10); + if (parsed > 0 && parsed <= 1000) return parsed; + } + } catch {} + return 20; + }); const { - data: rows = [], + data, isLoading, refetch, isRefetching - } = useQuery(orgQueries.alertRules({ orgId })); + } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize })); + + const rows = data?.alertRules ?? []; + const total = data?.pagination.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount }; + + const handlePaginationChange = (newState: PaginationState) => { + setPageIndex(newState.pageIndex); + setPageSize(newState.pageSize); + }; const invalidate = () => - queryClient.invalidateQueries(orgQueries.alertRules({ orgId })); + queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] }); const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { setTogglingId(rule.alertRuleId); @@ -296,6 +321,8 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" + pagination={paginationState} + onPaginationChange={handlePaginationChange} /> ); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index a09af2da1..a63585532 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -24,6 +24,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import type { PaginationState } from "@tanstack/react-table"; +import type { DataTablePaginationState } from "@app/components/ui/data-table"; import Link from "next/link"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -75,21 +77,54 @@ export default function HealthChecksTable({ const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(() => { + if (typeof window === "undefined") return 20; + try { + const stored = localStorage.getItem( + "Org-standalone-health-checks-table-size" + ); + if (stored) { + const parsed = parseInt(stored, 10); + if (parsed > 0 && parsed <= 1000) return parsed; + } + } catch {} + return 20; + }); const { - data: rows = [], + data, isLoading, refetch, isRefetching } = useQuery({ - ...orgQueries.standaloneHealthChecks({ orgId }), + ...orgQueries.standaloneHealthChecks({ + orgId, + limit: pageSize, + offset: pageIndex * pageSize + }), refetchInterval: 10_000 }); + const rows = data?.healthChecks ?? []; + const total = data?.pagination.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const paginationState: DataTablePaginationState = { + pageIndex, + pageSize, + pageCount + }; + + const handlePaginationChange = (newState: PaginationState) => { + setPageIndex(newState.pageIndex); + setPageSize(newState.pageSize); + }; + const invalidate = () => - queryClient.invalidateQueries( - orgQueries.standaloneHealthChecks({ orgId }) - ); + queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] + }); const handleToggleEnabled = async ( row: HealthCheckRow, @@ -356,6 +391,8 @@ export default function HealthChecksTable({ enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" + pagination={paginationState} + onPaginationChange={handlePaginationChange} /> ); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 25f96602d..45e62b515 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -256,14 +256,25 @@ export const orgQueries = { } }), - alertRules: ({ orgId }: { orgId: string }) => + alertRules: ({ + orgId, + limit = 20, + offset = 0 + }: { + orgId: string; + limit?: number; + offset?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "ALERT_RULES"] as const, + queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset }] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/alert-rules`, { signal }); - return res.data.data.alertRules; + >(`/org/${orgId}/alert-rules?limit=${limit}&offset=${offset}`, { signal }); + return { + alertRules: res.data.data.alertRules, + pagination: res.data.data.pagination + }; } }), @@ -289,9 +300,17 @@ export const orgQueries = { } }), - standaloneHealthChecks: ({ orgId }: { orgId: string }) => + standaloneHealthChecks: ({ + orgId, + limit = 20, + offset = 0 + }: { + orgId: string; + limit?: number; + offset?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const, + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset }] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse<{ @@ -325,8 +344,11 @@ export const orgQueries = { offset: number; }; }> - >(`/org/${orgId}/health-checks`, { signal }); - return res.data.data.healthChecks; + >(`/org/${orgId}/health-checks?limit=${limit}&offset=${offset}`, { signal }); + return { + healthChecks: res.data.data.healthChecks, + pagination: res.data.data.pagination + }; } }), siteStatusHistory: ({ From c8d560d78fd6487df98c6899a1a9292bfe5f8b41 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 18:25:04 -0700 Subject: [PATCH 105/176] Reorder sidebar --- src/app/navigation.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 59a75472b..24dc02a19 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -213,9 +213,9 @@ export const orgNavSections = ( icon: , items: [ { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: }, { title: "sidebarProvisioning", @@ -228,9 +228,9 @@ export const orgNavSections = ( icon: }, { - title: "sidebarAlerting", - href: "/{orgId}/settings/alerting", - icon: + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: } ] }, From f938e9c3c0d9930730510527d3043ad97febd83d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 20:05:59 -0700 Subject: [PATCH 106/176] Paginate the tables with queries --- .../routers/alertRule/listAlertRules.ts | 8 ++- .../routers/healthChecks/listHealthChecks.ts | 13 +++-- src/components/AlertingRulesTable.tsx | 49 ++++++++++------- src/components/HealthChecksTable.tsx | 52 ++++++++++++------- src/lib/queries.ts | 24 ++++++--- 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 9d40817fb..601ab0fa3 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -21,7 +21,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, like, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -40,6 +40,7 @@ const querySchema = z.strictObject({ .default("0") .transform(Number) .pipe(z.number().int().nonnegative()), + query: z.string().optional(), siteId: z .string() .optional() @@ -112,7 +113,7 @@ export async function listAlertRules( ) ); } - const { limit, offset, siteId, resourceId } = parsedQuery.data; + const { limit, offset, query, siteId, resourceId } = parsedQuery.data; // Resolve siteId filter → matching alertRuleIds let siteFilterRuleIds: number[] | null = null; @@ -160,6 +161,9 @@ export async function listAlertRules( const whereClause = and( eq(alertRules.orgId, orgId), + query + ? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`) + : undefined, siteFilterRuleIds !== null ? inArray(alertRules.alertRuleId, siteFilterRuleIds) : undefined, diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index b2e6949a1..e266441b2 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -17,7 +17,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, like, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; @@ -39,7 +39,8 @@ const querySchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + query: z.string().optional() }); registry.registerPath({ @@ -80,10 +81,16 @@ export async function listHealthChecks( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, query } = parsedQuery.data; const whereClause = and( eq(targetHealthCheck.orgId, orgId), + query + ? like( + sql`LOWER(${targetHealthCheck.name})`, + `%${query.toLowerCase()}%` + ) + : undefined ); const list = await db diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 1b34ae2bd..6c9bc7f0a 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -13,6 +13,7 @@ import { import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; @@ -26,6 +27,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; +import { useDebouncedCallback } from "use-debounce"; type AlertingRulesTableProps = { orgId: string; @@ -105,28 +107,27 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(() => { - if (typeof window === "undefined") return 20; - try { - const stored = localStorage.getItem("Org-alerting-rules-table-size"); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) return parsed; - } - } catch {} - return 20; - }); + + const page = Math.max(1, Number(searchParams.get("page") ?? 1)); + const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; const { data, isLoading, refetch, isRefetching - } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize })); + } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query })); const rows = data?.alertRules ?? []; const total = data?.pagination.total ?? 0; @@ -135,10 +136,21 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount }; const handlePaginationChange = (newState: PaginationState) => { - setPageIndex(newState.pageIndex); - setPageSize(newState.pageSize); + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); }; + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] }); @@ -308,15 +320,16 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { router.push(`/${orgId}/settings/alerting/create`); }} onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading} + isRefreshing={isRefetching || isLoading || isFiltering} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index a63585532..e0e57ff09 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -26,6 +26,8 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; import Link from "next/link"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -74,23 +76,20 @@ export default function HealthChecksTable({ const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); const [credenzaOpen, setCredenzaOpen] = useState(false); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(() => { - if (typeof window === "undefined") return 20; - try { - const stored = localStorage.getItem( - "Org-standalone-health-checks-table-size" - ); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) return parsed; - } - } catch {} - return 20; - }); + + const page = Math.max(1, Number(searchParams.get("page") ?? 1)); + const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; const { data, @@ -101,7 +100,8 @@ export default function HealthChecksTable({ ...orgQueries.standaloneHealthChecks({ orgId, limit: pageSize, - offset: pageIndex * pageSize + offset: pageIndex * pageSize, + query }), refetchInterval: 10_000 }); @@ -117,10 +117,21 @@ export default function HealthChecksTable({ }; const handlePaginationChange = (newState: PaginationState) => { - setPageIndex(newState.pageIndex); - setPageSize(newState.pageSize); + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); }; + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] @@ -376,17 +387,18 @@ export default function HealthChecksTable({ { setSelected(null); setCredenzaOpen(true); }} addButtonDisabled={!isPaid} onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading} + isRefreshing={isRefetching || isLoading || isFiltering} addButtonText={t("standaloneHcAddButton")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 45e62b515..228c37540 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -259,18 +259,24 @@ export const orgQueries = { alertRules: ({ orgId, limit = 20, - offset = 0 + offset = 0, + query }: { orgId: string; limit?: number; offset?: number; + query?: string; }) => queryOptions({ - queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset }] as const, + queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + sp.set("limit", String(limit)); + sp.set("offset", String(offset)); + if (query) sp.set("query", query); const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/alert-rules?limit=${limit}&offset=${offset}`, { signal }); + >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); return { alertRules: res.data.data.alertRules, pagination: res.data.data.pagination @@ -303,15 +309,21 @@ export const orgQueries = { standaloneHealthChecks: ({ orgId, limit = 20, - offset = 0 + offset = 0, + query }: { orgId: string; limit?: number; offset?: number; + query?: string; }) => queryOptions({ - queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset }] as const, + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset, query }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + sp.set("limit", String(limit)); + sp.set("offset", String(offset)); + if (query) sp.set("query", query); const res = await meta!.api.get< AxiosResponse<{ healthChecks: { @@ -344,7 +356,7 @@ export const orgQueries = { offset: number; }; }> - >(`/org/${orgId}/health-checks?limit=${limit}&offset=${offset}`, { signal }); + >(`/org/${orgId}/health-checks?${sp.toString()}`, { signal }); return { healthChecks: res.data.data.healthChecks, pagination: res.data.data.pagination From d7a9e1a5178c100dc261b15ef458f08940557e15 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 20:14:25 -0700 Subject: [PATCH 107/176] Polish the create and link to table --- .../settings/resources/proxy/[niceId]/general/page.tsx | 1 + .../[orgId]/settings/sites/[niceId]/general/page.tsx | 1 + src/components/AlertingRulesTable.tsx | 6 ++++-- src/components/UptimeAlertSection.tsx | 8 +++++--- src/lib/queries.ts | 10 ++++++++-- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index be94fb10a..f7de28c56 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -583,6 +583,7 @@ export default function GeneralForm() { )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 8797455ef..f4c4d72ef 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -117,6 +117,7 @@ export default function GeneralPage() { )} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 6c9bc7f0a..ea67b6b73 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -31,6 +31,8 @@ import { useDebouncedCallback } from "use-debounce"; type AlertingRulesTableProps = { orgId: string; + siteId?: number; + resourceId?: number; }; type AlertRuleRow = { @@ -99,7 +101,7 @@ function triggerLabel( } } -export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { +export default function AlertingRulesTable({ orgId, siteId, resourceId }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -127,7 +129,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { isLoading, refetch, isRefetching - } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query })); + } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query, siteId, resourceId })); const rows = data?.alertRules ?? []; const total = data?.pagination.total ?? 0; diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index c7f0cc184..72c9fa78f 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -35,6 +35,7 @@ import { orgQueries } from "@app/lib/queries"; interface UptimeAlertSectionProps { orgId: string; siteId?: number; + startingName?: string; resourceId?: number; days?: number; } @@ -42,6 +43,7 @@ interface UptimeAlertSectionProps { export default function UptimeAlertSection({ orgId, siteId, + startingName, resourceId, days = 90 }: UptimeAlertSectionProps) { @@ -49,7 +51,7 @@ export default function UptimeAlertSection({ const queryClient = useQueryClient(); const [open, setOpen] = useState(false); - const [name, setName] = useState("Uptime Alert"); + const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`); const [userTags, setUserTags] = useState([]); const [roleTags, setRoleTags] = useState([]); const [emailTags, setEmailTags] = useState([]); @@ -156,7 +158,7 @@ export default function UptimeAlertSection({ const alertButton = alertRulesLoading ? null : hasRules ? (
) : ( - - setActiveTab(value as "available" | "archived") - } - className="w-full" + !d.archived).length})`, + href: "#available" + }, + { + title: `${t("archived") || "Archived"} (${devices.filter((d) => d.archived).length})`, + href: "#archived" + } + ]} > - - - {t("available") || "Available"} ( - { - devices.filter( - (d) => !d.archived - ).length - } - ) - - - {t("archived") || "Archived"} ( - { - devices.filter( - (d) => d.archived - ).length - } - ) - - - +
{devices.filter((d) => !d.archived) .length === 0 ? (
@@ -271,8 +257,8 @@ export default function ViewDevicesDialog({
)} - - +
+
{devices.filter((d) => d.archived) .length === 0 ? (
@@ -336,8 +322,8 @@ export default function ViewDevicesDialog({
)} - - +
+
)} From 34dc4c2d0730ca0b1b86a56996ea6a7ab6a90c78 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 20:41:38 -0700 Subject: [PATCH 109/176] remove tcp/udp text to reduce cloud confusion --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 26790b29b..836fa0a80 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -163,7 +163,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourcesBannerTitle": "Web-based Public Access", - "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", From b3bc70875b181ff2c4122d2ab6acdb0cef81b8fe Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 20:46:38 -0700 Subject: [PATCH 110/176] fix count on list domains endpoint --- messages/en-US.json | 2 +- server/routers/domain/listDomains.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 836fa0a80..2c8ac950b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -380,7 +380,7 @@ "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", "userAbount": "About User Management", - "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", + "userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.", "userServer": "Server Users", "userSearch": "Search server users...", "userErrorDelete": "Error deleting user", diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index 085acf0c6..94dddb1cf 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -103,7 +103,8 @@ export async function listDomains( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(domains); + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); return response(res, { data: { From 5e88862e290cc9c8b30bc8f417ffae3efab593c6 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 20:48:14 -0700 Subject: [PATCH 111/176] Support all resources,sites,health checks --- messages/en-US.json | 12 + .../routers/alertRule/createAlertRule.ts | 30 ++- .../routers/alertRule/updateAlertRule.ts | 30 ++- .../alert-rule-editor/AlertRuleFields.tsx | 217 ++++++++++++++---- src/lib/alertRuleForm.ts | 38 ++- 5 files changed, 266 insertions(+), 61 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 8a9f864c4..13f73a3ad 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1376,6 +1376,18 @@ "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", "alertingPickResources": "Resources", + "alertingAllSites": "All sites", + "alertingAllSitesDescription": "Alert fires for any site", + "alertingSpecificSites": "Specific sites", + "alertingSpecificSitesDescription": "Choose specific sites to watch", + "alertingAllHealthChecks": "All health checks", + "alertingAllHealthChecksDescription": "Alert fires for any health check", + "alertingSpecificHealthChecks": "Specific health checks", + "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", + "alertingAllResources": "All resources", + "alertingAllResourcesDescription": "Alert fires for any resource", + "alertingSpecificResources": "Specific resources", + "alertingSpecificResourcesDescription": "Choose specific resources to watch", "alertingSelectResources": "Select resources…", "alertingResourcesSelected": "{count} resources selected", "alertingResourcesEmpty": "No resources with targets in the first 10 results.", diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index b42750b87..ff7321c77 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -66,14 +66,17 @@ const bodySchema = z cooldownSeconds: z.number().int().nonnegative().optional().default(300), // Source join tables - which is required depends on eventType siteIds: z.array(z.number().int().positive()).optional().default([]), + allSites: z.boolean().optional().default(false), healthCheckIds: z .array(z.number().int().positive()) .optional() .default([]), + allHealthChecks: z.boolean().optional().default(false), resourceIds: z .array(z.number().int().positive()) .optional() .default([]), + allResources: z.boolean().optional().default(false), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), roleIds: z.array(z.number()).optional().default([]), @@ -92,19 +95,19 @@ const bodySchema = z val.eventType ); - if (isSiteEvent && val.siteIds.length === 0) { + if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one siteId is required for site event types", + message: "At least one siteId is required for site event types when allSites is false", path: ["siteIds"] }); } - if (isHcEvent && val.healthCheckIds.length === 0) { + if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "At least one healthCheckId is required for health check event types", + "At least one healthCheckId is required for health check event types when allHealthChecks is false", path: ["healthCheckIds"] }); } @@ -125,10 +128,10 @@ const bodySchema = z }); } - if (isResourceEvent && val.resourceIds.length === 0) { + if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one resourceId is required for resource event types", + message: "At least one resourceId is required for resource event types when allResources is false", path: ["resourceIds"] }); } @@ -222,8 +225,11 @@ export async function createAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, resourceIds, + allResources, userIds, roleIds, emails, @@ -245,8 +251,8 @@ export async function createAlertRule( }) .returning(); - // Insert site associations - if (siteIds.length > 0) { + // Insert site associations (skipped when allSites=true — empty junction = match all) + if (!allSites && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId: rule.alertRuleId, @@ -255,8 +261,8 @@ export async function createAlertRule( ); } - // Insert health check associations - if (healthCheckIds.length > 0) { + // Insert health check associations (skipped when allHealthChecks=true) + if (!allHealthChecks && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId: rule.alertRuleId, @@ -265,8 +271,8 @@ export async function createAlertRule( ); } - // Insert resource associations - if (resourceIds.length > 0) { + // Insert resource associations (skipped when allResources=true) + if (!allResources && resourceIds.length > 0) { await db.insert(alertResources).values( resourceIds.map((resourceId) => ({ alertRuleId: rule.alertRuleId, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index cd07c0a2e..4b2c2df87 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -33,6 +33,7 @@ import { and, eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; +import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode"; const paramsSchema = z .object({ @@ -62,8 +63,11 @@ const bodySchema = z cooldownSeconds: z.number().int().nonnegative().optional(), // Source join tables - if provided the full set is replaced siteIds: z.array(z.number().int().positive()).optional(), + allSites: z.boolean().optional(), healthCheckIds: z.array(z.number().int().positive()).optional(), + allHealthChecks: z.boolean().optional(), resourceIds: z.array(z.number().int().positive()).optional(), + allResources: z.boolean().optional(), // Recipient arrays - if any are provided the full recipient set is replaced userIds: z.array(z.string().nonempty()).optional(), roleIds: z.array(z.number()).optional(), @@ -84,6 +88,30 @@ const bodySchema = z val.eventType ); + if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one siteId is required for site event types when allSites is false", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one healthCheckId is required for health check event types when allHealthChecks is false", + path: ["healthCheckIds"] + }); + } + + if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } + if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -363,4 +391,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index b040530dd..2b57724cc 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -35,6 +35,7 @@ import { RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { StrategySelect } from "@app/components/StrategySelect"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { @@ -957,6 +958,58 @@ export function AlertRuleSourceFields({ const t = useTranslations(); const { setValue, getValues } = useFormContext(); const sourceType = useWatch({ control, name: "sourceType" }); + const allSites = useWatch({ control, name: "allSites" }); + const allHealthChecks = useWatch({ control, name: "allHealthChecks" }); + const allResources = useWatch({ control, name: "allResources" }); + + const siteStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllSites"), + description: t("alertingAllSitesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificSites"), + description: t("alertingSpecificSitesDescription") + } + ], + [t] + ); + + const healthCheckStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllHealthChecks"), + description: t("alertingAllHealthChecksDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificHealthChecks"), + description: t("alertingSpecificHealthChecksDescription") + } + ], + [t] + ); + + const resourceStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllResources"), + description: t("alertingAllResourcesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificResources"), + description: t("alertingSpecificResourcesDescription") + } + ], + [t] + ); + return (
{sourceType === "site" ? ( - ( - - {t("alertingPickSites")} - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("siteIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allSites && ( + ( + + + {t("alertingPickSites")} + + + + + )} + /> )} - /> + ) : sourceType === "resource" ? ( - ( - - {t("alertingPickResources")} - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("resourceIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allResources && ( + ( + + + {t("alertingPickResources")} + + + + + )} + /> )} - /> + ) : ( - ( - - - {t("alertingPickHealthChecks")} - - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("healthCheckIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allHealthChecks && ( + ( + + + {t("alertingPickHealthChecks")} + + + + + )} + /> )} - /> + )}
); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 4c07c14e2..f7f96e927 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -50,8 +50,11 @@ export type AlertRuleFormValues = { name: string; enabled: boolean; sourceType: "site" | "health_check" | "resource"; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; resourceIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; @@ -74,8 +77,11 @@ export type AlertRuleApiPayload = { | "resource_unhealthy" | "resource_toggle"; enabled: boolean; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; resourceIds: number[]; userIds: string[]; roleIds: number[]; @@ -136,8 +142,11 @@ export function buildFormSchema(t: (k: string) => string) { .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), sourceType: z.enum(["site", "health_check", "resource"]), + allSites: z.boolean(), siteIds: z.array(z.number()), + allHealthChecks: z.boolean(), healthCheckIds: z.array(z.number()), + allResources: z.boolean(), resourceIds: z.array(z.number()), trigger: z.enum([ "site_online", @@ -185,7 +194,11 @@ export function buildFormSchema(t: (k: string) => string) { path: ["actions"] }); } - if (val.sourceType === "site" && val.siteIds.length === 0) { + if ( + val.sourceType === "site" && + !val.allSites && + val.siteIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("alertingErrorPickSites"), @@ -194,6 +207,7 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "health_check" && + !val.allHealthChecks && val.healthCheckIds.length === 0 ) { ctx.addIssue({ @@ -204,6 +218,7 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "resource" && + !val.allResources && val.resourceIds.length === 0 ) { ctx.addIssue({ @@ -295,8 +310,11 @@ export function defaultFormValues(): AlertRuleFormValues { name: "", enabled: true, sourceType: "site", + allSites: true, siteIds: [], + allHealthChecks: true, healthCheckIds: [], + allResources: true, resourceIds: [], trigger: "site_toggle", actions: [ @@ -371,12 +389,21 @@ export function apiResponseToFormValues( }); } + const allSites = sourceType === "site" && rule.siteIds.length === 0; + const allHealthChecks = + sourceType === "health_check" && rule.healthCheckIds.length === 0; + const allResources = + sourceType === "resource" && (rule.resourceIds?.length ?? 0) === 0; + return { name: rule.name, enabled: rule.enabled, sourceType, + allSites, siteIds: rule.siteIds, + allHealthChecks, healthCheckIds: rule.healthCheckIds, + allResources, resourceIds: rule.resourceIds ?? [], trigger: trigger as AlertTrigger, actions @@ -432,9 +459,12 @@ export function formValuesToApiPayload( name: values.name.trim(), eventType, enabled: values.enabled, - siteIds: values.siteIds, - healthCheckIds: values.healthCheckIds, - resourceIds: values.resourceIds, + allSites: values.allSites, + siteIds: values.allSites ? [] : values.siteIds, + allHealthChecks: values.allHealthChecks, + healthCheckIds: values.allHealthChecks ? [] : values.healthCheckIds, + allResources: values.allResources, + resourceIds: values.allResources ? [] : values.resourceIds, userIds: uniqueUserIds, roleIds: uniqueRoleIds, emails: uniqueEmails, From 7f0264dec3e5aeab395988bf3b313fdcc67c2689 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 20:48:50 -0700 Subject: [PATCH 112/176] fix collapsed sidebar bottom padding issue --- src/components/LayoutSidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 6e80ffd52..bde9e37a8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -220,7 +220,12 @@ export function LayoutSidebar({
)} -
+
{canShowProductUpdates ? (
From 725603101b892fd0377978a69d37d26d3d1236e4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 21:00:28 -0700 Subject: [PATCH 113/176] Support the all types in the schema and engine --- server/db/pg/schema/privateSchema.ts | 3 ++ server/db/sqlite/schema/privateSchema.ts | 3 ++ server/private/lib/alerts/processAlerts.ts | 50 +++++++++++++------ .../routers/alertRule/createAlertRule.ts | 3 ++ .../routers/alertRule/updateAlertRule.ts | 25 +++++++--- server/routers/target/createTarget.ts | 1 + 6 files changed, 62 insertions(+), 23 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index f9126f291..1f8085c35 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -488,6 +488,9 @@ export const alertRules = pgTable("alertRules", { // Nullable depending on eventType enabled: boolean("enabled").notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: boolean("allSites").notNull().default(false), + allHealthChecks: boolean("allHealthChecks").notNull().default(false), + allResources: boolean("allResources").notNull().default(false), lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f903d2955..a3168360f 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -479,6 +479,9 @@ export const alertRules = sqliteTable("alertRules", { .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), + allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), lastTriggeredAt: integer("lastTriggeredAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 2ec2eee98..5e098a1f2 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@server/db"; import { alertRules, @@ -49,11 +49,9 @@ export async function processAlerts(context: AlertContext): Promise { // ------------------------------------------------------------------ // 1. Find matching alert rules // ------------------------------------------------------------------ - // Rules with no junction-table entries match ALL sites / health checks. - // Rules with junction entries match only those specific IDs. - // We implement this with a LEFT JOIN: a NULL join result means the rule - // has no scope restrictions (match all); a non-NULL result that satisfies - // the id equality filter means an explicit match. + // Rules with allSites / allHealthChecks / allResources set to true match + // ANY event of that type. Rules without these flags set match only the + // specific IDs listed in the junction tables. const baseConditions = and( eq(alertRules.orgId, context.orgId), eq(alertRules.eventType, context.eventType), @@ -74,12 +72,20 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertSites.siteId, context.siteId), - isNull(alertSites.alertRuleId) + eq(alertRules.allSites, true), + eq(alertSites.siteId, context.siteId) ) ) ); - rules = rows.map((r) => r.alertRules); + // Deduplicate in case a rule matched on multiple junction rows + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else if (context.healthCheckId != null) { const rows = await db .select() @@ -92,12 +98,19 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertHealthChecks.healthCheckId, context.healthCheckId), - isNull(alertHealthChecks.alertRuleId) + eq(alertRules.allHealthChecks, true), + eq(alertHealthChecks.healthCheckId, context.healthCheckId) ) ) ); - rules = rows.map((r) => r.alertRules); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else if (context.resourceId != null) { const rows = await db .select() @@ -110,12 +123,19 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertResources.resourceId, context.resourceId), - isNull(alertResources.alertRuleId) + eq(alertRules.allResources, true), + eq(alertResources.resourceId, context.resourceId) ) ) ); - rules = rows.map((r) => r.alertRules); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else { rules = []; } diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index ff7321c77..8a31327ab 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -246,6 +246,9 @@ export async function createAlertRule( eventType, enabled, cooldownSeconds, + allSites, + allHealthChecks, + allResources, createdAt: now, updatedAt: now }) diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 4b2c2df87..358661ac9 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -217,8 +217,11 @@ export async function updateAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, resourceIds, + allResources, userIds, roleIds, emails, @@ -233,8 +236,10 @@ export async function updateAlertRule( if (name !== undefined) updateData.name = name; if (eventType !== undefined) updateData.eventType = eventType; if (enabled !== undefined) updateData.enabled = enabled; - if (cooldownSeconds !== undefined) - updateData.cooldownSeconds = cooldownSeconds; + if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (allSites !== undefined) updateData.allSites = allSites; + if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks; + if (allResources !== undefined) updateData.allResources = allResources; await db .update(alertRules) @@ -247,12 +252,14 @@ export async function updateAlertRule( ); // --- Full-replace site associations if siteIds was provided --- - if (siteIds !== undefined) { + if (siteIds !== undefined || allSites !== undefined) { await db .delete(alertSites) .where(eq(alertSites.alertRuleId, alertRuleId)); - if (siteIds.length > 0) { + // Only insert junction rows when allSites is not true + const effectiveAllSites = allSites ?? false; + if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId, @@ -263,12 +270,13 @@ export async function updateAlertRule( } // --- Full-replace health check associations if healthCheckIds was provided --- - if (healthCheckIds !== undefined) { + if (healthCheckIds !== undefined || allHealthChecks !== undefined) { await db .delete(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); - if (healthCheckIds.length > 0) { + const effectiveAllHealthChecks = allHealthChecks ?? false; + if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId, @@ -279,12 +287,13 @@ export async function updateAlertRule( } // --- Full-replace resource associations if resourceIds was provided --- - if (resourceIds !== undefined) { + if (resourceIds !== undefined || allResources !== undefined) { await db .delete(alertResources) .where(eq(alertResources.alertRuleId, alertRuleId)); - if (resourceIds.length > 0) { + const effectiveAllResources = allResources ?? false; + if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) { await db.insert(alertResources).values( resourceIds.map((resourceId) => ({ alertRuleId, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a31e5179e..7d4a724ea 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -230,6 +230,7 @@ export async function createTarget( .values({ orgId: resource.orgId, targetId: newTarget[0].targetId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null, From 66c0ed5bf00cac2a78ea1feb28244db48932db3e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 21:15:55 -0700 Subject: [PATCH 114/176] slightly improve add user form --- messages/en-US.json | 2 +- src/app/[orgId]/settings/access/users/create/page.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2c8ac950b..a0fc07e70 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -523,7 +523,7 @@ "userSettings": "User Information", "userSettingsDescription": "Enter the details for the new user", "inviteEmailSent": "Send invite email to user", - "inviteValid": "Valid For", + "inviteValid": "Invite Valid For (days)", "selectDuration": "Select duration", "selectResource": "Select Resource", "filterByResource": "Filter By Resource", diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 858ac8da8..2c3292f9e 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -467,7 +467,7 @@ export default function Page() {
- {!inviteLink ? ( + {!inviteLink && userOptions.length > 1 ? ( @@ -490,7 +490,7 @@ export default function Page() { genericOidcForm.reset(); } }} - cols={2} + cols={3} /> From 8093904d478a3253e8d11433b926e360cda121c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 21:20:45 -0700 Subject: [PATCH 115/176] Adjust ui --- messages/en-US.json | 12 ++++++------ src/components/UptimeAlertSection.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 13f73a3ad..f3aca3e5d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1352,7 +1352,7 @@ "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "alertingTitle": "Alerting", - "alertingDescription": "Define sources, triggers, and actions for notifications.", + "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", "alertingSearchRules": "Search rules…", "alertingAddRule": "Create Rule", @@ -1376,15 +1376,15 @@ "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", "alertingPickResources": "Resources", - "alertingAllSites": "All sites", + "alertingAllSites": "All Sites", "alertingAllSitesDescription": "Alert fires for any site", - "alertingSpecificSites": "Specific sites", + "alertingSpecificSites": "Specific Sites", "alertingSpecificSitesDescription": "Choose specific sites to watch", - "alertingAllHealthChecks": "All health checks", + "alertingAllHealthChecks": "All Health Checks", "alertingAllHealthChecksDescription": "Alert fires for any health check", - "alertingSpecificHealthChecks": "Specific health checks", + "alertingSpecificHealthChecks": "Specific Health Checks", "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", - "alertingAllResources": "All resources", + "alertingAllResources": "All Resources", "alertingAllResourcesDescription": "Alert fires for any resource", "alertingSpecificResources": "Specific resources", "alertingSpecificResourcesDescription": "Choose specific resources to watch", diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index 72c9fa78f..ce1c174f1 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -157,14 +157,14 @@ export default function UptimeAlertSection({ } const alertButton = alertRulesLoading ? null : hasRules ? ( - ) : ( - From 6f06f98cc1aca797d4bcdc3380fa627b73434855 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 21:51:53 -0700 Subject: [PATCH 116/176] add filter by idp and role in users table --- messages/en-US.json | 2 + server/routers/user/listUsers.ts | 109 ++++++++++++- .../[orgId]/settings/access/users/page.tsx | 67 +++++--- src/components/ColumnMultiFilterButton.tsx | 146 ++++++++++++++++++ src/components/UsersTable.tsx | 92 +++++++++-- 5 files changed, 386 insertions(+), 30 deletions(-) create mode 100644 src/components/ColumnMultiFilterButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7d1b54102..ba9b1e5a0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -270,6 +270,8 @@ "accessUserManage": "Manage User", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}", + "accessUsersRoleFilterClear": "Clear role filters", "accessUserCreate": "Create User", "accessUserRemove": "Remove User", "username": "Username", diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index eae9e79f9..42a62636d 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,15 +1,23 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; +import { + idp, + idpOrg, + roles, + userOrgRoles, + userOrgs, + users +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() @@ -60,6 +68,41 @@ const listUsersSchema = z.strictObject({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + idp_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, z.union([z.literal("internal"), z.number().int().positive()]).optional()) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + role_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive()).max(50).optional()) + .openapi({ + description: + "Filter users who have any of these role ids in the organization (repeat query param)" }) }); @@ -125,7 +168,9 @@ export async function listUsers( ) ); } - const { page, pageSize, sort_by, order, query } = parsedQuery.data; + const { page, pageSize, sort_by, order, query, idp_id, role_id } = + parsedQuery.data; + const roleIds = role_id ?? []; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -139,6 +184,41 @@ export async function listUsers( const { orgId } = parsedParams.data; + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idpOrg) + .where( + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id)) + ) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id is not linked to this organization" + ) + ); + } + } + + if (roleIds.length > 0) { + const validRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and(eq(roles.orgId, orgId), inArray(roles.roleId, roleIds)) + ); + if (validRoles.length !== roleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role_id values are not valid for this organization" + ) + ); + } + } + const conditions = [and(eq(userOrgs.orgId, orgId))]; if (query) { @@ -160,6 +240,29 @@ export async function listUsers( ); } + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (roleIds.length > 0) { + conditions.push( + exists( + db + .select() + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, users.userId), + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, roleIds) + ) + ) + ) + ); + } + const countQuery = db.$count( queryUsersBase() .where(and(...conditions)) diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index bdf8531a2..462122a95 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -1,8 +1,9 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { ListOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import type { ListRolesResponse } from "@server/routers/role/listRoles"; import { ListUsersResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; @@ -29,7 +30,6 @@ export default async function UsersPage(props: UsersPageProps) { const searchParams = new URLSearchParams(await props.searchParams); const user = await verifySession(); - const t = await getTranslations(); let users: ListUsersResponse["users"] = []; let pagination: ListUsersResponse["pagination"] = { @@ -39,23 +39,54 @@ export default async function UsersPage(props: UsersPageProps) { }; let hasInvitations = false; - const res = await internal - .get< - AxiosResponse - >(`/org/${params.orgId}/users?${searchParams.toString()}`, await authCookieHeader()) - .catch((e) => {}); + const cookieHeader = await authCookieHeader(); - if (res && res.status === 200) { - users = res.data.data.users; - pagination = res.data.data.pagination; + const [usersRes, idpsRes, rolesRes] = await Promise.all([ + internal + .get( + `/org/${params.orgId}/users?${searchParams.toString()}`, + cookieHeader + ) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/roles?pageSize=500&page=1`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data as ListUsersResponse; + users = list.users; + pagination = list.pagination; } + const t = await getTranslations(); + + const orgIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...orgIdps.map((i: ListOrgIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + + const orgRoles = + rolesRes && rolesRes.status === 200 + ? (rolesRes.data.data.roles ?? []) + : []; + const roleFilterOptions = orgRoles.map( + (r: ListRolesResponse["roles"][number]) => ({ + value: String(r.roleId), + label: r.name + }) + ); + const invitationsRes = await internal - .get< - AxiosResponse<{ - pagination: { total: number }; - }> - >( + .get( `/org/${params.orgId}/invitations?limit=1&offset=0`, await authCookieHeader() ) @@ -68,9 +99,7 @@ export default async function UsersPage(props: UsersPageProps) { let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal - .get< - AxiosResponse - >(`/org/${params.orgId}`, await authCookieHeader()) + .get(`/org/${params.orgId}`, await authCookieHeader()) .catch((e) => { console.error(e); }) @@ -124,6 +153,8 @@ export default async function UsersPage(props: UsersPageProps) { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + idpFilterOptions={idpFilterOptions} + roleFilterOptions={roleFilterOptions} /> diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx new file mode 100644 index 000000000..ee386461d --- /dev/null +++ b/src/components/ColumnMultiFilterButton.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, Funnel } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; + +type FilterOption = { + value: string; + label: string; +}; + +type ColumnMultiFilterButtonProps = { + options: FilterOption[]; + selectedValues: string[]; + onSelectedValuesChange: (values: string[]) => void; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + label: string; +}; + +export function ColumnMultiFilterButton({ + options, + selectedValues, + onSelectedValuesChange, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className, + label +}: ColumnMultiFilterButtonProps) { + const [open, setOpen] = useState(false); + const t = useTranslations(); + + const selectedSet = useMemo( + () => new Set(selectedValues), + [selectedValues] + ); + + const summary = useMemo(() => { + if (selectedValues.length === 0) { + return null; + } + if (selectedValues.length === 1) { + return ( + options.find((o) => o.value === selectedValues[0])?.label ?? + selectedValues[0] + ); + } + return t("accessUsersRoleFilterCount", { + count: selectedValues.length + }); + }, [selectedValues, options, t]); + + function toggle(value: string) { + const next = selectedSet.has(value) + ? selectedValues.filter((v) => v !== value) + : [...selectedValues, value]; + onSelectedValuesChange(next); + } + + return ( + + + + + + + + + {emptyMessage} + + {selectedValues.length > 0 && ( + { + onSelectedValuesChange([]); + setOpen(false); + }} + className="text-muted-foreground" + > + {t("accessUsersRoleFilterClear")} + + )} + {options.map((option) => ( + { + toggle(option.value); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 60816ee9e..979c59425 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -2,7 +2,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -28,10 +27,16 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; -import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; import UserRoleBadges from "./UserRoleBadges"; export type UserRow = { @@ -49,16 +54,22 @@ export type UserRow = { isOwner: boolean; }; +type FilterOption = { value: string; label: string }; + type UsersTableProps = { users: UserRow[]; pagination: PaginationState; rowCount: number; + idpFilterOptions: FilterOption[]; + roleFilterOptions: FilterOption[]; }; export default function UsersTable({ users, pagination, - rowCount + rowCount, + idpFilterOptions, + roleFilterOptions }: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); @@ -72,9 +83,48 @@ export default function UsersTable({ const { navigate: filter, isNavigating: isFiltering, - searchParams + searchParams, + pathname } = useNavigationContext(); + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const roleIdsFromSearchParams = useMemo(() => { + const sp = new URLSearchParams(searchParams); + return [ + ...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id))) + ]; + }, [searchParams.toString()]); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function handleRoleIdsChange(values: string[]) { + const sp = new URLSearchParams(searchParams); + sp.delete("role_id"); + sp.delete("page"); + for (const id of values) { + if (/^\d+$/.test(id)) { + sp.append("role_id", id); + } + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + const refreshData = async () => { startTransition(async () => { try { @@ -118,8 +168,22 @@ export default function UsersTable({ { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return {t("identityProvider")}; + header: () => { + return ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ); }, cell: ({ row }) => { const userRow = row.original; @@ -136,8 +200,18 @@ export default function UsersTable({ id: "role", accessorFn: (row) => row.roleLabels.join(", "), friendlyName: t("role"), - header: ({ column }) => { - return {t("role")}; + header: () => { + return ( + + ); }, cell: ({ row }) => { return ; From 85f7c1e87b0c6bcecca4205d232e84d44ef969db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 20 Apr 2026 22:05:29 -0700 Subject: [PATCH 117/176] support server side table for admin users table --- server/routers/user/adminListUsers.ts | 226 +++++++++++++--- src/app/admin/users/AdminUsersTable.tsx | 264 ------------------- src/app/admin/users/page.tsx | 73 +++++- src/components/AdminUsersDataTable.tsx | 37 --- src/components/AdminUsersTable.tsx | 325 +++++++++++++++--------- 5 files changed, 464 insertions(+), 461 deletions(-) delete mode 100644 src/app/admin/users/AdminUsersTable.tsx delete mode 100644 src/components/AdminUsersDataTable.tsx diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 3a965259c..3d7bac4b3 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -1,31 +1,98 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; -import { idp, users } from "@server/db"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["username", "email", "name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username", "email", "name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + idp_id: z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, + z + .union([z.literal("internal"), z.number().int().positive()]) + .optional() + ) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + two_factor: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: + "Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested" + }) }); -async function queryUsers(limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -40,17 +107,39 @@ async function queryUsers(limit: number, offset: number) { twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) - .leftJoin(idp, eq(users.idpId, idp.idpId)) - .where(eq(users.serverAdmin, false)) - .limit(limit) - .offset(offset); + .leftJoin(idp, eq(users.idpId, idp.idpId)); } -export type AdminListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; +/** Row shape returned by `queryUsersBase()` (matches selected columns + join). */ +export type AdminListUserRow = { + id: string; + email: string | null; + username: string; + name: string | null; + dateCreated: string; + serverAdmin: boolean; + type: string; + idpName: string | null; + idpId: number | null; + twoFactorEnabled: boolean; + twoFactorSetupRequested: boolean | null; }; +export type AdminListUsersResponse = PaginatedResponse<{ + users: AdminListUserRow[]; +}>; + +registry.registerPath({ + method: "get", + path: "/users", + description: "List non–server-admin users (server admin).", + tags: [OpenAPITags.User], + request: { + query: listUsersSchema + }, + responses: {} +}); + export async function adminListUsers( req: Request, res: Response, @@ -66,21 +155,96 @@ export async function adminListUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + order, + idp_id, + two_factor: twoFactorFilter + } = parsedQuery.data; - const allUsers = await queryUsers(limit, offset); + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idp) + .where(eq(idp.idpId, idp_id)) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id does not exist" + ) + ); + } + } - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(users); + const conditions = [eq(users.serverAdmin, false)]; + + if (query) { + const q = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${users.username})`, q), + like(sql`LOWER(${users.email})`, q), + like(sql`LOWER(${users.name})`, q) + )! + ); + } + + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (typeof twoFactorFilter === "boolean") { + if (twoFactorFilter) { + conditions.push( + or( + eq(users.twoFactorEnabled, true), + eq(users.twoFactorSetupRequested, true) + )! + ); + } else { + conditions.push( + and( + eq(users.twoFactorEnabled, false), + eq(users.twoFactorSetupRequested, false) + )! + ); + } + } + + const whereClause = and(...conditions); + + const countQuery = db.$count( + queryUsersBase().where(whereClause).as("filtered_admin_users") + ); + + const userListQuery = queryUsersBase() + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.username) + ); + + const [total, rows] = await Promise.all([countQuery, userListQuery]); return response(res, { data: { - users: allUsers, + users: rows, pagination: { - total: count, - limit, - offset + total, + page, + pageSize } }, success: true, diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx deleted file mode 100644 index 1c7d1b7fd..000000000 --- a/src/app/admin/users/AdminUsersTable.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger -} from "@app/components/ui/dropdown-menu"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; - -export type GlobalUserRow = { - id: string; - name: string | null; - username: string; - email: string | null; - type: string; - idpId: number | null; - idpName: string; - dateCreated: string; - twoFactorEnabled: boolean | null; - twoFactorSetupRequested: boolean | null; -}; - -type Props = { - users: GlobalUserRow[]; -}; - -export default function UsersTable({ users }: Props) { - const router = useRouter(); - const t = useTranslations(); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); - }; - - const columns: ExtendedColumnDef[] = [ - { - accessorKey: "id", - friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "username", - friendlyName: t("username"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "email", - friendlyName: t("email"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "name", - friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "idpName", - friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "twoFactorEnabled", - friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const userRow = row.original; - - return ( -
- - {userRow.twoFactorEnabled || - userRow.twoFactorSetupRequested ? ( - - {t("enabled")} - - ) : ( - {t("disabled")} - )} - -
- ); - } - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => { - const r = row.original; - return ( - <> -
- - - - - - - { - setSelected(r); - setIsDeleteModalOpen(true); - }} - > - {t("delete")} - - - -
- - ); - } - } - ]; - - return ( - <> - {selected && ( - { - setIsDeleteModalOpen(val); - setSelected(null); - }} - dialog={ -
-

{t("userQuestionRemove")}

- -

{t("userMessageRemove")}

-
- } - buttonText={t("userDeleteConfirm")} - onConfirm={async () => deleteUser(selected!.id)} - string={ - selected.email || selected.name || selected.username - } - title={t("userDeleteServer")} - /> - )} - - - - ); -} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 2a000b34b..0cfaaf3b0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,33 +1,70 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { ListIdpsResponse } from "@server/routers/idp/listIdps"; import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -type PageProps = { - params: Promise<{ orgId: string }>; +/** API JSON body shape for `response()` handlers (see `server/lib/response.ts`). */ +type ApiPayload = { + data: T; + success: boolean; + error: boolean; + message: string; + status: number; +}; + +type AdminUsersPageProps = { + searchParams: Promise>; }; export const dynamic = "force-dynamic"; -export default async function UsersPage(props: PageProps) { +export default async function UsersPage(props: AdminUsersPageProps) { + const searchParams = new URLSearchParams(await props.searchParams); + const cookieHeader = await authCookieHeader(); + let rows: AdminListUsersResponse["users"] = []; - try { - const res = await internal.get>( - `/users`, - await authCookieHeader() - ); - rows = res.data.data.users; - } catch (e) { - console.error(e); + let pagination: AdminListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + const [usersRes, idpsRes] = await Promise.all([ + internal + .get< + ApiPayload + >(`/users?${searchParams.toString()}`, cookieHeader) + .catch(() => {}), + internal + .get< + ApiPayload + >(`/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data; + rows = list.users; + pagination = list.pagination; } const t = await getTranslations(); + const globalIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...globalIdps.map((i: ListIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + const userRows: GlobalUserRow[] = rows.map((row) => { return { id: row.id, @@ -59,7 +96,15 @@ export default async function UsersPage(props: PageProps) { {t("userAbountDescription")} - + ); } diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx deleted file mode 100644 index afa473e86..000000000 --- a/src/components/AdminUsersDataTable.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 09797a2e2..eabb6b468 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -1,19 +1,31 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; 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 { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; import { DropdownMenu, DropdownMenuItem, @@ -31,7 +43,6 @@ import { CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { AxiosResponse } from "axios"; export type GlobalUserRow = { id: string; @@ -44,10 +55,16 @@ export type GlobalUserRow = { dateCreated: string; twoFactorEnabled: boolean | null; twoFactorSetupRequested: boolean | null; + serverAdmin?: boolean; }; +type FilterOption = { value: string; label: string }; + type Props = { users: GlobalUserRow[]; + pagination: PaginationState; + rowCount: number; + idpFilterOptions: FilterOption[]; }; type AdminGeneratePasswordResetCodeResponse = { @@ -56,74 +73,103 @@ type AdminGeneratePasswordResetCodeResponse = { url: string; }; -export default function UsersTable({ users }: Props) { +export default function UsersTable({ + users, + pagination, + rowCount, + idpFilterOptions +}: Props) { const router = useRouter(); const t = useTranslations(); + const api = createApiClient(useEnvContext()); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const [isRefreshing, setIsRefreshing] = useState(false); const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] = useState(false); const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(users); - }, [users]); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams, + pathname + } = useNavigationContext(); + + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const twoFactorFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) + startTransition(() => { + void api + .delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + setSelected(null); }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); + }); }; const generatePasswordResetCode = async (userId: string) => { setIsGeneratingCode(true); try { - const res = await api.post< - AxiosResponse - >(`/user/${userId}/generate-password-reset-code`); + const res = await api.post( + `/user/${userId}/generate-password-reset-code` + ); - if (res.data?.data) { - setPasswordResetCodeData(res.data.data); + const envelope = res.data as { + data?: AdminGeneratePasswordResetCodeResponse; + }; + if (envelope?.data) { + setPasswordResetCodeData(envelope.data); setIsPasswordResetCodeDialogOpen(true); } } catch (e) { @@ -138,37 +184,55 @@ export default function UsersTable({ users }: Props) { } }; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + const columns: ExtendedColumnDef[] = [ { accessorKey: "id", friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } + header: () => ID }, { accessorKey: "username", enableHiding: false, friendlyName: t("username"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("username", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -176,16 +240,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "email", friendlyName: t("email"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("email", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -193,16 +263,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "name", friendlyName: t("name"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("name", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -210,39 +286,45 @@ export default function UsersTable({ users }: Props) { { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } + header: () => ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ) }, { accessorKey: "twoFactorEnabled", friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("two_factor", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("twoFactor")} + className="p-3" + /> + ), cell: ({ row }) => { const userRow = row.original; - return (
@@ -277,8 +359,11 @@ export default function UsersTable({ users }: Props) { {r.type === "internal" && ( { - generatePasswordResetCode(r.id); + void generatePasswordResetCode( + r.id + ); }} > {t("generatePasswordResetCode")} @@ -350,11 +435,21 @@ export default function UsersTable({ users }: Props) { /> )} - Date: Tue, 21 Apr 2026 09:47:20 -0700 Subject: [PATCH 118/176] Working on newt compat --- server/routers/newt/buildConfiguration.ts | 16 ++- .../routers/newt/handleNewtRegisterMessage.ts | 2 +- server/routers/newt/targets.ts | 10 +- .../target/handleHealthcheckStatusMessage.ts | 105 +++++++++++++----- .../alert-rule-editor/AlertRuleFields.tsx | 50 ++++++++- 5 files changed, 143 insertions(+), 40 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 46729f11d..cca69dd61 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -21,6 +21,7 @@ import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; +import { supportsTargetHealthChecksV2 } from "./targets"; export async function buildClientConfigurationForNewtClient( site: Site, @@ -86,7 +87,8 @@ export async function buildClientConfigurationForNewtClient( // ) // ); - if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm + if (!client.clientSitesAssociationsCache.isJitMode) { + // if we are adding sites through jit then dont add the site to the olm // update the peer info on the olm // if the peer has not been added yet this will be a no-op await updatePeer(client.clients.clientId, { @@ -189,7 +191,10 @@ export async function buildClientConfigurationForNewtClient( }; } -export async function buildTargetConfigurationForNewtClient(siteId: number) { +export async function buildTargetConfigurationForNewtClient( + siteId: number, + version?: string | null +) { // Get all enabled targets with their resource protocol information const allTargets = await db .select({ @@ -201,7 +206,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { internalPort: targets.internalPort, enabled: targets.enabled, protocol: resources.protocol, - hcId: targetHealthCheck.targetHealthCheckId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, @@ -273,8 +278,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { } return { - id: target.targetId, - hcId: target.hcId, + id: supportsTargetHealthChecksV2(version) + ? target.targetId + : target.targetHealthCheckId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index fce42caa3..f3902a35d 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { } const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId); + await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 572c63e98..a28ef4f91 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -2,6 +2,13 @@ import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; +import semver from "semver"; + +const NEWT_V2_TARGET_HEALTH_CHECK_VERSION = ">=1.12.0"; + +export function supportsTargetHealthChecksV2(version?: string | null) { + return version ? semver.satisfies(version, NEWT_V2_TARGET_HEALTH_CHECK_VERSION) : false; +} export async function addTargets( newtId: string, @@ -83,8 +90,7 @@ export async function addTargets( } return { - id: target.targetId, - hcId: hc.targetHealthCheckId, + id: supportsTargetHealthChecksV2(version) ? target.targetId : hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index a049e3224..47e4a771c 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -14,6 +14,7 @@ import { fireHealthCheckHealthyAlert, fireHealthCheckNotHealthyAlert } from "#dynamic/lib/alerts"; +import { supportsTargetHealthChecksV2 } from "@server/routers/newt/targets"; interface TargetHealthStatus { status: string; @@ -73,6 +74,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( let successCount = 0; let errorCount = 0; + const isV2 = supportsTargetHealthChecksV2(newt.version); + // Process each target status update for (const [targetId, healthStatus] of Object.entries(data.targets)) { logger.debug( @@ -88,34 +91,78 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } - const [targetCheck] = await db - .select({ - targetId: targets.targetId, - siteId: targets.siteId, - orgId: targetHealthCheck.orgId, - targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceOrgId: resources.orgId, - resourceId: resources.resourceId, - name: targetHealthCheck.name, - hcStatus: targetHealthCheck.hcHealth - }) - .from(targets) - .innerJoin( - resources, - eq(targets.resourceId, resources.resourceId) - ) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .innerJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where( - and( - eq(targets.targetId, targetIdNum), - eq(sites.siteId, newt.siteId) + let targetCheck: { + targetId: number; + siteId: number | null; + orgId: string | null; + targetHealthCheckId: number; + resourceOrgId: string | null; + resourceId: number | null; + name: string | null; + hcStatus: string | null; + } | undefined; + + if (isV2) { + // New newt (>= 1.12.0): the key is the targetId + [targetCheck] = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + orgId: targetHealthCheck.orgId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceOrgId: resources.orgId, + resourceId: resources.resourceId, + name: targetHealthCheck.name, + hcStatus: targetHealthCheck.hcHealth + }) + .from(targets) + .innerJoin( + resources, + eq(targets.resourceId, resources.resourceId) ) - ) - .limit(1); + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where( + and( + eq(targets.targetId, targetIdNum), + eq(sites.siteId, newt.siteId) + ) + ) + .limit(1); + } else { + // Old newt (< 1.12.0): the key is the targetHealthCheckId + [targetCheck] = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + orgId: targetHealthCheck.orgId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceOrgId: resources.orgId, + resourceId: resources.resourceId, + name: targetHealthCheck.name, + hcStatus: targetHealthCheck.hcHealth + }) + .from(targetHealthCheck) + .innerJoin( + targets, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .innerJoin( + resources, + eq(targets.resourceId, resources.resourceId) + ) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, targetIdNum), + eq(sites.siteId, newt.siteId) + ) + ) + .limit(1); + } if (!targetCheck) { logger.warn( @@ -142,7 +189,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( | "healthy" | "unhealthy" }) - .where(eq(targetHealthCheck.targetId, targetIdNum)); + .where(eq(targetHealthCheck.targetId, targetCheck.targetId)); // Log the state change to status history await db.insert(statusHistory).values({ @@ -170,7 +217,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where( and( eq(targets.resourceId, targetCheck.resourceId), - eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated + eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated ) ); diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 2b57724cc..aa004357b 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -46,7 +46,7 @@ import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; @@ -484,8 +484,8 @@ function NotifyActionFields({ number | null >(null); - const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId })); - const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId })); + const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId })); + const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId })); const allUsers = useMemo( () => @@ -508,6 +508,50 @@ function NotifyActionFields({ [orgRoles] ); + const hasResolvedTagsRef = useRef(false); + + useEffect(() => { + if (isLoadingUsers || isLoadingRoles) return; + if (hasResolvedTagsRef.current) return; + + const currentUserTags = form.getValues( + `actions.${index}.userTags` + ) as Tag[]; + const currentRoleTags = form.getValues( + `actions.${index}.roleTags` + ) as Tag[]; + + const resolvedUserTags = currentUserTags.map((tag) => { + const match = allUsers.find((u) => u.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const resolvedRoleTags = currentRoleTags.map((tag) => { + const match = allRoles.find((r) => r.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const userTagsNeedUpdate = resolvedUserTags.some( + (t, i) => t.text !== currentUserTags[i]?.text + ); + const roleTagsNeedUpdate = resolvedRoleTags.some( + (t, i) => t.text !== currentRoleTags[i]?.text + ); + + if (userTagsNeedUpdate) { + form.setValue(`actions.${index}.userTags`, resolvedUserTags, { + shouldDirty: false + }); + } + if (roleTagsNeedUpdate) { + form.setValue(`actions.${index}.roleTags`, resolvedRoleTags, { + shouldDirty: false + }); + } + + hasResolvedTagsRef.current = true; + }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); + const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[]; const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[]; const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[]; From 4ce4e63a0a69116d4cb32cebeb4b7a5a9993117d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 09:57:06 -0700 Subject: [PATCH 119/176] Accept nice id when creating --- server/routers/siteResource/createSiteResource.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1485a4192..3737ffbea 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -38,6 +38,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), siteId: z.int(), + niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), @@ -160,6 +161,7 @@ export async function createSiteResource( const { name, siteId, + niceId, mode, // protocol, // proxyPort, @@ -278,7 +280,11 @@ export async function createSiteResource( tierMatrix.sshPam ); - const niceId = await getUniqueSiteResourceName(orgId); + let updatedNiceId = niceId; + if (!niceId) { + updatedNiceId = await getUniqueSiteResourceName(orgId); + } + let aliasAddress: string | null = null; if (mode == "host") { // we can only have an alias on a host @@ -290,7 +296,7 @@ export async function createSiteResource( // Create the site resource const insertValues: typeof siteResources.$inferInsert = { siteId, - niceId, + niceId: updatedNiceId!, orgId, name, mode: mode as "host" | "cidr", From 206b3a7d22a113421d746fcc67d3447d1a68ba0c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 11:52:15 -0700 Subject: [PATCH 120/176] Adding external actions --- messages/en-US.json | 3 + public/third-party/incidentio.png | Bin 0 -> 2446 bytes public/third-party/opsgenie.png | Bin 0 -> 218898 bytes public/third-party/pgd.png | Bin 0 -> 6452 bytes public/third-party/servicenow.png | Bin 0 -> 8836 bytes .../alert-rule-editor/AlertRuleFields.tsx | 265 +++++++++++++----- 6 files changed, 192 insertions(+), 76 deletions(-) create mode 100644 public/third-party/incidentio.png create mode 100644 public/third-party/opsgenie.png create mode 100644 public/third-party/pgd.png create mode 100644 public/third-party/servicenow.png diff --git a/messages/en-US.json b/messages/en-US.json index 0fefade48..30ed6c933 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1408,7 +1408,10 @@ "alertingSectionActions": "Actions", "alertingAddAction": "Add action", "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Send email notifications to users or roles", "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send an HTTP request to a custom endpoint", + "alertingExternalIntegration": "External Integration", "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", diff --git a/public/third-party/incidentio.png b/public/third-party/incidentio.png new file mode 100644 index 0000000000000000000000000000000000000000..e567d31fb5b7f258d05098e4936c56cec60326f7 GIT binary patch literal 2446 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 TFh~G$Ly&=YcCTyqiLs&1Q6^p{0Km~Jy8oC0 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 JSi|AL{{j(WWk&!2 literal 0 HcmV?d00001 diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index aa004357b..32fbbc5f1 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -44,13 +44,39 @@ import { } from "@app/lib/alertRuleForm"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; +const EXTERNAL_INTEGRATIONS = [ + { + id: "pagerduty", + name: "PagerDuty", + logo: "/third-party/pgd.png" + }, + { + id: "opsgenie", + name: "Opsgenie", + logo: "/third-party/opsgenie.png" + }, + { + id: "servicenow", + name: "ServiceNow", + logo: "/third-party/servicenow.png" + }, + { + id: "incidentio", + name: "Incident.io", + logo: "/third-party/incidentio.png" + } +] as const; + +const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); + export function DropdownAddAction({ onAdd }: { @@ -58,8 +84,15 @@ export function DropdownAddAction({ }) { const t = useTranslations(); const [open, setOpen] = useState(false); + const [salesFor, setSalesFor] = useState(null); return ( - + { + setOpen(o); + if (!o) setSalesFor(null); + }} + > - - - - - { - onAdd("notify"); - setOpen(false); - }} - > - {t("alertingActionNotify")} - - { - onAdd("webhook"); - setOpen(false); - }} - > - {t("alertingActionWebhook")} - - - - + + {salesFor ? ( +
+
+ i.id === salesFor + )?.logo + } + alt={salesFor} + className="h-5 w-5 object-contain" + /> + + { + EXTERNAL_INTEGRATIONS.find( + (i) => i.id === salesFor + )?.name + } + +
+ + +
+ ) : ( + + + + { + onAdd("notify"); + setOpen(false); + }} + > + + {t("alertingActionNotify")} + + { + onAdd("webhook"); + setOpen(false); + }} + > + + {t("alertingActionWebhook")} + + + + {EXTERNAL_INTEGRATIONS.map((integration) => ( + + setSalesFor(integration.id) + } + > + {integration.name} + {integration.name} + + ))} + + + + )}
); @@ -382,6 +470,43 @@ export function ActionBlock({ }) { const t = useTranslations(); const type = useWatch({ control, name: `actions.${index}.type` }); + const [displayType, setDisplayType] = useState(type ?? "notify"); + + useEffect(() => { + if (!EXTERNAL_IDS.includes(displayType as any)) { + setDisplayType(type ?? "notify"); + } + }, [type]); + + const isPremium = EXTERNAL_IDS.includes(displayType as any); + + const actionTypeOptions = [ + { + id: "notify", + title: t("alertingActionNotify"), + description: t("alertingActionNotifyDescription"), + icon: + }, + { + id: "webhook", + title: t("alertingActionWebhook"), + description: t("alertingActionWebhookDescription"), + icon: + }, + ...EXTERNAL_INTEGRATIONS.map((integration) => ({ + id: integration.id, + title: integration.name, + description: t("alertingExternalIntegration"), + icon: ( + {integration.name} + ) + })) + ]; + return (
{canRemove && ( @@ -395,56 +520,44 @@ export function ActionBlock({ )} - ( - - {t("alertingActionType")} - - - )} - /> - {type === "notify" && ( +
+ + { + setDisplayType(v); + if (!EXTERNAL_IDS.includes(v as any)) { + const nt = v as AlertRuleFormAction["type"]; + if (nt === "notify") { + onUpdate({ + type: "notify", + userTags: [], + roleTags: [], + emailTags: [] + }); + } else { + onUpdate({ + type: "webhook", + url: "", + method: "POST", + headers: [], + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" + }); + } + } + }} + /> +
+ {isPremium && } + {!isPremium && type === "notify" && ( )} - {type === "webhook" && ( + {!isPremium && type === "webhook" && ( Date: Tue, 21 Apr 2026 12:09:19 -0700 Subject: [PATCH 121/176] Add descriptions and adjust ui --- messages/en-US.json | 4 + .../alert-rule-editor/AlertRuleFields.tsx | 300 +++++++----------- .../AlertRuleGraphEditor.tsx | 82 +++-- 3 files changed, 150 insertions(+), 236 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 30ed6c933..24446f5c8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1412,6 +1412,10 @@ "alertingActionWebhook": "Webhook", "alertingActionWebhookDescription": "Send an HTTP request to a custom endpoint", "alertingExternalIntegration": "External Integration", + "alertingExternalPagerDutyDescription": "Send alerts to PagerDuty for incident management", + "alertingExternalOpsgenieDescription": "Route alerts to Opsgenie for on-call management", + "alertingExternalServiceNowDescription": "Create ServiceNow incidents from alert events", + "alertingExternalIncidentIoDescription": "Trigger Incident.io workflows from alert events", "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 32fbbc5f1..e92980171 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -52,134 +52,107 @@ import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; -const EXTERNAL_INTEGRATIONS = [ - { - id: "pagerduty", - name: "PagerDuty", - logo: "/third-party/pgd.png" - }, - { - id: "opsgenie", - name: "Opsgenie", - logo: "/third-party/opsgenie.png" - }, - { - id: "servicenow", - name: "ServiceNow", - logo: "/third-party/servicenow.png" - }, - { - id: "incidentio", - name: "Incident.io", - logo: "/third-party/incidentio.png" - } -] as const; - -const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); - -export function DropdownAddAction({ +export function AddActionPanel({ onAdd }: { onAdd: (type: AlertRuleFormAction["type"]) => void; }) { const t = useTranslations(); - const [open, setOpen] = useState(false); - const [salesFor, setSalesFor] = useState(null); + + + const EXTERNAL_INTEGRATIONS = [ + { + id: "pagerduty", + name: "PagerDuty", + logo: "/third-party/pgd.png", + description: "Send alerts to PagerDuty for incident management", + descriptionKey: t("alertingExternalPagerDutyDescription") + }, + { + id: "opsgenie", + name: "Opsgenie", + logo: "/third-party/opsgenie.png", + description: "Route alerts to Opsgenie for on-call management", + descriptionKey: t("alertingExternalOpsgenieDescription") + }, + { + id: "servicenow", + name: "ServiceNow", + logo: "/third-party/servicenow.png", + description: "Create ServiceNow incidents from alert events", + descriptionKey: t("alertingExternalServiceNowDescription") + }, + { + id: "incidentio", + name: "Incident.io", + logo: "/third-party/incidentio.png", + description: "Trigger Incident.io workflows from alert events", + descriptionKey: t("alertingExternalIncidentIoDescription") + } + ] as const; + + const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); + + const [selected, setSelected] = useState(null); + + const isPremiumSelected = + selected !== null && EXTERNAL_IDS.includes(selected as any); + const isBuiltInSelected = selected !== null && !isPremiumSelected; + + const actionTypeOptions = [ + { + id: "notify", + title: t("alertingActionNotify"), + description: t("alertingActionNotifyDescription"), + icon: + }, + { + id: "webhook", + title: t("alertingActionWebhook"), + description: t("alertingActionWebhookDescription"), + icon: + }, + ...EXTERNAL_INTEGRATIONS.map((integration) => ({ + id: integration.id, + title: integration.name, + description: integration.description, + icon: ( + {integration.name} + ) + })) + ]; + + const handleAdd = () => { + if (!isBuiltInSelected) return; + onAdd(selected as AlertRuleFormAction["type"]); + setSelected(null); + }; + return ( - { - setOpen(o); - if (!o) setSalesFor(null); - }} - > - - - - - {salesFor ? ( -
-
- i.id === salesFor - )?.logo - } - alt={salesFor} - className="h-5 w-5 object-contain" - /> - - { - EXTERNAL_INTEGRATIONS.find( - (i) => i.id === salesFor - )?.name - } - -
- - -
- ) : ( - - - - { - onAdd("notify"); - setOpen(false); - }} - > - - {t("alertingActionNotify")} - - { - onAdd("webhook"); - setOpen(false); - }} - > - - {t("alertingActionWebhook")} - - - - {EXTERNAL_INTEGRATIONS.map((integration) => ( - - setSalesFor(integration.id) - } - > - {integration.name} - {integration.name} - - ))} - - - - )} -
-
+ )} +
); } @@ -470,42 +443,19 @@ export function ActionBlock({ }) { const t = useTranslations(); const type = useWatch({ control, name: `actions.${index}.type` }); - const [displayType, setDisplayType] = useState(type ?? "notify"); - useEffect(() => { - if (!EXTERNAL_IDS.includes(displayType as any)) { - setDisplayType(type ?? "notify"); - } - }, [type]); - - const isPremium = EXTERNAL_IDS.includes(displayType as any); - - const actionTypeOptions = [ - { - id: "notify", - title: t("alertingActionNotify"), - description: t("alertingActionNotifyDescription"), - icon: - }, - { - id: "webhook", - title: t("alertingActionWebhook"), - description: t("alertingActionWebhookDescription"), - icon: - }, - ...EXTERNAL_INTEGRATIONS.map((integration) => ({ - id: integration.id, - title: integration.name, - description: t("alertingExternalIntegration"), - icon: ( - {integration.name} - ) - })) - ]; + const typeHeader = + type === "notify" ? ( +
+ + {t("alertingActionNotify")} +
+ ) : ( +
+ + {t("alertingActionWebhook")} +
+ ); return (
@@ -520,44 +470,8 @@ export function ActionBlock({ )} -
- - { - setDisplayType(v); - if (!EXTERNAL_IDS.includes(v as any)) { - const nt = v as AlertRuleFormAction["type"]; - if (nt === "notify") { - onUpdate({ - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - }); - } else { - onUpdate({ - type: "webhook", - url: "", - method: "POST", - headers: [], - authType: "none", - bearerToken: "", - basicCredentials: "", - customHeaderName: "", - customHeaderValue: "" - }); - } - } - }} - /> -
- {isPremium && } - {!isPremium && type === "notify" && ( + {typeHeader} + {type === "notify" && ( )} - {!isPremium && type === "webhook" && ( + {type === "webhook" && ( -
- - {t( - "alertingSectionActions" - )} - - { - const newIndex = - fields.length; - if (type === "notify") { - append({ - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - }); - } else { - append({ - type: "webhook", - url: "", - method: "POST", - headers: [ - { - key: "", - value: "" - } - ], - authType: "none", - bearerToken: "", - basicCredentials: "", - customHeaderName: "", - customHeaderValue: "" - }); - } - setSelectedStep( - `action-${newIndex}` - ); - }} - /> -
+ + {t("alertingSectionActions")} + + { + const newIndex = + fields.length; + if (type === "notify") { + append({ + type: "notify", + userTags: [], + roleTags: [], + emailTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { + key: "", + value: "" + } + ], + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" + }); + } + setSelectedStep( + `action-${newIndex}` + ); + }} + /> {fields.map((f, index) => ( Date: Tue, 21 Apr 2026 12:17:24 -0700 Subject: [PATCH 122/176] Fix errors --- .../target/handleHealthcheckStatusMessage.ts | 16 ++++++++++++---- .../alert-rule-editor/AlertRuleGraphEditor.tsx | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 47e4a771c..b35ab8ed2 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -191,11 +191,19 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( }) .where(eq(targetHealthCheck.targetId, targetCheck.targetId)); + const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + if (!orgId) { + logger.warn( + `No org ID found for target ${targetId}, skipping status history logging` + ); + continue; + } + // Log the state change to status history await db.insert(statusHistory).values({ entityType: "healthCheck", entityId: targetCheck.targetHealthCheckId, - orgId: targetCheck.orgId || targetCheck.resourceOrgId, + orgId: orgId, status: healthStatus.status, timestamp: Math.floor(Date.now() / 1000) }); @@ -235,7 +243,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db.insert(statusHistory).values({ entityType: "resource", entityId: targetCheck.resourceId, - orgId: targetCheck.orgId || targetCheck.resourceOrgId, + orgId: orgId, status: status, timestamp: Math.floor(Date.now() / 1000) }); @@ -244,13 +252,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { await fireHealthCheckHealthyAlert( - targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + orgId, targetCheck.targetHealthCheckId, targetCheck.name ); } else if (healthStatus.status === "healthy") { await fireHealthCheckNotHealthyAlert( - targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + orgId, targetCheck.targetHealthCheckId, targetCheck.name ); diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 8b9aef0d2..6d7d8d076 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -346,10 +346,16 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "enabled" }) ?? true; const wSourceType = useWatch({ control: form.control, name: "sourceType" }) ?? "site"; + const wAllSites = + useWatch({ control: form.control, name: "allSites" }) ?? true; const wSiteIds = useWatch({ control: form.control, name: "siteIds" }) ?? []; + const wAllHealthChecks = + useWatch({ control: form.control, name: "allHealthChecks" }) ?? true; const wHealthCheckIds = useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; + const wAllResources = + useWatch({ control: form.control, name: "allResources" }) ?? true; const wResourceIds = useWatch({ control: form.control, name: "resourceIds" }) ?? []; const wTrigger = @@ -363,8 +369,11 @@ export default function AlertRuleGraphEditor({ name: wName, enabled: wEnabled, sourceType: wSourceType, + allSites: wAllSites, siteIds: wSiteIds, + allHealthChecks: wAllHealthChecks, healthCheckIds: wHealthCheckIds, + allResources: wAllResources, resourceIds: wResourceIds, trigger: wTrigger, actions: wActions @@ -373,8 +382,11 @@ export default function AlertRuleGraphEditor({ wName, wEnabled, wSourceType, + wAllSites, wSiteIds, + wAllHealthChecks, wHealthCheckIds, + wAllResources, wResourceIds, wTrigger, wActions From 6969671fc448e875d1fd3ec5c33a17452a138e28 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 14:04:38 -0700 Subject: [PATCH 123/176] Log status inside of the trigger api calls --- server/auth/actions.ts | 5 +- server/private/routers/alertEvents/index.ts | 16 +++ .../alertEvents/triggerHealthCheckAlert.ts | 129 +++++++++++++++++ .../alertEvents/triggerResourceAlert.ts | 135 ++++++++++++++++++ .../routers/alertEvents/triggerSiteAlert.ts | 113 +++++++++++++++ server/private/routers/integration.ts | 22 +++ 6 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/alertEvents/index.ts create mode 100644 server/private/routers/alertEvents/triggerHealthCheckAlert.ts create mode 100644 server/private/routers/alertEvents/triggerResourceAlert.ts create mode 100644 server/private/routers/alertEvents/triggerSiteAlert.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index fd9c02e93..51804fd64 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -153,7 +153,10 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks" + listHealthChecks = "listHealthChecks", + triggerSiteAlert = "triggerSiteAlert", + triggerResourceAlert = "triggerResourceAlert", + triggerHealthCheckAlert = "triggerHealthCheckAlert" } export async function checkUserActionPermission( diff --git a/server/private/routers/alertEvents/index.ts b/server/private/routers/alertEvents/index.ts new file mode 100644 index 000000000..485b434eb --- /dev/null +++ b/server/private/routers/alertEvents/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./triggerSiteAlert"; +export * from "./triggerResourceAlert"; +export * from "./triggerHealthCheckAlert"; \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts new file mode 100644 index 000000000..246de8cd0 --- /dev/null +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -0,0 +1,129 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { targetHealthCheck, statusHistory } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckNotHealthyAlert +} from "#private/lib/alerts/events/healthCheckEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + healthCheckId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["health_check_healthy", "health_check_unhealthy"]) +}); + +export type TriggerHealthCheckAlertResponse = { + success: true; +}; + +export async function triggerHealthCheckAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the health check exists and belongs to the org + const [healthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + + if (!healthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check ${healthCheckId} not found in organization ${orgId}` + ) + ); + } + + await db.insert(statusHistory).values({ + entityType: "healthCheck", + entityId: healthCheckId, + orgId, + status: eventType === "health_check_healthy" ? "healthy" : "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + + if (eventType === "health_check_healthy") { + await fireHealthCheckHealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } else { + await fireHealthCheckNotHealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts new file mode 100644 index 000000000..61b81d900 --- /dev/null +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -0,0 +1,135 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources, statusHistory } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { + fireResourceHealthyAlert, + fireResourceUnhealthyAlert, + fireResourceToggleAlert +} from "#private/lib/alerts/events/resourceEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + resourceId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"]) +}); + +export type TriggerResourceAlertResponse = { + success: true; +}; + +export async function triggerResourceAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, resourceId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the resource exists and belongs to the org + const [resource] = await db + .select() + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource ${resourceId} not found in organization ${orgId}` + ) + ); + } + + if (eventType === "resource_healthy" || eventType === "resource_unhealthy") { + await db.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId, + status: eventType === "resource_healthy" ? "healthy" : "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + } + + if (eventType === "resource_healthy") { + await fireResourceHealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else if (eventType === "resource_unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else { + await fireResourceToggleAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts new file mode 100644 index 000000000..084fbc758 --- /dev/null +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -0,0 +1,113 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { sites, statusHistory } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { + fireSiteOnlineAlert, + fireSiteOfflineAlert +} from "#private/lib/alerts/events/siteEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + siteId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["site_online", "site_offline"]) +}); + +export type TriggerSiteAlertResponse = { + success: true; +}; + +export async function triggerSiteAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, siteId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the site exists and belongs to the org + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site ${siteId} not found in organization ${orgId}` + ) + ); + } + + await db.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId, + status: eventType === "site_online" ? "online" : "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + + if (eventType === "site_online") { + await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined); + } else { + await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 8c1ce4d46..8bae377c0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -14,6 +14,7 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; +import * as alertEvents from "#private/routers/alertEvents"; import { verifyApiKeyHasAction, @@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const unauthenticated = ua; export const authenticated = a; +authenticated.post( + "/org/:orgId/site/:siteId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert), + alertEvents.triggerSiteAlert +); + +authenticated.post( + "/org/:orgId/resource/:resourceId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert), + alertEvents.triggerResourceAlert +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert), + alertEvents.triggerHealthCheckAlert +); + authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine From b1293e6f563033f039b85a6c6590441cbc06346f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 14:12:05 -0700 Subject: [PATCH 124/176] Add siteId to api --- server/db/pg/schema/schema.ts | 3 +++ server/db/sqlite/schema/schema.ts | 3 +++ server/private/routers/healthChecks/createHealthCheck.ts | 3 +++ server/private/routers/healthChecks/getStatusHistory.ts | 2 +- server/private/routers/healthChecks/listHealthChecks.ts | 9 ++++++++- server/private/routers/healthChecks/updateHealthCheck.ts | 5 +++++ server/routers/target/createTarget.ts | 1 + server/routers/target/updateTarget.ts | 1 + 8 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f064ed906..b61cfcf19 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -194,6 +194,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 00994fa2a..c5600b756 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -217,6 +217,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 2a6028ea8..90993015d 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -27,6 +27,7 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ name: z.string().nonempty(), + siteId: z.number().int().positive(), hcEnabled: z.boolean().default(false), hcMode: z.string().default("http"), hcHostname: z.string().optional(), @@ -97,6 +98,7 @@ export async function createHealthCheck( const { name, + siteId, hcEnabled, hcMode, hcHostname, @@ -120,6 +122,7 @@ export async function createHealthCheck( .values({ targetId: null, orgId, + siteId, name, hcEnabled, hcMode, diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index f010c8ed7..5b1ddcfb0 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -43,7 +43,7 @@ export async function getHealthCheckStatusHistory( } const entityType = "healthCheck"; - const entityId = parsedParams.data.healthCheckId + const entityId = parsedParams.data.healthCheckId; const { days } = parsedQuery.data; const nowSec = Math.floor(Date.now() / 1000); diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index e266441b2..e156573e4 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { db, targetHealthCheck, targets, resources } from "@server/db"; +import { db, targetHealthCheck, targets, resources, sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -97,6 +97,9 @@ export async function listHealthChecks( .select({ targetHealthCheckId: targetHealthCheck.targetHealthCheckId, name: targetHealthCheck.name, + siteId: targetHealthCheck.siteId, + siteName: sites.name, + siteNiceId: sites.niceId, hcEnabled: targetHealthCheck.hcEnabled, hcHealth: targetHealthCheck.hcHealth, hcMode: targetHealthCheck.hcMode, @@ -121,6 +124,7 @@ export async function listHealthChecks( .from(targetHealthCheck) .leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId)) .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId)) .where(whereClause) .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .limit(limit) @@ -136,6 +140,9 @@ export async function listHealthChecks( healthChecks: list.map((row) => ({ targetHealthCheckId: row.targetHealthCheckId, name: row.name ?? "", + siteId: row.siteId ?? null, + siteName: row.siteName ?? null, + siteNiceId: row.siteNiceId ?? null, hcEnabled: row.hcEnabled, hcHealth: (row.hcHealth ?? "unknown") as | "unknown" diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index c5a0759b7..e64ea220e 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -34,6 +34,7 @@ const paramsSchema = z const bodySchema = z.strictObject({ name: z.string().nonempty().optional(), + siteId: z.number().int().positive().optional(), hcEnabled: z.boolean().optional(), hcMode: z.string().optional(), hcHostname: z.string().optional(), @@ -55,6 +56,7 @@ const bodySchema = z.strictObject({ export type UpdateHealthCheckResponse = { targetHealthCheckId: number; name: string | null; + siteId: number | null; hcEnabled: boolean; hcHealth: string | null; hcMode: string | null; @@ -145,6 +147,7 @@ export async function updateHealthCheck( const { name, + siteId, hcEnabled, hcMode, hcHostname, @@ -166,6 +169,7 @@ export async function updateHealthCheck( const updateData: Record = {}; if (name !== undefined) updateData.name = name; + if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; if (hcMode !== undefined) updateData.hcMode = hcMode; if (hcHostname !== undefined) updateData.hcHostname = hcHostname; @@ -206,6 +210,7 @@ export async function updateHealthCheck( return response(res, { data: { targetHealthCheckId: updated.targetHealthCheckId, + siteId: updated.siteId ?? null, name: updated.name ?? null, hcEnabled: updated.hcEnabled, hcHealth: updated.hcHealth ?? null, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 7d4a724ea..ea7512b9c 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -230,6 +230,7 @@ export async function createTarget( .values({ orgId: resource.orgId, targetId: newTarget[0].targetId, + siteId: targetData.siteId, name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index e42ce98a1..52759bfc8 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -228,6 +228,7 @@ export async function updateTarget( const [updatedHc] = await db .update(targetHealthCheck) .set({ + siteId: parsedBody.data.siteId, hcEnabled: parsedBody.data.hcEnabled || false, hcPath: parsedBody.data.hcPath, hcScheme: parsedBody.data.hcScheme, From 7b3c10c7b0fda522b46699f176d9dc7185f46645 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 14:21:58 -0700 Subject: [PATCH 125/176] Handle crud to newt with new hcs --- .../routers/healthChecks/createHealthCheck.ts | 29 +++++- .../routers/healthChecks/deleteHealthCheck.ts | 18 +++- .../routers/healthChecks/updateHealthCheck.ts | 24 +++-- server/routers/newt/buildConfiguration.ts | 25 +++-- server/routers/newt/targets.ts | 98 ++++++++++++++++++- 5 files changed, 172 insertions(+), 22 deletions(-) diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 90993015d..ff5495e55 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -13,13 +13,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -143,6 +145,31 @@ export async function createHealthCheck( }) .returning(); + // Push health check to newt if the site is a newt site + if (siteId) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck( + newt.newtId, + record, + newt.version + ); + } + } + } + return response(res, { data: { targetHealthCheckId: record.targetHealthCheckId diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts index b65e4a701..530653aab 100644 --- a/server/private/routers/healthChecks/deleteHealthCheck.ts +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; +import { removeStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z .object({ @@ -91,6 +92,21 @@ export async function deleteHealthCheck( ) ); + // Remove health check from newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, existing.siteId)) + .limit(1); + + if (newt) { + await removeStandaloneHealthCheck( + newt.newtId, + healthCheckId, + newt.version + ); + } + return response(res, { data: null, success: true, diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index e64ea220e..713bf1e03 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; +import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z .object({ @@ -127,10 +128,7 @@ export async function updateHealthCheck( .from(targetHealthCheck) .where( and( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheckId - ), + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), eq(targetHealthCheck.orgId, orgId), isNull(targetHealthCheck.targetId) ) @@ -197,16 +195,24 @@ export async function updateHealthCheck( .set(updateData) .where( and( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheckId - ), + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), eq(targetHealthCheck.orgId, orgId), isNull(targetHealthCheck.targetId) ) ) .returning(); + // Push updated health check to newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, updated.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck(newt.newtId, updated, newt.version); + } + return response(res, { data: { targetHealthCheckId: updated.targetHealthCheckId, diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index cca69dd61..20f4bd95d 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -205,7 +205,14 @@ export async function buildTargetConfigurationForNewtClient( port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - protocol: resources.protocol, + protocol: resources.protocol + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + + const allHealthChecks = await db + .select({ targetHealthCheckId: targetHealthCheck.targetHealthCheckId, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, @@ -224,13 +231,13 @@ export async function buildTargetConfigurationForNewtClient( hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold }) - .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.siteId, siteId), + eq(targetHealthCheck.hcEnabled, true) + ) + ); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -254,7 +261,7 @@ export async function buildTargetConfigurationForNewtClient( { tcpTargets: [] as string[], udpTargets: [] as string[] } ); - const healthCheckTargets = allTargets.map((target) => { + const healthCheckTargets = allHealthChecks.map((target) => { // make sure the stuff is defined const isTCP = target.hcMode?.toLowerCase() === "tcp"; if (!target.hcHostname || !target.hcPort || !target.hcInterval) { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index a28ef4f91..c7253060f 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -7,7 +7,9 @@ import semver from "semver"; const NEWT_V2_TARGET_HEALTH_CHECK_VERSION = ">=1.12.0"; export function supportsTargetHealthChecksV2(version?: string | null) { - return version ? semver.satisfies(version, NEWT_V2_TARGET_HEALTH_CHECK_VERSION) : false; + return version + ? semver.satisfies(version, NEWT_V2_TARGET_HEALTH_CHECK_VERSION) + : false; } export async function addTargets( @@ -90,7 +92,9 @@ export async function addTargets( } return { - id: supportsTargetHealthChecksV2(version) ? target.targetId : hc.targetHealthCheckId, + id: supportsTargetHealthChecksV2(version) + ? target.targetId + : hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, @@ -127,6 +131,96 @@ export async function addTargets( ); } +export async function addStandaloneHealthCheck( + newtId: string, + healthCheck: TargetHealthCheck, + version?: string | null +) { + const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp"; + if ( + !healthCheck.hcHostname || + !healthCheck.hcPort || + !healthCheck.hcInterval + ) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields` + ); + return; + } + if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields` + ); + return; + } + + const hcHeadersParse = healthCheck.hcHeaders + ? JSON.parse(healthCheck.hcHeaders) + : null; + const hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + hcHeadersParse.forEach((header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + }); + } + + let hcStatus: number | undefined = undefined; + if (healthCheck.hcStatus) { + const parsedStatus = parseInt(healthCheck.hcStatus.toString()); + if (!isNaN(parsedStatus)) { + hcStatus = parsedStatus; + } + } + + await sendToClient( + newtId, + { + type: `newt/healthcheck/add`, + data: { + targets: [ + { + id: healthCheck.targetHealthCheckId, + hcEnabled: healthCheck.hcEnabled, + hcPath: healthCheck.hcPath, + hcScheme: healthCheck.hcScheme, + hcMode: healthCheck.hcMode, + hcHostname: healthCheck.hcHostname, + hcPort: healthCheck.hcPort, + hcInterval: healthCheck.hcInterval, + hcUnhealthyInterval: healthCheck.hcUnhealthyInterval, + hcTimeout: healthCheck.hcTimeout, + hcHeaders: hcHeadersSend, + hcFollowRedirects: healthCheck.hcFollowRedirects, + hcMethod: healthCheck.hcMethod, + hcStatus: hcStatus, + hcTlsServerName: healthCheck.hcTlsServerName, + hcHealthyThreshold: healthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold + } + ] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + +export async function removeStandaloneHealthCheck( + newtId: string, + healthCheckId: number, + version?: string | null +) { + await sendToClient( + newtId, + { + type: `newt/healthcheck/remove`, + data: { + ids: [healthCheckId] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + export async function removeTargets( newtId: string, targets: Target[], From dc299a740b9ff383444b5a135271629b198277ca Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 14:34:28 -0700 Subject: [PATCH 126/176] Add the site to the ui and allow picking --- server/routers/healthChecks/types.ts | 3 ++ src/components/HealthCheckCredenza.tsx | 52 ++++++++++++++++++++++++++ src/components/HealthChecksTable.tsx | 21 +++++++++++ src/lib/queries.ts | 3 ++ 4 files changed, 79 insertions(+) diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts index d8395c593..0def60833 100644 --- a/server/routers/healthChecks/types.ts +++ b/server/routers/healthChecks/types.ts @@ -2,6 +2,9 @@ export type ListHealthChecksResponse = { healthChecks: { targetHealthCheckId: number; name: string; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; hcEnabled: boolean; hcHealth: "unknown" | "healthy" | "unhealthy"; hcMode: string | null; diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index f29fccccd..671a16e7d 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -41,6 +41,11 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { SitesSelector } from "@app/components/site-selector"; +import type { Selectedsite } from "@app/components/site-selector"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { cn } from "@app/lib/cn"; export type HealthCheckConfig = { hcEnabled: boolean; @@ -84,6 +89,9 @@ export type HealthCheckRow = { resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; }; export type HealthCheckCredenzaProps = @@ -132,6 +140,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [selectedSite, setSelectedSite] = useState(null); const healthCheckSchema = z .object({ @@ -280,8 +289,14 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { hcStatus: initialValues.hcStatus ?? null, hcHeaders: parsedHeaders }); + if (initialValues.siteId && initialValues.siteName) { + setSelectedSite({ siteId: initialValues.siteId, name: initialValues.siteName, type: "" }); + } else { + setSelectedSite(null); + } } else { form.reset(DEFAULT_VALUES); + setSelectedSite(null); } } }, [open]); @@ -331,6 +346,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { try { const payload = { name: (values as any).name, + siteId: selectedSite?.siteId, hcEnabled: values.hcEnabled, hcMode: values.hcMode, hcScheme: values.hcScheme, @@ -439,6 +455,42 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { /> )} + {/* Site picker (submit mode only) */} + {mode === "submit" && ( +
+ + {t("site")} + + + + + + { + setSelectedSite(site); + }} + /> + + + +
+ )} +
( + Site + ), + cell: ({ row }) => { + const r = row.original; + if (!r.siteId || !r.siteName || !r.siteNiceId) { + return -; + } + return ( + + + + ); + } + }, { id: "health", friendlyName: t("standaloneHcColumnHealth"), diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 29bca39ce..1e7074e3a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -335,6 +335,9 @@ export const orgQueries = { healthChecks: { targetHealthCheckId: number; name: string; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; hcEnabled: boolean; hcHealth: "unknown" | "healthy" | "unhealthy"; hcMode: string | null; From ff1ca7eafb08f193933ad67b740ca4b1dbe6ed48 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 14:56:25 -0700 Subject: [PATCH 127/176] Just use the targetHealthCheckId as the id --- server/lib/blueprints/proxyResources.ts | 1 + server/routers/newt/buildConfiguration.ts | 12 +-- server/routers/newt/targets.ts | 13 +-- .../target/handleHealthcheckStatusMessage.ts | 101 +++++------------- 4 files changed, 31 insertions(+), 96 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 237238910..175c8c79f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -141,6 +141,7 @@ export async function updateProxyResources( .insert(targetHealthCheck) .values({ name: `${targetData.hostname}:${targetData.port}`, + siteId: site.siteId, targetId: newTarget.targetId, orgId: orgId, hcEnabled: healthcheckData?.enabled || false, diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 20f4bd95d..f87d38450 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -21,7 +21,6 @@ import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip"; -import { supportsTargetHealthChecksV2 } from "./targets"; export async function buildClientConfigurationForNewtClient( site: Site, @@ -232,12 +231,7 @@ export async function buildTargetConfigurationForNewtClient( hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold }) .from(targetHealthCheck) - .where( - and( - eq(targetHealthCheck.siteId, siteId), - eq(targetHealthCheck.hcEnabled, true) - ) - ); + .where(eq(targetHealthCheck.siteId, siteId)); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -285,9 +279,7 @@ export async function buildTargetConfigurationForNewtClient( } return { - id: supportsTargetHealthChecksV2(version) - ? target.targetId - : target.targetHealthCheckId, + id: target.targetHealthCheckId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index c7253060f..25b520854 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -2,15 +2,6 @@ import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; -import semver from "semver"; - -const NEWT_V2_TARGET_HEALTH_CHECK_VERSION = ">=1.12.0"; - -export function supportsTargetHealthChecksV2(version?: string | null) { - return version - ? semver.satisfies(version, NEWT_V2_TARGET_HEALTH_CHECK_VERSION) - : false; -} export async function addTargets( newtId: string, @@ -92,9 +83,7 @@ export async function addTargets( } return { - id: supportsTargetHealthChecksV2(version) - ? target.targetId - : hc.targetHealthCheckId, + id: hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index b35ab8ed2..55834d926 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -14,7 +14,6 @@ import { fireHealthCheckHealthyAlert, fireHealthCheckNotHealthyAlert } from "#dynamic/lib/alerts"; -import { supportsTargetHealthChecksV2 } from "@server/routers/newt/targets"; interface TargetHealthStatus { status: string; @@ -74,8 +73,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( let successCount = 0; let errorCount = 0; - const isV2 = supportsTargetHealthChecksV2(newt.version); - // Process each target status update for (const [targetId, healthStatus] of Object.entries(data.targets)) { logger.debug( @@ -91,78 +88,34 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } - let targetCheck: { - targetId: number; - siteId: number | null; - orgId: string | null; - targetHealthCheckId: number; - resourceOrgId: string | null; - resourceId: number | null; - name: string | null; - hcStatus: string | null; - } | undefined; - - if (isV2) { - // New newt (>= 1.12.0): the key is the targetId - [targetCheck] = await db - .select({ - targetId: targets.targetId, - siteId: targets.siteId, - orgId: targetHealthCheck.orgId, - targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceOrgId: resources.orgId, - resourceId: resources.resourceId, - name: targetHealthCheck.name, - hcStatus: targetHealthCheck.hcHealth - }) - .from(targets) - .innerJoin( - resources, - eq(targets.resourceId, resources.resourceId) + const [targetCheck] = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + orgId: targetHealthCheck.orgId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceOrgId: resources.orgId, + resourceId: resources.resourceId, + name: targetHealthCheck.name, + hcStatus: targetHealthCheck.hcHealth + }) + .from(targetHealthCheck) + .innerJoin( + targets, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .innerJoin( + resources, + eq(targets.resourceId, resources.resourceId) + ) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, targetIdNum), + eq(sites.siteId, newt.siteId) ) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .innerJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where( - and( - eq(targets.targetId, targetIdNum), - eq(sites.siteId, newt.siteId) - ) - ) - .limit(1); - } else { - // Old newt (< 1.12.0): the key is the targetHealthCheckId - [targetCheck] = await db - .select({ - targetId: targets.targetId, - siteId: targets.siteId, - orgId: targetHealthCheck.orgId, - targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceOrgId: resources.orgId, - resourceId: resources.resourceId, - name: targetHealthCheck.name, - hcStatus: targetHealthCheck.hcHealth - }) - .from(targetHealthCheck) - .innerJoin( - targets, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .innerJoin( - resources, - eq(targets.resourceId, resources.resourceId) - ) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .where( - and( - eq(targetHealthCheck.targetHealthCheckId, targetIdNum), - eq(sites.siteId, newt.siteId) - ) - ) - .limit(1); - } + ) + .limit(1); if (!targetCheck) { logger.warn( From 177ce20dda648c3c1f08af476419a46679941463 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 15:02:56 -0700 Subject: [PATCH 128/176] remove graph --- messages/en-US.json | 4 +- package.json | 1 - .../settings/alerting/[ruleId]/page.tsx | 35 +- .../[orgId]/settings/alerting/create/page.tsx | 23 +- .../alert-rule-editor/AlertRuleFields.tsx | 2 +- .../AlertRuleGraphEditor.tsx | 824 +++++------------- src/lib/alertRuleForm.ts | 19 +- 7 files changed, 253 insertions(+), 655 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 24446f5c8..d66e89c4b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1364,8 +1364,8 @@ "alertingDeleteRule": "Delete alert rule", "alertingRuleDeleted": "Alert rule deleted", "alertingRuleSaved": "Alert rule saved", - "alertingEditRule": "Edit alert rule", - "alertingCreateRule": "Create alert rule", + "alertingEditRule": "Edit Alert Rule", + "alertingCreateRule": "Create Alert Rule", "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.", "alertingRuleNamePlaceholder": "Production site down", "alertingRuleEnabled": "Rule enabled", diff --git a/package.json b/package.json index 596bc91c0..7d7b3df69 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", - "@xyflow/react": "^12.8.4", "arctic": "3.7.0", "axios": "1.13.5", "better-sqlite3": "11.9.1", diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 50c612bbf..86d455db7 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; import { apiResponseToFormValues } from "@app/lib/alertRuleForm"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -59,9 +60,15 @@ export default function EditAlertRulePage() { if (formValues === undefined) { return ( -
- {t("loading")} -
+ <> + +
+ {t("loading")} +
+ ); } @@ -70,13 +77,19 @@ export default function EditAlertRulePage() { } return ( - + <> + + + ); } diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx index babc018fa..9f3f20611 100644 --- a/src/app/[orgId]/settings/alerting/create/page.tsx +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -1,23 +1,32 @@ "use client"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; import { defaultFormValues } from "@app/lib/alertRuleForm"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; export default function NewAlertRulePage() { const params = useParams(); const orgId = params.orgId as string; + const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); return ( - + <> + + + ); -} \ No newline at end of file +} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index e92980171..8ec323261 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -93,7 +93,7 @@ export function AddActionPanel({ const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState("notify"); const isPremiumSelected = selected !== null && EXTERNAL_IDS.includes(selected as any); diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 6d7d8d076..70667cc2e 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -8,13 +8,7 @@ import { } from "@app/components/alert-rule-editor/AlertRuleFields"; import { SettingsContainer } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from "@app/components/ui/card"; +import { Card, CardContent } from "@app/components/ui/card"; import { Form, FormControl, @@ -24,291 +18,35 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { buildFormSchema, defaultFormValues, formValuesToApiPayload, - type AlertRuleFormAction, type AlertRuleFormValues } from "@app/lib/alertRuleForm"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { CreateAlertRuleResponse } from "@server/private/routers/alertRule"; import type { AxiosResponse } from "axios"; -import { cn } from "@app/lib/cn"; -import { - Background, - Handle, - Position, - ReactFlow, - ReactFlowProvider, - useEdgesState, - useNodesState, - type Edge, - type Node, - type NodeProps, - type NodeTypes -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Check, ChevronLeft } from "lucide-react"; +import { ChevronLeft, Cog, Flag, Zap } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useFieldArray, useForm, useWatch } from "react-hook-form"; +import { useMemo, useState, type ReactNode } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; import { useTranslations } from "next-intl"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { SwitchInput } from "@app/components/SwitchInput"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -type AlertRuleT = ReturnType; +const FORM_ID = "alert-rule-form"; -export type AlertStepId = "source" | "trigger" | "actions"; - -type AlertStepNodeData = { - roleLabel: string; - title: string; - subtitle: string; - configured: boolean; - accent: string; - topBorderClass: string; +type StepAccent = { + labelClass: string; + icon: typeof Flag; }; -function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) { - if (v.sourceType === "site") { - if (v.siteIds.length === 0) { - return t("alertingNodeNotConfigured"); - } - return t("alertingSummarySites", { count: v.siteIds.length }); - } - if (v.sourceType === "resource") { - if (v.resourceIds.length === 0) { - return t("alertingNodeNotConfigured"); - } - return t("alertingSummaryResources", { count: v.resourceIds.length }); - } - if (v.healthCheckIds.length === 0) { - return t("alertingNodeNotConfigured"); - } - return t("alertingSummaryHealthChecks", { count: v.healthCheckIds.length }); -} - -function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { - switch (v.trigger) { - case "site_online": - return t("alertingTriggerSiteOnline"); - case "site_offline": - return t("alertingTriggerSiteOffline"); - case "site_toggle": - return t("alertingTriggerSiteToggle"); - case "health_check_healthy": - return t("alertingTriggerHcHealthy"); - case "health_check_unhealthy": - return t("alertingTriggerHcUnhealthy"); - case "health_check_toggle": - return t("alertingTriggerHcToggle"); - case "resource_healthy": - return t("alertingTriggerResourceHealthy"); - case "resource_unhealthy": - return t("alertingTriggerResourceUnhealthy"); - case "resource_toggle": - return t("alertingTriggerResourceToggle"); - default: - return v.trigger; - } -} - -function oneActionConfigured(a: AlertRuleFormAction): boolean { - if (a.type === "notify") { - return ( - a.userTags.length > 0 || - a.roleTags.length > 0 || - a.emailTags.length > 0 - ); - } - - try { - new URL(a.url.trim()); - return true; - } catch { - return false; - } -} - -function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string { - switch (a.type) { - case "notify": - return t("alertingActionNotify"); - case "webhook": - return t("alertingActionWebhook"); - } -} - -function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string { - if (a.type === "notify") { - if ( - a.userTags.length === 0 && - a.roleTags.length === 0 && - a.emailTags.length === 0 - ) { - return t("alertingNodeNotConfigured"); - } - const parts: string[] = []; - if (a.userTags.length > 0) { - parts.push(t("alertingUsersSelected", { count: a.userTags.length })); - } - if (a.roleTags.length > 0) { - parts.push(t("alertingRolesSelected", { count: a.roleTags.length })); - } - if (a.emailTags.length > 0) { - parts.push( - `${t("alertingNotifyEmails")} (${a.emailTags.length})` - ); - } - return parts.join(" · "); - } - const url = a.url.trim(); - if (!url) { - return t("alertingNodeNotConfigured"); - } - try { - return new URL(url).hostname; - } catch { - return t("alertingNodeNotConfigured"); - } -} - -function stepConfigured( - step: "source" | "trigger", - v: AlertRuleFormValues -): boolean { - if (step === "source") { - return v.sourceType === "site" - ? v.siteIds.length > 0 - : v.healthCheckIds.length > 0; - } - return Boolean(v.trigger); -} - -function buildActionStepNodeData( - index: number, - action: AlertRuleFormAction, - t: AlertRuleT -): AlertStepNodeData { - return { - roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`, - title: actionTypeLabel(action, t), - subtitle: summarizeOneAction(action, t), - configured: oneActionConfigured(action), - accent: "text-amber-600 dark:text-amber-400", - topBorderClass: "border-t-amber-500" - }; -} - -function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData { - return { - roleLabel: t("alertingNodeRoleAction"), - title: t("alertingSectionActions"), - subtitle: t("alertingNodeNotConfigured"), - configured: false, - accent: "text-amber-600 dark:text-amber-400", - topBorderClass: "border-t-amber-500" - }; -} - -const AlertStepNode = memo(function AlertStepNodeFn({ - data, - selected -}: NodeProps>) { - return ( -
- - {data.configured && ( - - )} -

- {data.roleLabel} -

-

{data.title}

-

- {data.subtitle} -

- -
- ); -}); - -const nodeTypes: NodeTypes = { - alertStep: AlertStepNode -}; - -const ACTION_NODE_X_GAP = 280; -const ACTION_NODE_Y = 468; -const SOURCE_NODE_POS = { x: 120, y: 28 }; -const TRIGGER_NODE_POS = { x: 120, y: 248 }; - -function buildNodeData( - stepId: "source" | "trigger", - v: AlertRuleFormValues, - t: AlertRuleT -): AlertStepNodeData { - const accents: Record< - "source" | "trigger", - { accent: string; topBorderClass: string; role: string; title: string } - > = { - source: { - accent: "text-blue-600 dark:text-blue-400", - topBorderClass: "border-t-blue-500", - role: t("alertingNodeRoleSource"), - title: t("alertingSectionSource") - }, - trigger: { - accent: "text-emerald-600 dark:text-emerald-400", - topBorderClass: "border-t-emerald-500", - role: t("alertingNodeRoleTrigger"), - title: t("alertingSectionTrigger") - } - }; - const meta = accents[stepId]; - const subtitle = - stepId === "source" - ? summarizeSource(v, t) - : summarizeTrigger(v, t); - return { - roleLabel: meta.role, - title: meta.title, - subtitle, - configured: stepConfigured(stepId, v), - accent: meta.accent, - topBorderClass: meta.topBorderClass - }; -} - type AlertRuleGraphEditorProps = { orgId: string; alertRuleId?: number; @@ -317,7 +55,53 @@ type AlertRuleGraphEditorProps = { disabled?: boolean; }; -const FORM_ID = "alert-rule-graph-form"; +function VerticalRuleStep({ + stepNumber, + isLast, + title, + accent, + children +}: { + stepNumber: number; + isLast: boolean; + title: string; + accent: StepAccent; + children: ReactNode; +}) { + const Icon = accent.icon; + return ( +
  • +
    +
    + {stepNumber} +
    + {!isLast && ( +
    + )} +
    +
    +
    + + {title} +
    +
    + {children} +
    +
    +
  • + ); +} export default function AlertRuleGraphEditor({ orgId, @@ -341,180 +125,6 @@ export default function AlertRuleGraphEditor({ name: "actions" }); - const wName = useWatch({ control: form.control, name: "name" }) ?? ""; - const wEnabled = - useWatch({ control: form.control, name: "enabled" }) ?? true; - const wSourceType = - useWatch({ control: form.control, name: "sourceType" }) ?? "site"; - const wAllSites = - useWatch({ control: form.control, name: "allSites" }) ?? true; - const wSiteIds = - useWatch({ control: form.control, name: "siteIds" }) ?? []; - const wAllHealthChecks = - useWatch({ control: form.control, name: "allHealthChecks" }) ?? true; - const wHealthCheckIds = - useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; - const wAllResources = - useWatch({ control: form.control, name: "allResources" }) ?? true; - const wResourceIds = - useWatch({ control: form.control, name: "resourceIds" }) ?? []; - const wTrigger = - useWatch({ control: form.control, name: "trigger" }) ?? - "site_toggle"; - const wActions = - useWatch({ control: form.control, name: "actions" }) ?? []; - - const flowValues: AlertRuleFormValues = useMemo( - () => ({ - name: wName, - enabled: wEnabled, - sourceType: wSourceType, - allSites: wAllSites, - siteIds: wSiteIds, - allHealthChecks: wAllHealthChecks, - healthCheckIds: wHealthCheckIds, - allResources: wAllResources, - resourceIds: wResourceIds, - trigger: wTrigger, - actions: wActions - }), - [ - wName, - wEnabled, - wSourceType, - wAllSites, - wSiteIds, - wAllHealthChecks, - wHealthCheckIds, - wAllResources, - wResourceIds, - wTrigger, - wActions - ] - ); - - const [selectedStep, setSelectedStep] = useState("source"); - - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - - const nodesSyncKeyRef = useRef(""); - useEffect(() => { - const key = JSON.stringify({ flowValues, selectedStep }); - if (key === nodesSyncKeyRef.current) { - return; - } - nodesSyncKeyRef.current = key; - - const nActions = flowValues.actions.length; - const actionNodes: Node[] = - nActions === 0 - ? [ - { - id: "actions", - type: "alertStep", - position: { - x: TRIGGER_NODE_POS.x, - y: ACTION_NODE_Y - }, - data: buildActionsPlaceholderNodeData(t), - selected: - selectedStep === "actions" || - selectedStep.startsWith("action-") - } - ] - : flowValues.actions.map((action, i) => { - const totalWidth = - (nActions - 1) * ACTION_NODE_X_GAP; - const originX = - TRIGGER_NODE_POS.x - totalWidth / 2; - return { - id: `action-${i}`, - type: "alertStep", - position: { - x: originX + i * ACTION_NODE_X_GAP, - y: ACTION_NODE_Y - }, - data: buildActionStepNodeData(i, action, t), - selected: selectedStep === `action-${i}` - }; - }); - - setNodes([ - { - id: "source", - type: "alertStep", - position: SOURCE_NODE_POS, - data: buildNodeData("source", flowValues, t), - selected: selectedStep === "source" - }, - { - id: "trigger", - type: "alertStep", - position: TRIGGER_NODE_POS, - data: buildNodeData("trigger", flowValues, t), - selected: selectedStep === "trigger" - }, - ...actionNodes - ]); - - const nextEdges: Edge[] = [ - { - id: "e-src-trg", - source: "source", - target: "trigger", - animated: true - }, - ...(nActions === 0 - ? [ - { - id: "e-trg-act", - source: "trigger", - target: "actions", - animated: true - } as const - ] - : flowValues.actions.map((_, i) => ({ - id: `e-trg-act-${i}`, - source: "trigger", - target: `action-${i}`, - animated: true - }))) - ]; - setEdges(nextEdges); - }, [flowValues, selectedStep, t, setNodes, setEdges]); - - useEffect(() => { - if (selectedStep === "actions" && wActions.length > 0) { - setSelectedStep("action-0"); - } - }, [selectedStep, wActions.length]); - - useEffect(() => { - if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) { - setSelectedStep("actions"); - } - }, [wActions.length, selectedStep]); - - useEffect(() => { - const m = /^action-(\d+)$/.exec(selectedStep); - if (!m) { - return; - } - const i = parseInt(m[1], 10); - if (i >= wActions.length) { - setSelectedStep( - wActions.length > 0 - ? `action-${wActions.length - 1}` - : "actions" - ); - } - }, [wActions.length, selectedStep]); - - const onNodeClick = useCallback((_event: unknown, node: Node) => { - setSelectedStep(node.id); - }, []); - const onSubmit = form.handleSubmit(async (values) => { setIsSaving(true); try { @@ -545,173 +155,158 @@ export default function AlertRuleGraphEditor({ } }); - const isActionsSidebar = - selectedStep === "actions" || selectedStep.startsWith("action-"); - - const sidebarTitle = isActionsSidebar - ? t("alertingConfigureActions") - : selectedStep === "source" - ? t("alertingConfigureSource") - : t("alertingConfigureTrigger"); - return (
    - - -
    -
    -
    - - {isNew && ( - - {t("alertingDraftBadge")} - - )} -
    - ( - - - {t("name")} - - - - - - - )} - /> -
    - ( - - - {t("alertingRuleEnabled")} - - - - - - )} - /> - -
    -
    -
    -
    -
    - -
    - - - - {t("alertingGraphCanvasTitle")} - - - {t("alertingGraphCanvasDescription")} - - - -
    - - +
    -
    -
    + {isSaving ? t("saving") : t("save")} + + + + + - - - - {sidebarTitle} - - - {t("alertingSidebarHint")} - - - -
    -
    - {selectedStep === "source" && ( - - )} - {selectedStep === "trigger" && ( - - )} - {isActionsSidebar && ( -
    - - {t("alertingSectionActions")} - +
    +
      + +
      + +
      +
      + +
      + +
      +
      + +
      +
      { - const newIndex = - fields.length; if (type === "notify") { append({ type: "notify", @@ -732,14 +327,14 @@ export default function AlertRuleGraphEditor({ ], authType: "none", bearerToken: "", - basicCredentials: "", - customHeaderName: "", - customHeaderValue: "" + basicCredentials: + "", + customHeaderName: + "", + customHeaderValue: + "" }); } - setSelectedStep( - `action-${newIndex}` - ); }} /> {fields.map((f, index) => ( @@ -759,11 +354,10 @@ export default function AlertRuleGraphEditor({ /> ))}
      - )} -
    -
    -
    -
    + + + +
    diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index f7f96e927..115c9fcf5 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -317,14 +317,7 @@ export function defaultFormValues(): AlertRuleFormValues { allResources: true, resourceIds: [], trigger: "site_toggle", - actions: [ - { - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - } - ] + actions: [] }; } @@ -379,16 +372,6 @@ export function apiResponseToFormValues( }); } - // Always ensure at least one action so the form is valid - if (actions.length === 0) { - actions.push({ - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - }); - } - const allSites = sourceType === "site" && rule.siteIds.length === 0; const allHealthChecks = sourceType === "health_check" && rule.healthCheckIds.length === 0; From f9a4e25dc988c98b356f75dd5ffd65ec795cde37 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 15:30:49 -0700 Subject: [PATCH 129/176] more cosmetic changes to alert rules --- messages/en-US.json | 9 ++- src/components/AlertingRulesTable.tsx | 65 ++++++++++------- .../alert-rule-editor/AlertRuleFields.tsx | 5 +- .../AlertRuleGraphEditor.tsx | 69 ++++++++++++------- 4 files changed, 91 insertions(+), 57 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a0e789cdf..ee912732e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1375,9 +1375,11 @@ "alertingDeleteRule": "Delete alert rule", "alertingRuleDeleted": "Alert rule deleted", "alertingRuleSaved": "Alert rule saved", + "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", + "alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", "alertingEditRule": "Edit Alert Rule", "alertingCreateRule": "Create Alert Rule", - "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.", + "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", "alertingRuleNamePlaceholder": "Production site down", "alertingRuleEnabled": "Rule enabled", "alertingSectionSource": "Source", @@ -1417,7 +1419,7 @@ "alertingTriggerResourceToggle": "Resource status changes", "alertingSourceResource": "Resource", "alertingSectionActions": "Actions", - "alertingAddAction": "Add action", + "alertingAddAction": "Add Action", "alertingActionNotify": "Email", "alertingActionNotifyDescription": "Send email notifications to users or roles", "alertingActionWebhook": "Webhook", @@ -3112,5 +3114,6 @@ "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", "idpUnassociateMenu": "Unassociate", "idpDeleteAllOrgsMenu": "Delete", - "publicIpEndpoint": "Endpoint" + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Last Trigger" } diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index ea67b6b73..b1b984c96 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -66,17 +66,16 @@ function sourceSummary( return t("alertingSummarySites", { count: rule.siteIds.length }); } if (rule.eventType.startsWith("resource_")) { - return t("alertingSummaryResources", { count: rule.resourceIds.length }); + return t("alertingSummaryResources", { + count: rule.resourceIds.length + }); } return t("alertingSummaryHealthChecks", { count: rule.healthCheckIds.length }); } -function triggerLabel( - rule: AlertRuleRow, - t: (k: string) => string -) { +function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { switch (rule.eventType) { case "site_online": return t("alertingTriggerSiteOnline"); @@ -101,7 +100,11 @@ function triggerLabel( } } -export default function AlertingRulesTable({ orgId, siteId, resourceId }: AlertingRulesTableProps) { +export default function AlertingRulesTable({ + orgId, + siteId, + resourceId +}: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -124,18 +127,26 @@ export default function AlertingRulesTable({ orgId, siteId, resourceId }: Alerti const pageIndex = page - 1; const query = searchParams.get("query") ?? undefined; - const { - data, - isLoading, - refetch, - isRefetching - } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query, siteId, resourceId })); + const { data, isLoading, refetch, isRefetching } = useQuery( + orgQueries.alertRules({ + orgId, + limit: pageSize, + offset: pageIndex * pageSize, + query, + siteId, + resourceId + }) + ); const rows = data?.alertRules ?? []; const total = data?.pagination.total ?? 0; const pageCount = Math.max(1, Math.ceil(total / pageSize)); - const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount }; + const paginationState: DataTablePaginationState = { + pageIndex, + pageSize, + pageCount + }; const handlePaginationChange = (newState: PaginationState) => { searchParams.set("page", (newState.pageIndex + 1).toString()); @@ -154,7 +165,9 @@ export default function AlertingRulesTable({ orgId, siteId, resourceId }: Alerti }, 300); const invalidate = () => - queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] }); + queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "ALERT_RULES"] + }); const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { setTogglingId(rule.alertRuleId); @@ -210,9 +223,7 @@ export default function AlertingRulesTable({ orgId, siteId, resourceId }: Alerti ), - cell: ({ row }) => ( - {row.original.name} - ) + cell: ({ row }) => {row.original.name} }, { id: "source", @@ -230,6 +241,18 @@ export default function AlertingRulesTable({ orgId, siteId, resourceId }: Alerti ), cell: ({ row }) => {triggerLabel(row.original, t)} }, + { + accessorKey: "lastTriggeredAt", + friendlyName: t("lastTriggeredAt"), + header: () => {t("lastTriggeredAt")}, + cell: ({ row }) => ( + + {row.original.lastTriggeredAt + ? moment(row.original.lastTriggeredAt).format("lll") + : "-"} + + ) + }, { accessorKey: "enabled", friendlyName: t("alertingColumnEnabled"), @@ -247,14 +270,6 @@ export default function AlertingRulesTable({ orgId, siteId, resourceId }: Alerti ); } }, - { - accessorKey: "createdAt", - friendlyName: t("createdAt"), - header: () => {t("createdAt")}, - cell: ({ row }) => ( - {moment(row.original.createdAt).format("lll")} - ) - }, { id: "rowActions", enableHiding: false, diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 8ec323261..0228b30bc 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -144,7 +144,6 @@ export function AddActionPanel({ {!isPremiumSelected && ( @@ -244,8 +249,7 @@ export default function AlertRuleGraphEditor({ isLast={false} title={t("alertingSectionSource")} accent={{ - labelClass: - "text-emerald-600 dark:text-emerald-400", + labelClass: "", icon: Flag }} > @@ -268,8 +272,7 @@ export default function AlertRuleGraphEditor({ isLast={false} title={t("alertingSectionTrigger")} accent={{ - labelClass: - "text-amber-600 dark:text-amber-400", + labelClass: "", icon: Cog }} > @@ -291,8 +294,7 @@ export default function AlertRuleGraphEditor({ isLast title={t("alertingSectionActions")} accent={{ - labelClass: - "text-blue-600 dark:text-blue-400", + labelClass: "", icon: Zap }} > @@ -337,21 +339,36 @@ export default function AlertRuleGraphEditor({ } }} /> - {fields.map((f, index) => ( - - remove(index) - } - onUpdate={(val) => - update(index, val) - } - canRemove + {fields.length > 0 && ( +
    + )} + {fields.map((f, index) => ( +
    + {index > 0 && ( +
    + )} + + remove(index) + } + onUpdate={(val) => + update(index, val) + } + canRemove + /> +
    ))}
    From e1efae74261023a61161f8b9b3f24cb1b98de7f6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 15:35:55 -0700 Subject: [PATCH 130/176] add help banners to alerts --- messages/en-US.json | 4 ++++ src/app/[orgId]/settings/alerting/page.tsx | 28 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ee912732e..4278a6aa2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1476,6 +1476,10 @@ "alertingNodeRoleAction": "Action", "alertingTabRules": "Alert Rules", "alertingTabHealthChecks": "Health Checks", + "alertingRulesBannerTitle": "Get Notified", + "alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.", + "alertingHealthChecksBannerTitle": "Monitor Health & Resources", + "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", "standaloneHcTableTitle": "Health Checks", "standaloneHcSearchPlaceholder": "Search health checks…", "standaloneHcAddButton": "Create Health Check", diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index cadc83516..945128ca0 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,7 +1,9 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import AlertingRulesTable from "@app/components/AlertingRulesTable"; import HealthChecksTable from "@app/components/HealthChecksTable"; +import DismissableBanner from "@app/components/DismissableBanner"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; +import { BellRing, HeartPulse } from "lucide-react"; import { getTranslations } from "next-intl/server"; type AlertingPageProps = { @@ -26,8 +28,30 @@ export default async function AlertingPage(props: AlertingPageProps) { description={t("alertingDescription")} /> - - +
    + + } + description={t("alertingRulesBannerDescription")} + /> + +
    +
    + + } + description={t("alertingHealthChecksBannerDescription")} + /> + +
    ); From 7d9a0cd0cccef0938b571eed6aa757f053bdaebd Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 15:35:19 -0700 Subject: [PATCH 131/176] Add 1.18 migrations --- server/lib/consts.ts | 2 +- server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.18.0.ts | 434 +++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.18.0.ts | 403 +++++++++++++++++++++++++ 5 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 server/setup/scriptsPg/1.18.0.ts create mode 100644 server/setup/scriptsSqlite/1.18.0.ts diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 8ad4f48e9..d2218e874 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.17.0"; +export const APP_VERSION = "1.18.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 9ba0b9767..992cc2583 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -22,6 +22,7 @@ import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; +import m17 from "./scriptsPg/1.18.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +44,8 @@ const migrations = [ { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, - { version: "1.17.0", run: m16 } + { version: "1.17.0", run: m16 }, + { version: "1.18.0", run: m17 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 45a29ec29..c32437aec 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -40,6 +40,7 @@ import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; +import m38 from "./scriptsSqlite/1.18.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -77,7 +78,8 @@ const migrations = [ { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, - { version: "1.17.0", run: m37 } + { version: "1.17.0", run: m37 }, + { version: "1.18.0", run: m38 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts new file mode 100644 index 000000000..9c0b7568b --- /dev/null +++ b/server/setup/scriptsPg/1.18.0.ts @@ -0,0 +1,434 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction adds the new columns (which start NULL for existing rows). + // We will delete all rows and reinsert them with targetHealthCheckId = targetId + // so the two IDs form a stable 1:1 mapping. + const healthChecksQuery = await db.execute( + sql`SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM "targetHealthCheck" thc + JOIN "targets" t ON thc."targetId" = t."targetId" + JOIN "sites" s ON t."siteId" = s."siteId"` + ); + const existingHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + hcEnabled: boolean; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "alertEmailActions" ( + "emailActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "alertEmailRecipients" ( + "recipientId" serial PRIMARY KEY NOT NULL, + "emailActionId" integer NOT NULL, + "userId" varchar, + "roleId" integer, + "email" varchar(255) + ); + `); + + await db.execute(sql` + CREATE TABLE "alertHealthChecks" ( + "alertRuleId" integer NOT NULL, + "healthCheckId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertResources" ( + "alertRuleId" integer NOT NULL, + "resourceId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertRules" ( + "alertRuleId" serial PRIMARY KEY NOT NULL, + "orgId" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "eventType" varchar(100) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "cooldownSeconds" integer DEFAULT 300 NOT NULL, + "allSites" boolean DEFAULT false NOT NULL, + "allHealthChecks" boolean DEFAULT false NOT NULL, + "allResources" boolean DEFAULT false NOT NULL, + "lastTriggeredAt" bigint, + "createdAt" bigint NOT NULL, + "updatedAt" bigint NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertSites" ( + "alertRuleId" integer NOT NULL, + "siteId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertWebhookActions" ( + "webhookActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "webhookUrl" text NOT NULL, + "config" text, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "networks" ( + "networkId" serial PRIMARY KEY NOT NULL, + "niceId" text, + "name" text, + "scope" varchar DEFAULT 'global' NOT NULL, + "orgId" varchar NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "siteNetworks" ( + "siteId" integer NOT NULL, + "networkId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "statusHistory" ( + "id" serial PRIMARY KEY NOT NULL, + "entityType" varchar NOT NULL, + "entityId" integer NOT NULL, + "orgId" varchar NOT NULL, + "status" varchar NOT NULL, + "timestamp" integer NOT NULL + ); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP CONSTRAINT "siteResources_siteId_sites_siteId_fk"; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ALTER COLUMN "targetId" DROP NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "expiresAt" bigint; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "trial" boolean DEFAULT false; + `); + + await db.execute(sql` + ALTER TABLE "requestAuditLog" ADD COLUMN "siteResourceId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "networkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "defaultNetworkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "ssl" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "scheme" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "domainId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "subdomain" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "fullDomain" varchar; + `); + + // Add orgId and siteId as nullable first; NOT NULL constraints are applied + // after the data migration below once every row has been populated. + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "orgId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "siteId" integer; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "name" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcHealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcUnhealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailActions" ADD CONSTRAINT "alertEmailActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_emailActionId_alertEmailActions_emailActionId_fk" FOREIGN KEY ("emailActionId") REFERENCES "public"."alertEmailActions"("emailActionId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_healthCheckId_targetHealthCheck_targetHealthCheckId_fk" FOREIGN KEY ("healthCheckId") REFERENCES "public"."targetHealthCheck"("targetHealthCheckId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertRules" ADD CONSTRAINT "alertRules_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertWebhookActions" ADD CONSTRAINT "alertWebhookActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "networks" ADD CONSTRAINT "networks_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "statusHistory" ADD CONSTRAINT "statusHistory_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_entity" ON "statusHistory" USING btree ("entityType","entityId","timestamp"); + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_org_timestamp" ON "statusHistory" USING btree ("orgId","timestamp"); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_defaultNetworkId_networks_networkId_fk" FOREIGN KEY ("defaultNetworkId") REFERENCES "public"."networks"("networkId") ON DELETE restrict ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "siteId"; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "protocol"; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Reinsert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is a serial (sequence-backed) column, inserting + // explicit values is allowed in PostgreSQL — the sequence is simply bypassed. + // After all inserts we advance the sequence to MAX(targetHealthCheckId) via + // setval() so future auto-inserts never collide with the explicit IDs we used. + if (existingHealthChecks.length > 0) { + try { + // Remove all existing rows first. The alertHealthChecks table is brand + // new in this migration so there are no FK references to worry about. + await db.execute(sql`DELETE FROM "targetHealthCheck"`); + + for (const hc of existingHealthChecks) { + await db.execute(sql` + INSERT INTO "targetHealthCheck" ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES ( + ${hc.targetId}, + ${hc.targetId}, + ${hc.orgId}, + ${hc.siteId}, + ${hc.hcEnabled}, + ${hc.hcPath}, + ${hc.hcScheme}, + ${hc.hcMode}, + ${hc.hcHostname}, + ${hc.hcPort}, + ${hc.hcInterval}, + ${hc.hcUnhealthyInterval}, + ${hc.hcTimeout}, + ${hc.hcHeaders}, + ${hc.hcFollowRedirects}, + ${hc.hcMethod}, + ${hc.hcStatus}, + ${hc.hcHealth}, + ${hc.hcTlsServerName} + ) + `); + } + + // Now that every row has orgId and siteId populated, enforce NOT NULL. + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "orgId" SET NOT NULL` + ); + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "siteId" SET NOT NULL` + ); + + // Advance the sequence so the next auto-insert picks up after the + // largest ID we explicitly wrote. setval(..., max, true) means the + // next nextval() call will return max + 1. + await db.execute(sql` + SELECT setval( + pg_get_serial_sequence('"targetHealthCheck"', 'targetHealthCheckId'), + (SELECT MAX("targetHealthCheckId") FROM "targetHealthCheck"), + true + ) + `); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } catch (e) { + console.error( + "Error while migrating targetHealthCheck rows:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts new file mode 100644 index 000000000..edb7f9c23 --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -0,0 +1,403 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction drops and recreates the table + const existingHealthChecks = db + .prepare( + `SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM 'targetHealthCheck' thc + JOIN 'targets' t ON thc."targetId" = t."targetId" + JOIN 'sites' s ON t."siteId" = s."siteId"` + ) + .all() as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + hcEnabled: number; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: number | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'alertEmailActions' ( + 'emailActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertEmailRecipients' ( + 'recipientId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'emailActionId' integer NOT NULL, + 'userId' text, + 'roleId' integer, + 'email' text, + FOREIGN KEY ('emailActionId') REFERENCES 'alertEmailActions'('emailActionId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertHealthChecks' ( + 'alertRuleId' integer NOT NULL, + 'healthCheckId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('healthCheckId') REFERENCES 'targetHealthCheck'('targetHealthCheckId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertResources' ( + 'alertRuleId' integer NOT NULL, + 'resourceId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertRules' ( + 'alertRuleId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'name' text NOT NULL, + 'eventType' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'cooldownSeconds' integer DEFAULT 300 NOT NULL, + 'allSites' integer DEFAULT false NOT NULL, + 'allHealthChecks' integer DEFAULT false NOT NULL, + 'allResources' integer DEFAULT false NOT NULL, + 'lastTriggeredAt' integer, + 'createdAt' integer NOT NULL, + 'updatedAt' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertSites' ( + 'alertRuleId' integer NOT NULL, + 'siteId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertWebhookActions' ( + 'webhookActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'webhookUrl' text NOT NULL, + 'config' text, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'networks' ( + 'networkId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'niceId' text, + 'name' text, + 'scope' text DEFAULT 'global' NOT NULL, + 'orgId' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'siteNetworks' ( + 'siteId' integer NOT NULL, + 'networkId' integer NOT NULL, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'statusHistory' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'entityType' text NOT NULL, + 'entityId' integer NOT NULL, + 'orgId' text NOT NULL, + 'status' text NOT NULL, + 'timestamp' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_entity' ON 'statusHistory' ('entityType','entityId','timestamp'); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_org_timestamp' ON 'statusHistory' ('orgId','timestamp'); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_siteResources' ( + 'siteResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'networkId' integer, + 'defaultNetworkId' integer, + 'niceId' text NOT NULL, + 'name' text NOT NULL, + 'ssl' integer DEFAULT false NOT NULL, + 'mode' text NOT NULL, + 'scheme' text, + 'proxyPort' integer, + 'destinationPort' integer, + 'destination' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'alias' text, + 'aliasAddress' text, + 'tcpPortRangeString' text DEFAULT '*' NOT NULL, + 'udpPortRangeString' text DEFAULT '*' NOT NULL, + 'disableIcmp' integer DEFAULT false NOT NULL, + 'authDaemonPort' integer DEFAULT 22123, + 'authDaemonMode' text DEFAULT 'site', + 'domainId' text, + 'subdomain' text, + 'fullDomain' text, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE set null, + FOREIGN KEY ('defaultNetworkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE restrict, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null + ); + ` + ).run(); + db.prepare( + ` + INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain" FROM 'siteResources'; + ` + ).run(); + db.prepare( + ` + DROP TABLE 'siteResources'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_siteResources' RENAME TO 'siteResources'; + ` + ).run(); + db.prepare( + ` + CREATE TABLE '__new_targetHealthCheck' ( + 'targetHealthCheckId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'targetId' integer, + 'orgId' text NOT NULL, + 'siteId' integer NOT NULL, + 'name' text, + 'hcEnabled' integer DEFAULT false NOT NULL, + 'hcPath' text, + 'hcScheme' text, + 'hcMode' text DEFAULT 'http', + 'hcHostname' text, + 'hcPort' integer, + 'hcInterval' integer DEFAULT 30, + 'hcUnhealthyInterval' integer DEFAULT 30, + 'hcTimeout' integer DEFAULT 5, + 'hcHeaders' text, + 'hcFollowRedirects' integer DEFAULT true, + 'hcMethod' text DEFAULT 'GET', + 'hcStatus' integer, + 'hcHealth' text DEFAULT 'unknown', + 'hcTlsServerName' text, + 'hcHealthyThreshold' integer DEFAULT 1, + 'hcUnhealthyThreshold' integer DEFAULT 1, + FOREIGN KEY ('targetId') REFERENCES 'targets'('targetId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + // INSERT INTO '__new_targetHealthCheck'("targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold") SELECT "targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold" FROM 'targetHealthCheck'; + db.prepare( + ` + DROP TABLE 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_targetHealthCheck' RENAME TO 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'expiresAt' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'trial' integer DEFAULT false; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'requestAuditLog' ADD 'siteResourceId' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId); + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is AUTOINCREMENT, inserting explicit values is + // allowed, but sqlite_sequence must be updated afterwards so future + // auto-increments don't reuse or collide with these IDs. + if (existingHealthChecks.length > 0) { + const insertHealthCheck = db.prepare( + `INSERT INTO 'targetHealthCheck' ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const hc of existingHealthChecks) { + insertHealthCheck.run( + hc.targetId, // targetHealthCheckId = targetId (explicit, non-sequential is fine) + hc.targetId, + hc.orgId, + hc.siteId, + hc.hcEnabled, + hc.hcPath, + hc.hcScheme, + hc.hcMode, + hc.hcHostname, + hc.hcPort, + hc.hcInterval, + hc.hcUnhealthyInterval, + hc.hcTimeout, + hc.hcHeaders, + hc.hcFollowRedirects, + hc.hcMethod, + hc.hcStatus, + hc.hcHealth, + hc.hcTlsServerName + ); + } + }); + + insertAll(); + + // Ensure sqlite_sequence reflects the true max so that future + // AUTOINCREMENT inserts never reuse one of the explicitly-set IDs. + // INSERT OR IGNORE handles the case where no auto-insert has happened + // yet and the row doesn't exist in sqlite_sequence. + db.prepare( + `INSERT OR IGNORE INTO sqlite_sequence (name, seq) VALUES ('targetHealthCheck', 0)` + ).run(); + db.prepare( + `UPDATE sqlite_sequence + SET seq = MAX(seq, (SELECT COALESCE(MAX("targetHealthCheckId"), 0) FROM 'targetHealthCheck')) + WHERE name = 'targetHealthCheck'` + ).run(); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} From 8dbe0a4bfe21c056569df34a1238d33be406829e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 15:57:15 -0700 Subject: [PATCH 132/176] add server side filter and sort to alerts --- messages/en-US.json | 2 +- .../routers/alertRule/listAlertRules.ts | 34 +++++- src/components/AlertingRulesTable.tsx | 115 +++++++++++++++--- src/components/ui/data-table.tsx | 4 + src/lib/queries.ts | 20 ++- 5 files changed, 152 insertions(+), 23 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4278a6aa2..9b14cee39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1399,7 +1399,7 @@ "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", "alertingAllResources": "All Resources", "alertingAllResourcesDescription": "Alert fires for any resource", - "alertingSpecificResources": "Specific resources", + "alertingSpecificResources": "Specific Resources", "alertingSpecificResourcesDescription": "Choose specific resources to watch", "alertingSelectResources": "Select resources…", "alertingResourcesSelected": "{count} resources selected", diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 601ab0fa3..e6a2e6381 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -21,7 +21,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -50,7 +50,10 @@ const querySchema = z.strictObject({ .string() .optional() .transform((v) => (v !== undefined ? Number(v) : undefined)) - .pipe(z.number().int().positive().optional()) + .pipe(z.number().int().positive().optional()), + sort_by: z.enum(["name", "last_triggered_at"]).optional(), + order: z.enum(["asc", "desc"]).optional().default("asc"), + enabled: z.enum(["true", "false"]).optional() }); export type ListAlertRulesResponse = { @@ -113,7 +116,16 @@ export async function listAlertRules( ) ); } - const { limit, offset, query, siteId, resourceId } = parsedQuery.data; + const { + limit, + offset, + query, + siteId, + resourceId, + sort_by, + order, + enabled: enabledFilter + } = parsedQuery.data; // Resolve siteId filter → matching alertRuleIds let siteFilterRuleIds: number[] | null = null; @@ -169,14 +181,28 @@ export async function listAlertRules( : undefined, resourceFilterRuleIds !== null ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + : undefined, + enabledFilter !== undefined + ? eq(alertRules.enabled, enabledFilter === "true") : undefined ); + const orderByClause = + sort_by === "name" + ? order === "asc" + ? asc(alertRules.name) + : desc(alertRules.name) + : sort_by === "last_triggered_at" + ? order === "asc" + ? sql`${alertRules.lastTriggeredAt} ASC NULLS FIRST` + : sql`${alertRules.lastTriggeredAt} DESC NULLS LAST` + : sql`${alertRules.createdAt} DESC`; + const list = await db .select() .from(alertRules) .where(whereClause) - .orderBy(sql`${alertRules.createdAt} DESC`) + .orderBy(orderByClause) .limit(limit) .offset(offset); diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index b1b984c96..3ddcc4c13 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -17,18 +17,31 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import moment from "moment"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useDebouncedCallback } from "use-debounce"; +const alertRulesEnabledQuerySchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + type AlertingRulesTableProps = { orgId: string; siteId?: number; @@ -126,6 +139,19 @@ export default function AlertingRulesTable({ const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); const pageIndex = page - 1; const query = searchParams.get("query") ?? undefined; + const sortBy = searchParams.get("sort_by") ?? undefined; + const order = searchParams.get("order") ?? undefined; + const enabledForQuery = alertRulesEnabledQuerySchema.parse( + searchParams.get("enabled") ?? undefined + ); + + const enabledFilterOptions = useMemo( + () => [ + { value: "true", label: t("enabled") }, + { value: "false", label: t("disabled") } + ], + [t] + ); const { data, isLoading, refetch, isRefetching } = useQuery( orgQueries.alertRules({ @@ -134,7 +160,10 @@ export default function AlertingRulesTable({ offset: pageIndex * pageSize, query, siteId, - resourceId + resourceId, + sortBy, + order, + enabled: enabledForQuery }) ); @@ -164,6 +193,22 @@ export default function AlertingRulesTable({ filter({ searchParams }); }, 300); + function toggleSort(column: string) { + filter({ + searchParams: getNextSortOrder(column, searchParams) + }); + } + + function handleEnabledFilter(value: string | undefined | null) { + const sp = new URLSearchParams(searchParams); + sp.delete("enabled"); + sp.delete("page"); + if (value) { + sp.set("enabled", value); + } + filter({ searchParams: sp }); + } + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] @@ -212,17 +257,25 @@ export default function AlertingRulesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => ( - - ), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, cell: ({ row }) => {row.original.name} }, { @@ -244,7 +297,28 @@ export default function AlertingRulesTable({ { accessorKey: "lastTriggeredAt", friendlyName: t("lastTriggeredAt"), - header: () => {t("lastTriggeredAt")}, + header: () => { + const triggerOrder = getSortDirection( + "last_triggered_at", + searchParams + ); + const Icon = + triggerOrder === "asc" + ? ArrowDown01Icon + : triggerOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, cell: ({ row }) => ( {row.original.lastTriggeredAt @@ -257,7 +331,15 @@ export default function AlertingRulesTable({ accessorKey: "enabled", friendlyName: t("alertingColumnEnabled"), header: () => ( - {t("alertingColumnEnabled")} + ), cell: ({ row }) => { const r = row.original; @@ -342,6 +424,7 @@ export default function AlertingRulesTable({ onSearch={handleSearchChange} searchQuery={query} manualFiltering + manualSorting onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index cf252f3ea..2c0e5e48c 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -199,6 +199,8 @@ type DataTableProps = { columnVisibility?: Record; enableColumnVisibility?: boolean; manualFiltering?: boolean; + /** When true, row order is controlled externally (e.g. server-side sorting). */ + manualSorting?: boolean; onSearch?: (input: string) => void; searchQuery?: string; pagination?: DataTablePaginationState; @@ -232,6 +234,7 @@ export function DataTable({ enableColumnVisibility = false, persistColumnVisibility = false, manualFiltering = false, + manualSorting = false, pagination: paginationState, stickyLeftColumn, onSearch, @@ -353,6 +356,7 @@ export function DataTable({ } : setPagination, manualFiltering, + manualSorting, manualPagination: Boolean(paginationState), pageCount: paginationState?.pageCount, initialState: { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1e7074e3a..8fb11989c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -262,7 +262,10 @@ export const orgQueries = { offset = 0, query, siteId, - resourceId + resourceId, + sortBy, + order, + enabled }: { orgId: string; limit?: number; @@ -270,9 +273,17 @@ export const orgQueries = { query?: string; siteId?: number; resourceId?: number; + sortBy?: string; + order?: string; + enabled?: string; }) => queryOptions({ - queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query, siteId, resourceId }] as const, + queryKey: [ + "ORG", + orgId, + "ALERT_RULES", + { limit, offset, query, siteId, resourceId, sortBy, order, enabled } + ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams(); sp.set("limit", String(limit)); @@ -280,6 +291,11 @@ export const orgQueries = { if (query) sp.set("query", query); if (siteId != null) sp.set("siteId", String(siteId)); if (resourceId != null) sp.set("resourceId", String(resourceId)); + if (sortBy) { + sp.set("sort_by", sortBy); + if (order) sp.set("order", order); + } + if (enabled) sp.set("enabled", enabled); const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); From 5b18612426e81c5f2d6037024ff7daaacd65b559 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:04:44 -0700 Subject: [PATCH 133/176] Only works on saas --- src/hooks/useSubscriptionStatusContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSubscriptionStatusContext.ts b/src/hooks/useSubscriptionStatusContext.ts index 240168b10..59d6a6b9a 100644 --- a/src/hooks/useSubscriptionStatusContext.ts +++ b/src/hooks/useSubscriptionStatusContext.ts @@ -3,7 +3,7 @@ import { build } from "@server/build"; import { useContext } from "react"; export function useSubscriptionStatusContext() { - if (build == "oss") { + if (build != "saas") { return null; } const context = useContext(SubscriptionStatusContext); From 38243ad887769a754a94e93ae77f840668c14ec3 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:13:22 -0700 Subject: [PATCH 134/176] Create the new networks for each site resource --- server/setup/scriptsPg/1.18.0.ts | 55 ++++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.18.0.ts | 49 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index 9c0b7568b..2f2b3067c 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -61,6 +61,22 @@ export default async function migration() { `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` ); + // Query existing siteResources with siteId before it is dropped by the DDL below. + const siteResourcesForNetworkQuery = await db.execute( + sql`SELECT sr."siteResourceId", sr."orgId", sr."siteId" + FROM "siteResources" sr + WHERE sr."siteId" IS NOT NULL` + ); + const existingSiteResourcesForNetwork = siteResourcesForNetworkQuery.rows as { + siteResourceId: number; + orgId: string; + siteId: number; + }[]; + + console.log( + `Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks` + ); + try { await db.execute(sql`BEGIN`); @@ -430,5 +446,44 @@ export default async function migration() { } } + // Create a dedicated "resource"-scoped network for each existing siteResource, + // populate siteNetworks with the old siteId, and set networkId / defaultNetworkId + // on the siteResource row. + if (existingSiteResourcesForNetwork.length > 0) { + try { + for (const sr of existingSiteResourcesForNetwork) { + const networkResult = await db.execute(sql` + INSERT INTO "networks" ("scope", "orgId") + VALUES ('resource', ${sr.orgId}) + RETURNING "networkId" + `); + const networkId = ( + networkResult.rows[0] as { networkId: number } + ).networkId; + + await db.execute(sql` + INSERT INTO "siteNetworks" ("siteId", "networkId") + VALUES (${sr.siteId}, ${networkId}) + `); + + await db.execute(sql` + UPDATE "siteResources" + SET "networkId" = ${networkId}, "defaultNetworkId" = ${networkId} + WHERE "siteResourceId" = ${sr.siteResourceId} + `); + } + + console.log( + `Migrated ${existingSiteResourcesForNetwork.length} siteResource(s) to networks` + ); + } catch (e) { + console.error( + "Error while migrating siteResources to networks:", + e + ); + throw e; + } + } + console.log(`${version} migration complete`); } diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index edb7f9c23..91249166e 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -67,6 +67,25 @@ export default async function migration() { `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` ); + // Query existing siteResources with siteId before the transaction recreates + // the table without that column. We use this data below to create a dedicated + // network for each resource. + const existingSiteResourcesForNetwork = db + .prepare( + `SELECT sr."siteResourceId", sr."orgId", sr."siteId" + FROM 'siteResources' sr + WHERE sr."siteId" IS NOT NULL` + ) + .all() as { + siteResourceId: number; + orgId: string; + siteId: number; + }[]; + + console.log( + `Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks` + ); + db.transaction(() => { db.prepare( ` @@ -315,6 +334,36 @@ export default async function migration() { db.pragma("foreign_keys = ON"); + // Create a dedicated network for each existing siteResource and link the + // old siteId via siteNetworks. Then set networkId and defaultNetworkId on + // the siteResource row so the app can use the new network model. + if (existingSiteResourcesForNetwork.length > 0) { + const insertNetwork = db.prepare( + `INSERT INTO 'networks' ("scope", "orgId") VALUES (?, ?)` + ); + const insertSiteNetwork = db.prepare( + `INSERT INTO 'siteNetworks' ("siteId", "networkId") VALUES (?, ?)` + ); + const updateSiteResource = db.prepare( + `UPDATE 'siteResources' SET "networkId" = ?, "defaultNetworkId" = ? WHERE "siteResourceId" = ?` + ); + + const migrateNetworks = db.transaction(() => { + for (const sr of existingSiteResourcesForNetwork) { + const result = insertNetwork.run("resource", sr.orgId); + const networkId = result.lastInsertRowid as number; + insertSiteNetwork.run(sr.siteId, networkId); + updateSiteResource.run(networkId, networkId, sr.siteResourceId); + } + }); + + migrateNetworks(); + + console.log( + `Migrated ${existingSiteResourcesForNetwork.length} siteResource(s) to networks` + ); + } + // Re-insert targetHealthCheck rows with corrected IDs: // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), // siteId and orgId are populated from the associated target and site. From b5dd20e49941bda3c5d7a9d56d968dc363abdcf1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 16:19:32 -0700 Subject: [PATCH 135/176] fix cant save form --- messages/en-US.json | 4 ++-- .../alert-rule-editor/AlertRuleFields.tsx | 3 +++ .../alert-rule-editor/AlertRuleGraphEditor.tsx | 4 ++-- src/lib/alertRuleForm.ts | 16 ++++++++-------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9b14cee39..29e904416 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1371,7 +1371,7 @@ "alertingColumnTrigger": "Trigger", "alertingColumnActions": "Actions", "alertingColumnEnabled": "Enabled", - "alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.", + "alertingDeleteQuestion": "Please confirm you want to delete this alert rule.", "alertingDeleteRule": "Delete alert rule", "alertingRuleDeleted": "Alert rule deleted", "alertingRuleSaved": "Alert rule saved", @@ -1489,7 +1489,7 @@ "standaloneHcNameLabel": "Name", "standaloneHcNamePlaceholder": "My HTTP Monitor", "standaloneHcDeleteTitle": "Delete health check", - "standaloneHcDeleteQuestion": "Delete this health check? This cannot be undone.", + "standaloneHcDeleteQuestion": "Please confirm you want to delete this health check.", "standaloneHcDeleted": "Health check deleted", "standaloneHcSaved": "Health check saved", "standaloneHcColumnHealth": "Health", diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 0228b30bc..bb99d57f3 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -1156,6 +1156,7 @@ export function AlertRuleSourceFields({ ( ( ( buildFormSchema(t), [t]); const form = useForm({ - resolver: zodResolver(schema), + resolver: zodResolver(schema) as Resolver, defaultValues: initialValues ?? defaultFormValues() }); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 115c9fcf5..426a6d309 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -142,12 +142,12 @@ export function buildFormSchema(t: (k: string) => string) { .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), sourceType: z.enum(["site", "health_check", "resource"]), - allSites: z.boolean(), - siteIds: z.array(z.number()), - allHealthChecks: z.boolean(), - healthCheckIds: z.array(z.number()), - allResources: z.boolean(), - resourceIds: z.array(z.number()), + allSites: z.boolean().default(true), + siteIds: z.array(z.number()).default([]), + allHealthChecks: z.boolean().default(true), + healthCheckIds: z.array(z.number()).default([]), + allResources: z.boolean().default(true), + resourceIds: z.array(z.number()).default([]), trigger: z.enum([ "site_online", "site_offline", @@ -332,8 +332,8 @@ export function apiResponseToFormValues( const sourceType = rule.eventType.startsWith("site_") ? "site" : rule.eventType.startsWith("resource_") - ? "resource" - : "health_check"; + ? "resource" + : "health_check"; // Collect notify recipients into a single notify action (if any) const userTags = rule.recipients From 23293da8451f8ff4eacd4755b2b4c6a1c121561d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:19:48 -0700 Subject: [PATCH 136/176] Fix the insert --- server/setup/scriptsSqlite/1.18.0.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index 91249166e..f631d59b6 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -255,7 +255,7 @@ export default async function migration() { ).run(); db.prepare( ` - INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain" FROM 'siteResources'; + INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources'; ` ).run(); db.prepare( @@ -450,3 +450,5 @@ export default async function migration() { console.log(`${version} migration complete`); } + +await migration(); From ccfa16563227eb9053eb36d5d411d9b9b5b024f8 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 16:23:08 -0700 Subject: [PATCH 137/176] show all sites|resources|health-checks in alert table --- messages/en-US.json | 3 ++ src/components/AlertingRulesTable.tsx | 14 ++++++++ src/lib/alertRuleForm.ts | 51 ++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 29e904416..299e64044 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1450,8 +1450,11 @@ "alertingSelectRoles": "Select roles…", "alertingRolesSelected": "{count} roles selected", "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "All sites", "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingSummaryAllHealthChecks": "All health checks", "alertingSummaryResources": "Resources ({count})", + "alertingSummaryAllResources": "All resources", "alertingErrorNameRequired": "Enter a name", "alertingErrorActionsMin": "Add at least one action", "alertingErrorPickSites": "Select at least one site", diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 3ddcc4c13..9ee70b205 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -18,6 +18,11 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { + alertRuleAllHealthChecksSelected, + alertRuleAllResourcesSelected, + alertRuleAllSitesSelected +} from "@app/lib/alertRuleForm"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ArrowDown01Icon, @@ -71,6 +76,9 @@ function sourceSummary( rule: AlertRuleRow, t: (k: string, o?: Record) => string ) { + if (alertRuleAllSitesSelected(rule.eventType, rule.siteIds)) { + return t("alertingSummaryAllSites"); + } if ( rule.eventType === "site_online" || rule.eventType === "site_offline" || @@ -78,11 +86,17 @@ function sourceSummary( ) { return t("alertingSummarySites", { count: rule.siteIds.length }); } + if (alertRuleAllResourcesSelected(rule.eventType, rule.resourceIds)) { + return t("alertingSummaryAllResources"); + } if (rule.eventType.startsWith("resource_")) { return t("alertingSummaryResources", { count: rule.resourceIds.length }); } + if (alertRuleAllHealthChecksSelected(rule.eventType, rule.healthCheckIds)) { + return t("alertingSummaryAllHealthChecks"); + } return t("alertingSummaryHealthChecks", { count: rule.healthCheckIds.length }); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 426a6d309..f4bcf9cee 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -321,6 +321,43 @@ export function defaultFormValues(): AlertRuleFormValues { }; } +// --------------------------------------------------------------------------- +// List/API row semantics: empty ID arrays mean "all" for that source kind +// --------------------------------------------------------------------------- + +export function alertRuleAllSitesSelected( + eventType: string, + siteIds: number[] +): boolean { + const siteEvent = + eventType === "site_online" || + eventType === "site_offline" || + eventType === "site_toggle"; + return siteEvent && siteIds.length === 0; +} + +export function alertRuleAllResourcesSelected( + eventType: string, + resourceIds: number[] | undefined +): boolean { + return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0; +} + +export function alertRuleAllHealthChecksSelected( + eventType: string, + healthCheckIds: number[] +): boolean { + if ( + eventType === "site_online" || + eventType === "site_offline" || + eventType === "site_toggle" || + eventType.startsWith("resource_") + ) { + return false; + } + return healthCheckIds.length === 0; +} + // --------------------------------------------------------------------------- // API response → form values // --------------------------------------------------------------------------- @@ -372,11 +409,15 @@ export function apiResponseToFormValues( }); } - const allSites = sourceType === "site" && rule.siteIds.length === 0; - const allHealthChecks = - sourceType === "health_check" && rule.healthCheckIds.length === 0; - const allResources = - sourceType === "resource" && (rule.resourceIds?.length ?? 0) === 0; + const allSites = alertRuleAllSitesSelected(rule.eventType, rule.siteIds); + const allHealthChecks = alertRuleAllHealthChecksSelected( + rule.eventType, + rule.healthCheckIds + ); + const allResources = alertRuleAllResourcesSelected( + rule.eventType, + rule.resourceIds + ); return { name: rule.name, From 709f2c187d7d6e0e237993eb5a5848f7b26a4617 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 16:27:27 -0700 Subject: [PATCH 138/176] remove loading state on the alert rule --- src/app/[orgId]/settings/alerting/[ruleId]/page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 86d455db7..9035dc7e5 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -27,7 +27,9 @@ export default function EditAlertRulePage() { const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); - const [formValues, setFormValues] = useState(undefined); + const [formValues, setFormValues] = useState< + AlertRuleFormValues | null | undefined + >(undefined); useEffect(() => { if (isNaN(alertRuleId)) { @@ -65,9 +67,6 @@ export default function EditAlertRulePage() { title={t("alertingEditRule")} description={t("alertingRuleCredenzaDescription")} /> -
    - {t("loading")} -
    ); } From b22ac171781f8f772c87d63ae1c3f6ed5a7f4ad8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:45:43 -0700 Subject: [PATCH 139/176] Remove self call --- server/setup/scriptsSqlite/1.18.0.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index f631d59b6..c9d2ddc95 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -450,5 +450,3 @@ export default async function migration() { console.log(`${version} migration complete`); } - -await migration(); From db2942447a8018ae13e5f5eaed31f221ada53a66 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 16:49:45 -0700 Subject: [PATCH 140/176] make alerts and health checks table server side --- messages/en-US.json | 1 + .../settings/alerting/[ruleId]/page.tsx | 4 +- .../settings/alerting/health-checks/page.tsx | 86 +++++++++++++++ src/app/[orgId]/settings/alerting/layout.tsx | 38 +++++++ src/app/[orgId]/settings/alerting/page.tsx | 61 ++--------- .../[orgId]/settings/alerting/rules/page.tsx | 100 ++++++++++++++++++ .../[orgId]/settings/health-checks/page.tsx | 18 ++++ src/components/AlertingRulesTable.tsx | 61 ++++------- src/components/HealthChecksTable.tsx | 71 +++++-------- src/components/HorizontalTabs.tsx | 31 ++++-- src/components/UptimeAlertSection.tsx | 4 +- 11 files changed, 333 insertions(+), 142 deletions(-) create mode 100644 src/app/[orgId]/settings/alerting/health-checks/page.tsx create mode 100644 src/app/[orgId]/settings/alerting/layout.tsx create mode 100644 src/app/[orgId]/settings/alerting/rules/page.tsx create mode 100644 src/app/[orgId]/settings/health-checks/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 299e64044..6da7ccb94 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1358,6 +1358,7 @@ "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarAlerting": "Alerting", + "sidebarHealthChecks": "Health checks", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 9035dc7e5..427ac7c44 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -33,7 +33,7 @@ export default function EditAlertRulePage() { useEffect(() => { if (isNaN(alertRuleId)) { - router.replace(`/${orgId}/settings/alerting`); + router.replace(`/${orgId}/settings/alerting/rules`); return; } @@ -56,7 +56,7 @@ export default function EditAlertRulePage() { useEffect(() => { if (formValues === null) { - router.replace(`/${orgId}/settings/alerting`); + router.replace(`/${orgId}/settings/alerting/rules`); } }, [formValues, orgId, router]); diff --git a/src/app/[orgId]/settings/alerting/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/health-checks/page.tsx new file mode 100644 index 000000000..8bb19fc8c --- /dev/null +++ b/src/app/[orgId]/settings/alerting/health-checks/page.tsx @@ -0,0 +1,86 @@ +import HealthChecksTable from "@app/components/HealthChecksTable"; +import DismissableBanner from "@app/components/DismissableBanner"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import { AxiosResponse } from "axios"; +import { HeartPulse } from "lucide-react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Health checks" +}; + +type AlertingHealthChecksPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + +export default async function AlertingHealthChecksPage( + props: AlertingHealthChecksPageProps +) { + const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); + + const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1); + const pageSize = Math.max( + 1, + parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 + ); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + + const apiSp = new URLSearchParams(); + apiSp.set("limit", String(pageSize)); + apiSp.set("offset", String(pageIndex * pageSize)); + if (query) apiSp.set("query", query); + + let healthChecks: ListHealthChecksResponse["healthChecks"] = []; + let pagination: ListHealthChecksResponse["pagination"] = { + total: 0, + limit: pageSize, + offset: pageIndex * pageSize + }; + try { + const res = await internal.get>( + `/org/${params.orgId}/health-checks?${apiSp.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + healthChecks = responseData.healthChecks; + pagination = responseData.pagination; + } catch { + // leave defaults + } + + const t = await getTranslations(); + + return ( +
    + + } + description={t("alertingHealthChecksBannerDescription")} + /> + +
    + ); +} diff --git a/src/app/[orgId]/settings/alerting/layout.tsx b/src/app/[orgId]/settings/alerting/layout.tsx new file mode 100644 index 000000000..f235eb2e9 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +type AlertingLayoutProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function AlertingLayout({ + children, + params +}: AlertingLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("alertingTabRules"), + href: `/${orgId}/settings/alerting/rules`, + activePrefix: `/${orgId}/settings/alerting` + }, + { + title: t("alertingTabHealthChecks"), + href: `/${orgId}/settings/alerting/health-checks` + } + ]; + + return ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index 945128ca0..1768fbced 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,58 +1,15 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import AlertingRulesTable from "@app/components/AlertingRulesTable"; -import HealthChecksTable from "@app/components/HealthChecksTable"; -import DismissableBanner from "@app/components/DismissableBanner"; -import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; -import { BellRing, HeartPulse } from "lucide-react"; -import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -type AlertingPageProps = { +export const metadata: Metadata = { + title: "Alerting" +}; + +type AlertingIndexPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - -export default async function AlertingPage(props: AlertingPageProps) { +export default async function AlertingIndexPage(props: AlertingIndexPageProps) { const params = await props.params; - const t = await getTranslations(); - - const tabs: TabItem[] = [ - { title: t("alertingTabRules"), href: "" }, - { title: t("alertingTabHealthChecks"), href: "" } - ]; - - return ( - <> - - -
    - - } - description={t("alertingRulesBannerDescription")} - /> - -
    -
    - - } - description={t("alertingHealthChecksBannerDescription")} - /> - -
    -
    - - ); + redirect(`/${params.orgId}/settings/alerting/rules`); } diff --git a/src/app/[orgId]/settings/alerting/rules/page.tsx b/src/app/[orgId]/settings/alerting/rules/page.tsx new file mode 100644 index 000000000..0bdcd817c --- /dev/null +++ b/src/app/[orgId]/settings/alerting/rules/page.tsx @@ -0,0 +1,100 @@ +import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import DismissableBanner from "@app/components/DismissableBanner"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListAlertRulesResponse } from "@server/private/routers/alertRule"; +import { AxiosResponse } from "axios"; +import { BellRing } from "lucide-react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Alerting" +}; + +type AlertingRulesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + +export default async function AlertingRulesPage(props: AlertingRulesPageProps) { + const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); + + const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1); + const pageSize = Math.max( + 1, + parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 + ); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + const sortBy = searchParams.get("sort_by") ?? undefined; + const order = searchParams.get("order") ?? undefined; + const enabled = searchParams.get("enabled"); + const enabledParam = + enabled === "true" || enabled === "false" ? enabled : undefined; + const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined); + const resourceId = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + + const apiSp = new URLSearchParams(); + apiSp.set("limit", String(pageSize)); + apiSp.set("offset", String(pageIndex * pageSize)); + if (query) apiSp.set("query", query); + if (siteId != null) apiSp.set("siteId", String(siteId)); + if (resourceId != null) apiSp.set("resourceId", String(resourceId)); + if (sortBy) { + apiSp.set("sort_by", sortBy); + if (order) apiSp.set("order", order); + } + if (enabledParam) apiSp.set("enabled", enabledParam); + + let alertRules: ListAlertRulesResponse["alertRules"] = []; + let pagination: ListAlertRulesResponse["pagination"] = { + total: 0, + limit: pageSize, + offset: pageIndex * pageSize + }; + try { + const res = await internal.get>( + `/org/${params.orgId}/alert-rules?${apiSp.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + alertRules = responseData.alertRules; + pagination = responseData.pagination; + } catch { + // leave defaults + } + + const t = await getTranslations(); + + return ( +
    + + } + description={t("alertingRulesBannerDescription")} + /> + +
    + ); +} diff --git a/src/app/[orgId]/settings/health-checks/page.tsx b/src/app/[orgId]/settings/health-checks/page.tsx new file mode 100644 index 000000000..e35880f56 --- /dev/null +++ b/src/app/[orgId]/settings/health-checks/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Health checks" +}; + +type LegacyHealthChecksPageProps = { + params: Promise<{ orgId: string }>; +}; + +/** @deprecated Use `/settings/alerting/health-checks` */ +export default async function LegacyHealthChecksRedirect( + props: LegacyHealthChecksPageProps +) { + const params = await props.params; + redirect(`/${params.orgId}/settings/alerting/health-checks`); +} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 9ee70b205..52ff3b609 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -16,7 +16,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { alertRuleAllHealthChecksSelected, @@ -34,10 +33,9 @@ import moment from "moment"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useTransition } from "react"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useDebouncedCallback } from "use-debounce"; @@ -47,13 +45,7 @@ const alertRulesEnabledQuerySchema = z .optional() .catch(undefined); -type AlertingRulesTableProps = { - orgId: string; - siteId?: number; - resourceId?: number; -}; - -type AlertRuleRow = { +export type AlertRuleRow = { alertRuleId: number; orgId: string; name: string; @@ -68,6 +60,12 @@ type AlertRuleRow = { resourceIds: number[]; }; +type AlertingRulesTableProps = { + orgId: string; + alertRules: AlertRuleRow[]; + rowCount: number; +}; + function ruleHref(orgId: string, ruleId: number) { return `/${orgId}/settings/alerting/${ruleId}`; } @@ -129,13 +127,13 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { export default function AlertingRulesTable({ orgId, - siteId, - resourceId + alertRules, + rowCount }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); - const queryClient = useQueryClient(); + const [isRefreshing, startRefresh] = useTransition(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); @@ -167,24 +165,16 @@ export default function AlertingRulesTable({ [t] ); - const { data, isLoading, refetch, isRefetching } = useQuery( - orgQueries.alertRules({ - orgId, - limit: pageSize, - offset: pageIndex * pageSize, - query, - siteId, - resourceId, - sortBy, - order, - enabled: enabledForQuery - }) - ); - - const rows = data?.alertRules ?? []; - const total = data?.pagination.total ?? 0; + const rows = alertRules; + const total = rowCount; const pageCount = Math.max(1, Math.ceil(total / pageSize)); + function refreshList() { + startRefresh(() => { + router.refresh(); + }); + } + const paginationState: DataTablePaginationState = { pageIndex, pageSize, @@ -223,18 +213,13 @@ export default function AlertingRulesTable({ filter({ searchParams: sp }); } - const invalidate = () => - queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "ALERT_RULES"] - }); - const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { setTogglingId(rule.alertRuleId); try { await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { enabled }); - await invalidate(); + refreshList(); } catch (e) { toast({ title: t("error"), @@ -252,7 +237,7 @@ export default function AlertingRulesTable({ await api.delete( `/org/${orgId}/alert-rule/${selected.alertRuleId}` ); - await invalidate(); + refreshList(); toast({ title: t("alertingRuleDeleted") }); } catch (e) { toast({ @@ -442,8 +427,8 @@ export default function AlertingRulesTable({ onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} - onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading || isFiltering} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 9e5d9f2c6..63b48c53a 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -6,7 +6,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { HealthCheckRow } from "@app/components/HealthCheckCredenza"; -import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -19,22 +18,23 @@ import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { orgQueries } from "@app/lib/queries"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useState, useTransition, useEffect } from "react"; import type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type StandaloneHealthChecksTableProps = { orgId: string; + healthChecks: HealthCheckRow[]; + rowCount: number; }; function formatTarget(row: HealthCheckRow): string { @@ -57,21 +57,15 @@ const healthLabel: Record = { unknown: "Unknown" }; -const healthVariant: Record< - HealthCheckRow["hcHealth"], - "green" | "red" | "secondary" -> = { - healthy: "green", - unhealthy: "red", - unknown: "secondary" -}; - export default function HealthChecksTable({ - orgId + orgId, + healthChecks, + rowCount }: StandaloneHealthChecksTableProps) { + const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); - const queryClient = useQueryClient(); + const [isRefreshing, startRefresh] = useTransition(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); @@ -91,25 +85,23 @@ export default function HealthChecksTable({ const pageIndex = page - 1; const query = searchParams.get("query") ?? undefined; - const { - data, - isLoading, - refetch, - isRefetching - } = useQuery({ - ...orgQueries.standaloneHealthChecks({ - orgId, - limit: pageSize, - offset: pageIndex * pageSize, - query - }), - refetchInterval: 10_000 - }); - - const rows = data?.healthChecks ?? []; - const total = data?.pagination.total ?? 0; + const rows = healthChecks; + const total = rowCount; const pageCount = Math.max(1, Math.ceil(total / pageSize)); + function refreshList() { + startRefresh(() => { + router.refresh(); + }); + } + + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 10_000); + return () => clearInterval(interval); + }, [router]); + const paginationState: DataTablePaginationState = { pageIndex, pageSize, @@ -132,11 +124,6 @@ export default function HealthChecksTable({ filter({ searchParams }); }, 300); - const invalidate = () => - queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] - }); - const handleToggleEnabled = async ( row: HealthCheckRow, enabled: boolean @@ -147,7 +134,7 @@ export default function HealthChecksTable({ `/org/${orgId}/health-check/${row.targetHealthCheckId}`, { hcEnabled: enabled } ); - await invalidate(); + refreshList(); } catch (e) { toast({ title: t("error"), @@ -165,7 +152,7 @@ export default function HealthChecksTable({ await api.delete( `/org/${orgId}/health-check/${selected.targetHealthCheckId}` ); - await invalidate(); + refreshList(); toast({ title: t("standaloneHcDeleted") }); } catch (e) { toast({ @@ -400,7 +387,7 @@ export default function HealthChecksTable({ }} orgId={orgId} initialValues={selected} - onSaved={invalidate} + onSaved={refreshList} /> @@ -418,8 +405,8 @@ export default function HealthChecksTable({ setCredenzaOpen(true); }} addButtonDisabled={!isPaid} - onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading || isFiltering} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} addButtonText={t("standaloneHcAddButton")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 717a3c120..bffaadeba 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -11,6 +11,8 @@ import { useTranslations } from "next-intl"; export type TabItem = { title: string; href: string; + /** When set, active tab detection uses this path instead of `href` (link target unchanged). */ + activePrefix?: string; icon?: React.ReactNode; showProfessional?: boolean; exact?: boolean; @@ -115,18 +117,33 @@ export function HorizontalTabs({ } // Server-side mode: original behavior with routing + const activeIndex: number | null = (() => { + if (pathname.includes("create")) return null; + let best: number | null = null; + let bestLen = -1; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const matchBase = hydrateHref(item.activePrefix ?? item.href); + const matched = item.exact + ? pathname === matchBase + : pathname === matchBase || + pathname.startsWith(`${matchBase}/`); + if (matched && matchBase.length > bestLen) { + bestLen = matchBase.length; + best = i; + } + } + return best; + })(); + return (
    - {items.map((item) => { + {items.map((item, index) => { const hydratedHref = hydrateHref(item.href); - const isActive = - (item.exact - ? pathname === hydratedHref - : pathname.startsWith(hydratedHref)) && - !pathname.includes("create"); + const isActive = activeIndex === index; const isProfessional = item.showProfessional && !isUnlocked(); @@ -135,7 +152,7 @@ export function HorizontalTabs({ return ( - + View Alerts From 38f1387db17e0c02b04c754be199fd54f96d7b0b Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:52:09 -0700 Subject: [PATCH 141/176] Update package lock --- package-lock.json | 71 +++-------------------------------------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5b422b89..0f391b19e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,6 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", - "@xyflow/react": "^12.8.4", "arctic": "3.7.0", "axios": "1.13.5", "better-sqlite3": "11.9.1", @@ -8719,6 +8718,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8834,6 +8834,7 @@ "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { @@ -8868,6 +8869,7 @@ "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -8877,6 +8879,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -9680,38 +9683,6 @@ "win32" ] }, - "node_modules/@xyflow/react": { - "version": "12.8.4", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz", - "integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.68", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.68", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz", - "integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -10564,12 +10535,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -19881,34 +19846,6 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } From 09744cf2f0e772cab70e88f65daf810dd9ad542d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:52:26 -0700 Subject: [PATCH 142/176] Make paid feature --- src/components/UptimeAlertSection.tsx | 167 ++++++++++++++------------ 1 file changed, 90 insertions(+), 77 deletions(-) diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index f7647c977..038e740b2 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -31,6 +31,9 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { orgQueries } from "@app/lib/queries"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; interface UptimeAlertSectionProps { orgId: string; @@ -49,6 +52,8 @@ export default function UptimeAlertSection({ }: UptimeAlertSectionProps) { const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); const [open, setOpen] = useState(false); const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`); @@ -207,82 +212,90 @@ export default function UptimeAlertSection({
    -
    - - setName(e.target.value)} - placeholder="Alert name" - /> -
    -
    - - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; - setUserTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allUsers} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags - /> -
    -
    - - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; - setRoleTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allRoles} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags - /> -
    -
    - - { - const next = - typeof newTags === "function" - ? newTags(emailTags) - : newTags; - setEmailTags(next as Tag[]); - }} - allowDuplicates={false} - sortTags - validateTag={(tag) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) - } - delimiterList={[",", "Enter"]} - /> -
    + +
    +
    +
    + + setName(e.target.value)} + placeholder="Alert name" + /> +
    +
    + + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + setUserTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allUsers} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
    +
    + + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + setRoleTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allRoles} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
    +
    + + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + setEmailTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
    +
    +
    @@ -292,7 +305,7 @@ export default function UptimeAlertSection({ From 84346fc23e8e20f8c81af4d1f70262b9638924db Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 16:52:35 -0700 Subject: [PATCH 143/176] Add missing header --- .../routers/healthChecks/getStatusHistory.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 5b1ddcfb0..2fa596950 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, statusHistory } from "@server/db"; From 22a6dabeb2b822b8ff618cc04596ba8e46e34ae5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 16:54:28 -0700 Subject: [PATCH 144/176] fix alerting layout --- .../{ => (list)}/health-checks/page.tsx | 0 .../settings/alerting/(list)/layout.tsx | 38 +++++++++++++++++++ .../alerting/{ => (list)}/rules/page.tsx | 0 src/app/[orgId]/settings/alerting/layout.tsx | 35 +---------------- 4 files changed, 40 insertions(+), 33 deletions(-) rename src/app/[orgId]/settings/alerting/{ => (list)}/health-checks/page.tsx (100%) create mode 100644 src/app/[orgId]/settings/alerting/(list)/layout.tsx rename src/app/[orgId]/settings/alerting/{ => (list)}/rules/page.tsx (100%) diff --git a/src/app/[orgId]/settings/alerting/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx similarity index 100% rename from src/app/[orgId]/settings/alerting/health-checks/page.tsx rename to src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx diff --git a/src/app/[orgId]/settings/alerting/(list)/layout.tsx b/src/app/[orgId]/settings/alerting/(list)/layout.tsx new file mode 100644 index 000000000..7393e7044 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/(list)/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +type AlertingListLayoutProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function AlertingListLayout({ + children, + params +}: AlertingListLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("alertingTabRules"), + href: `/${orgId}/settings/alerting/rules`, + activePrefix: `/${orgId}/settings/alerting` + }, + { + title: t("alertingTabHealthChecks"), + href: `/${orgId}/settings/alerting/health-checks` + } + ]; + + return ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/alerting/rules/page.tsx b/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx similarity index 100% rename from src/app/[orgId]/settings/alerting/rules/page.tsx rename to src/app/[orgId]/settings/alerting/(list)/rules/page.tsx diff --git a/src/app/[orgId]/settings/alerting/layout.tsx b/src/app/[orgId]/settings/alerting/layout.tsx index f235eb2e9..a541860eb 100644 --- a/src/app/[orgId]/settings/alerting/layout.tsx +++ b/src/app/[orgId]/settings/alerting/layout.tsx @@ -1,38 +1,7 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { getTranslations } from "next-intl/server"; - type AlertingLayoutProps = { children: React.ReactNode; - params: Promise<{ orgId: string }>; }; -export default async function AlertingLayout({ - children, - params -}: AlertingLayoutProps) { - const { orgId } = await params; - const t = await getTranslations(); - - const navItems = [ - { - title: t("alertingTabRules"), - href: `/${orgId}/settings/alerting/rules`, - activePrefix: `/${orgId}/settings/alerting` - }, - { - title: t("alertingTabHealthChecks"), - href: `/${orgId}/settings/alerting/health-checks` - } - ]; - - return ( - <> - - {children} - - ); +export default function AlertingLayout({ children }: AlertingLayoutProps) { + return <>{children}; } From 13afa90d28ef9d32c2be33a87c06e22899385ea6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 17:02:21 -0700 Subject: [PATCH 145/176] Fix the linking out and deleting for target ones --- .../routers/healthChecks/listHealthChecks.ts | 3 +- src/components/HealthChecksTable.tsx | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index e156573e4..e87525a3f 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -17,7 +17,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, like, sql } from "drizzle-orm"; +import { and, eq, isNotNull, like, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; @@ -85,6 +85,7 @@ export async function listHealthChecks( const whereClause = and( eq(targetHealthCheck.orgId, orgId), + isNotNull(targetHealthCheck.hcMode), // filter out the null ones attached to targets query ? like( sql`LOWER(${targetHealthCheck.name})`, diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 63b48c53a..510639b46 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -329,7 +329,7 @@ export default function HealthChecksTable({ { setSelected(r); setDeleteOpen(true); @@ -339,18 +339,31 @@ export default function HealthChecksTable({ {t("delete")} + - + {r.resourceId && r.resourceName && r.resourceNiceId ? ( + + + + ) : ( + + )} +
    ); } From 0434b1a656319132138fb282eeda615b9801a990 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 17:22:50 -0700 Subject: [PATCH 146/176] fix site and resource filters on alert --- .../routers/alertRule/listAlertRules.ts | 180 ++++++++++++------ .../settings/alerting/(list)/rules/page.tsx | 5 + src/components/UptimeAlertSection.tsx | 23 ++- src/lib/queries.ts | 75 +++++--- 4 files changed, 193 insertions(+), 90 deletions(-) diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index e6a2e6381..73f11ca03 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -14,14 +14,19 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -51,11 +56,34 @@ const querySchema = z.strictObject({ .optional() .transform((v) => (v !== undefined ? Number(v) : undefined)) .pipe(z.number().int().positive().optional()), + healthCheckId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), sort_by: z.enum(["name", "last_triggered_at"]).optional(), order: z.enum(["asc", "desc"]).optional().default("asc"), enabled: z.enum(["true", "false"]).optional() }); +const SITE_ALERT_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; + +const RESOURCE_ALERT_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" +] as const; + +const HEALTH_CHECK_ALERT_EVENT_TYPES = [ + "health_check_healthy", + "health_check_unhealthy", + "health_check_toggle" +] as const; + export type ListAlertRulesResponse = { alertRules: { alertRuleId: number; @@ -122,66 +150,110 @@ export async function listAlertRules( query, siteId, resourceId, + healthCheckId, sort_by, order, enabled: enabledFilter } = parsedQuery.data; - // Resolve siteId filter → matching alertRuleIds - let siteFilterRuleIds: number[] | null = null; - if (siteId !== undefined) { - const rows = await db - .select({ alertRuleId: alertSites.alertRuleId }) - .from(alertSites) - .where(eq(alertSites.siteId, siteId)); - siteFilterRuleIds = rows.map((r) => r.alertRuleId); - if (siteFilterRuleIds.length === 0) { - return response(res, { - data: { - alertRules: [], - pagination: { total: 0, limit, offset } - }, - success: true, - error: false, - message: "Alert rules retrieved successfully", - status: HttpCode.OK - }); - } - } + const explicitSiteRuleIds: number[] = + siteId !== undefined + ? ( + await db + .select({ alertRuleId: alertSites.alertRuleId }) + .from(alertSites) + .where(eq(alertSites.siteId, siteId)) + ).map((r) => r.alertRuleId) + : []; - // Resolve resourceId filter → matching alertRuleIds - let resourceFilterRuleIds: number[] | null = null; - if (resourceId !== undefined) { - const rows = await db - .select({ alertRuleId: alertResources.alertRuleId }) - .from(alertResources) - .where(eq(alertResources.resourceId, resourceId)); - resourceFilterRuleIds = rows.map((r) => r.alertRuleId); - if (resourceFilterRuleIds.length === 0) { - return response(res, { - data: { - alertRules: [], - pagination: { total: 0, limit, offset } - }, - success: true, - error: false, - message: "Alert rules retrieved successfully", - status: HttpCode.OK - }); - } - } + const explicitResourceRuleIds: number[] = + resourceId !== undefined + ? ( + await db + .select({ + alertRuleId: alertResources.alertRuleId + }) + .from(alertResources) + .where(eq(alertResources.resourceId, resourceId)) + ).map((r) => r.alertRuleId) + : []; + + const explicitHealthCheckRuleIds: number[] = + healthCheckId !== undefined + ? ( + await db + .select({ + alertRuleId: alertHealthChecks.alertRuleId + }) + .from(alertHealthChecks) + .where( + eq(alertHealthChecks.healthCheckId, healthCheckId) + ) + ).map((r) => r.alertRuleId) + : []; + + const allSitesWildcardClause = and( + eq(alertRules.allSites, true), + inArray(alertRules.eventType, SITE_ALERT_EVENT_TYPES) + ); + + const siteScopeClause = + siteId !== undefined + ? explicitSiteRuleIds.length > 0 + ? or( + allSitesWildcardClause, + inArray(alertRules.alertRuleId, explicitSiteRuleIds) + ) + : allSitesWildcardClause + : undefined; + + const allResourcesWildcardClause = and( + eq(alertRules.allResources, true), + inArray(alertRules.eventType, RESOURCE_ALERT_EVENT_TYPES) + ); + + const resourceScopeClause = + resourceId !== undefined + ? explicitResourceRuleIds.length > 0 + ? or( + allResourcesWildcardClause, + inArray( + alertRules.alertRuleId, + explicitResourceRuleIds + ) + ) + : allResourcesWildcardClause + : undefined; + + const allHealthChecksWildcardClause = and( + eq(alertRules.allHealthChecks, true), + inArray(alertRules.eventType, HEALTH_CHECK_ALERT_EVENT_TYPES) + ); + + const healthCheckScopeClause = + healthCheckId !== undefined + ? explicitHealthCheckRuleIds.length > 0 + ? or( + allHealthChecksWildcardClause, + inArray( + alertRules.alertRuleId, + explicitHealthCheckRuleIds + ) + ) + : allHealthChecksWildcardClause + : undefined; const whereClause = and( eq(alertRules.orgId, orgId), query - ? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`) - : undefined, - siteFilterRuleIds !== null - ? inArray(alertRules.alertRuleId, siteFilterRuleIds) - : undefined, - resourceFilterRuleIds !== null - ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + ? like( + sql`LOWER(${alertRules.name})`, + `%${query.toLowerCase()}%` + ) : undefined, + siteScopeClause, + resourceScopeClause, + healthCheckScopeClause, enabledFilter !== undefined ? eq(alertRules.enabled, enabledFilter === "true") : undefined @@ -228,9 +300,7 @@ export async function listAlertRules( ? await db .select() .from(alertHealthChecks) - .where( - inArray(alertHealthChecks.alertRuleId, ruleIds) - ) + .where(inArray(alertHealthChecks.alertRuleId, ruleIds)) : []; const resourceRows = @@ -297,4 +367,4 @@ export async function listAlertRules( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx b/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx index 0bdcd817c..ee2a561bf 100644 --- a/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx +++ b/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx @@ -46,6 +46,9 @@ export default async function AlertingRulesPage(props: AlertingRulesPageProps) { const resourceId = parsePositiveInt( searchParams.get("resourceId") ?? undefined ); + const healthCheckId = parsePositiveInt( + searchParams.get("healthCheckId") ?? undefined + ); const apiSp = new URLSearchParams(); apiSp.set("limit", String(pageSize)); @@ -53,6 +56,8 @@ export default async function AlertingRulesPage(props: AlertingRulesPageProps) { if (query) apiSp.set("query", query); if (siteId != null) apiSp.set("siteId", String(siteId)); if (resourceId != null) apiSp.set("resourceId", String(resourceId)); + if (healthCheckId != null) + apiSp.set("healthCheckId", String(healthCheckId)); if (sortBy) { apiSp.set("sort_by", sortBy); if (order) apiSp.set("order", order); diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index f7647c977..a43ef858b 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -51,7 +51,9 @@ export default function UptimeAlertSection({ const queryClient = useQueryClient(); const [open, setOpen] = useState(false); - const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`); + const [name, setName] = useState( + `${siteId ? "Site" : "Resource"} ${startingName} Alert` + ); const [userTags, setUserTags] = useState([]); const [roleTags, setRoleTags] = useState([]); const [emailTags, setEmailTags] = useState([]); @@ -129,8 +131,7 @@ export default function UptimeAlertSection({ toast({ title: "Alert created", - description: - "You will be notified when this changes status." + description: "You will be notified when this changes status." }); setOpen(false); @@ -156,11 +157,17 @@ export default function UptimeAlertSection({ setLoading(false); } + const rulesListSearch = new URLSearchParams(); + if (siteId != null) rulesListSearch.set("siteId", String(siteId)); + if (resourceId != null) + rulesListSearch.set("resourceId", String(resourceId)); + const rulesListHref = `/${orgId}/settings/alerting/rules${ + rulesListSearch.toString() ? `?${rulesListSearch}` : "" + }`; + const alertButton = alertRulesLoading ? null : hasRules ? ( + ) : hasRules ? ( + defaultDomainId={ + form.watch( + "domainId" + ) ?? undefined + } + defaultFullDomain={ + resourceFullDomainName || + undefined + } + onDomainChange={(res) => { + if (res === null) { + form.setValue( + "domainId", + undefined + ); + form.setValue( + "subdomain", + undefined + ); + setResourceFullDomain( + `${resource.ssl ? "https" : "http"}://` + ); + return; + } + form.setValue( + "domainId", + res.domainId + ); + form.setValue( + "subdomain", + res.subdomain ?? + undefined + ); + setResourceFullDomain( + `${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}` + ); + }} + />
    )} + + ( + +
    + + + form.setValue( + "enabled", + val + ) + } + /> + +
    + +
    + )} + /> @@ -767,86 +776,6 @@ export default function GeneralForm() { /> )} - - setEditDomainOpen(setOpen)} - > - - - Edit Domain - - Select a domain for your resource - - - - { - const selected = - res === null - ? null - : { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain, - domainNamespaceId: - res.domainNamespaceId - }; - - setSelectedDomain(selected); - }} - /> - - - - - - - - - ); } From b3aafa5fe6b132a1c93b5fe5f191fb5fd316cc3f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 18:05:08 -0700 Subject: [PATCH 151/176] Handle toggles --- .../lib/alerts/events/healthCheckEvents.ts | 20 +++++++++++++++++ .../lib/alerts/events/resourceEvents.ts | 22 ++++++++++++++++++- .../private/lib/alerts/events/siteEvents.ts | 22 ++++++++++++++++++- server/private/lib/alerts/processAlerts.ts | 2 +- .../newt/handleNewtDisconnectingMessage.ts | 8 +++++-- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 594e27aec..02b46e94e 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -46,6 +46,16 @@ export async function fireHealthCheckHealthyAlert( ...extra } }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, @@ -82,6 +92,16 @@ export async function fireHealthCheckNotHealthyAlert( ...extra } }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 5c9b168e8..eb84982aa 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -46,6 +46,16 @@ export async function fireResourceHealthyAlert( ...extra } }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, @@ -82,6 +92,16 @@ export async function fireResourceUnhealthyAlert( ...extra } }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, @@ -124,4 +144,4 @@ export async function fireResourceToggleAlert( err ); } -} \ No newline at end of file +} diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 27c4cb8bf..aeb8a8d2c 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -46,6 +46,16 @@ export async function fireSiteOnlineAlert( ...extra } }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, @@ -82,10 +92,20 @@ export async function fireSiteOfflineAlert( ...extra } }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); } catch (err) { logger.error( `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, err ); } -} \ No newline at end of file +} diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 5e098a1f2..681eb3cd0 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -330,4 +330,4 @@ async function resolveEmailRecipients(emailActionId: number): Promise } return Array.from(emailSet); -} \ No newline at end of file +} diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index 02c5a95ac..302738f19 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -2,6 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import { db, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { fireSiteOfflineAlert } from "@server/lib/alerts"; /** * Handles disconnecting messages from sites to show disconnected in the ui @@ -24,12 +25,15 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( try { // Update the client's last ping timestamp - await db + const [site] = await db .update(sites) .set({ online: false }) - .where(eq(sites.siteId, newt.siteId)); + .where(eq(sites.siteId, newt.siteId)) + .returning(); + + await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); } catch (error) { logger.error("Error handling disconnecting message", { error }); } From 6f07156075aa111310c7fc6e8641f3769335ae7b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 18:19:27 -0700 Subject: [PATCH 152/176] adjust email template for alerts --- server/emails/templates/AlertNotification.tsx | 80 ++++++++++++++----- .../emails/templates/NotifyTrialExpiring.tsx | 6 +- server/emails/templates/components/Email.tsx | 4 +- .../lib/alerts/events/healthCheckEvents.ts | 2 - .../lib/alerts/events/resourceEvents.ts | 3 - .../private/lib/alerts/events/siteEvents.ts | 2 - server/private/lib/alerts/sendAlertEmail.ts | 6 +- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 418924650..5ee7d6375 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -8,9 +8,11 @@ import { EmailHeading, EmailInfoSection, EmailLetterHead, + EmailSection, EmailSignature, EmailText } from "./components/Email"; +import ButtonLink from "./components/ButtonLink"; export type AlertEventType = | "site_online" @@ -23,11 +25,38 @@ export type AlertEventType = | "resource_unhealthy" | "resource_toggle"; -interface Props { +// --------------------------------------------------------------------------- +// Local preview / layout testing +// --------------------------------------------------------------------------- +// +// Set to `true` while running `npm run email` or otherwise rendering this +// template without real alert context. Uses `alertNotificationFixture` below +// and ignores props passed by callers (including real alert sends). Must be +// `false` before shipping or triggering real alert emails. + +export const USE_FAKE_ALERT_NOTIFICATION_DATA = true; + +export type AlertNotificationProps = { eventType: AlertEventType; orgId: string; data: Record; -} + dashboardLink: string; +}; + +/** Sample props for previews; also used when `USE_FAKE_ALERT_NOTIFICATION_DATA` is true. */ +export const alertNotificationFixture: AlertNotificationProps = { + eventType: "site_online", + orgId: "org_preview_7a3c2f91", + dashboardLink: + "https://app.pangolin.net/org_preview_7a3c2f91/settings/alerting/rules", + data: { + siteId: 42, + healthCheckName: "Edge API – readiness probe", + targetUrl: "https://api.example.com/internal/health", + lastFailureMessage: "Connection timed out after 5000ms", + consecutiveFailures: 3 + } +}; function getEventMeta(eventType: AlertEventType): { heading: string; @@ -51,7 +80,7 @@ function getEventMeta(eventType: AlertEventType): { heading: "Site Offline", previewText: "A site in your organization has gone offline.", summary: - "A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.", + "A site in your organization has gone offline and is no longer reachable.", statusLabel: "Offline", statusColor: "#dc2626" }; @@ -59,8 +88,7 @@ function getEventMeta(eventType: AlertEventType): { return { heading: "Site Status Changed", previewText: "A site in your organization has changed status.", - summary: - "A site in your organization has changed status. Please review the details below and take action if needed.", + summary: "A site in your organization has changed status.", statusLabel: "Status Changed", statusColor: "#f59e0b" }; @@ -80,7 +108,7 @@ function getEventMeta(eventType: AlertEventType): { previewText: "A health check in your organization is not healthy.", summary: - "A health check in your organization is currently failing. Please review the details below and take action if needed.", + "A health check in your organization is currently failing.", statusLabel: "Not Healthy", statusColor: "#dc2626" }; @@ -90,7 +118,7 @@ function getEventMeta(eventType: AlertEventType): { previewText: "A health check in your organization has changed status.", summary: - "A health check in your organization has changed status. Please review the details below and take action if needed.", + "A health check in your organization has changed status.", statusLabel: "Status Changed", statusColor: "#f59e0b" }; @@ -108,7 +136,7 @@ function getEventMeta(eventType: AlertEventType): { heading: "Resource Unhealthy", previewText: "A resource in your organization is not healthy.", summary: - "A resource in your organization is currently unhealthy. Please review the details below and take action if needed.", + "A resource in your organization is currently unhealthy.", statusLabel: "Unhealthy", statusColor: "#dc2626" }; @@ -117,17 +145,16 @@ function getEventMeta(eventType: AlertEventType): { heading: "Resource Status Changed", previewText: "A resource in your organization has changed status.", - summary: - "A resource in your organization has changed status. Please review the details below and take action if needed.", + summary: "A resource in your organization has changed status.", statusLabel: "Status Changed", statusColor: "#f59e0b" }; default: return { heading: "Alert Notification", - previewText: "An alert event has occurred in your organization.", - summary: - "An alert event has occurred in your organization. Please review the details below and take action if needed.", + previewText: + "An alert event has occurred in your organization.", + summary: "An alert event has occurred in your organization.", statusLabel: "Alert", statusColor: "#f59e0b" }; @@ -148,17 +175,22 @@ function formatDataItems( })); } -export const AlertNotification = ({ eventType, orgId, data }: Props) => { +export const AlertNotification = (props: AlertNotificationProps) => { + const { eventType, orgId, data, dashboardLink } = + USE_FAKE_ALERT_NOTIFICATION_DATA ? alertNotificationFixture : props; const meta = getEventMeta(eventType); const dataItems = formatDataItems(data); const allItems: { label: string; value: React.ReactNode }[] = [ { label: "Organization", value: orgId }, - { label: "Status", value: ( - - {meta.statusLabel} - - )}, + { + label: "Status", + value: ( + + {meta.statusLabel} + + ) + }, { label: "Time", value: new Date().toUTCString() }, ...dataItems ]; @@ -184,10 +216,16 @@ export const AlertNotification = ({ eventType, orgId, data }: Props) => { /> - Log in to your dashboard to view more details and - manage your alert rules. + Open your dashboard to view more details and manage + your alert rules. + + + Open Dashboard + + + diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx index 5e8f0e6a8..7cd6d30ac 100644 --- a/server/emails/templates/NotifyTrialExpiring.tsx +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -36,8 +36,8 @@ export const NotifyTrialExpiring = ({ : `Your trial for ${orgName} ends in ${daysRemaining} days.`; const heading = hasEnded - ? "Your Trial Has Ended" - : "Your Trial Is Ending Soon"; + ? "Your Trial Ended" + : "Your Trial is Ending Soon"; return ( @@ -124,4 +124,4 @@ export const NotifyTrialExpiring = ({ ); }; -export default NotifyTrialExpiring; \ No newline at end of file +export default NotifyTrialExpiring; diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index 71d8b4671..f74046042 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -5,7 +5,7 @@ import { build } from "@server/build"; // EmailContainer: Wraps the entire email layout export function EmailContainer({ children }: { children: React.ReactNode }) { return ( - + {children} ); @@ -18,7 +18,7 @@ export function EmailLetterHead() { Pangolin Logo diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 02b46e94e..2e1f591a2 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -41,7 +41,6 @@ export async function fireHealthCheckHealthyAlert( orgId, healthCheckId, data: { - healthCheckId, ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } @@ -87,7 +86,6 @@ export async function fireHealthCheckNotHealthyAlert( orgId, healthCheckId, data: { - healthCheckId, ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index eb84982aa..280b1d0c9 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -41,7 +41,6 @@ export async function fireResourceHealthyAlert( orgId, resourceId, data: { - resourceId, ...(resourceName != null ? { resourceName } : {}), ...extra } @@ -87,7 +86,6 @@ export async function fireResourceUnhealthyAlert( orgId, resourceId, data: { - resourceId, ...(resourceName != null ? { resourceName } : {}), ...extra } @@ -133,7 +131,6 @@ export async function fireResourceToggleAlert( orgId, resourceId, data: { - resourceId, ...(resourceName != null ? { resourceName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index aeb8a8d2c..d8531a5b7 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -41,7 +41,6 @@ export async function fireSiteOnlineAlert( orgId, siteId, data: { - siteId, ...(siteName != null ? { siteName } : {}), ...extra } @@ -87,7 +86,6 @@ export async function fireSiteOfflineAlert( orgId, siteId, data: { - siteId, ...(siteName != null ? { siteName } : {}), ...extra } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 634598158..5e818678d 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -36,13 +36,17 @@ export async function sendAlertEmail( const from = config.getNoReplyEmail(); const subject = buildSubject(context); + const baseUrl = config.getRawConfig().app.dashboard_url!.replace(/\/$/, ""); + const dashboardLink = `${baseUrl}/${context.orgId}/settings`; + for (const to of recipients) { try { await sendEmail( AlertNotification({ eventType: context.eventType, orgId: context.orgId, - data: context.data + data: context.data, + dashboardLink }), { from, From 88eb1649e4a9959746e7fa351c37fdd4a2f4d7fd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 18:35:38 -0700 Subject: [PATCH 153/176] add server filters to health check table --- messages/en-US.json | 16 + .../routers/healthChecks/listHealthChecks.ts | 52 ++- .../alerting/(list)/health-checks/page.tsx | 134 +++++- src/components/HealthChecksTable.tsx | 390 +++++++++++++++--- src/components/ui/controlled-data-table.tsx | 14 +- src/lib/queries.ts | 29 +- 6 files changed, 562 insertions(+), 73 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6da7ccb94..e4d55e4b9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1499,6 +1499,22 @@ "standaloneHcColumnHealth": "Health", "standaloneHcColumnMode": "Mode", "standaloneHcColumnTarget": "Target", + "standaloneHcHealthStateHealthy": "Healthy", + "standaloneHcHealthStateUnhealthy": "Unhealthy", + "standaloneHcHealthStateUnknown": "Unknown", + "standaloneHcFilterAnySite": "All sites", + "standaloneHcFilterAnyResource": "All resources", + "standaloneHcFilterMode": "Mode", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Health", + "standaloneHcFilterEnabled": "Enabled", + "standaloneHcFilterEnabledOn": "Enabled", + "standaloneHcFilterEnabledOff": "Disabled", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Resource {id}", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index e87525a3f..26cb75e9c 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -17,7 +17,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, isNotNull, like, sql } from "drizzle-orm"; +import { and, eq, exists, isNotNull, like, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; @@ -40,7 +40,23 @@ const querySchema = z.object({ .default("0") .transform(Number) .pipe(z.int().nonnegative()), - query: z.string().optional() + query: z.string().optional(), + hcMode: z.enum(["http", "tcp", "snmp", "ping"]).optional(), + siteId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + resourceId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + hcHealth: z.enum(["healthy", "unhealthy", "unknown"]).optional(), + hcEnabled: z + .enum(["true", "false"]) + .optional() + .transform((v) => (v === undefined ? undefined : v === "true")) }); registry.registerPath({ @@ -81,7 +97,30 @@ export async function listHealthChecks( ) ); } - const { limit, offset, query } = parsedQuery.data; + const { + limit, + offset, + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled + } = parsedQuery.data; + + const resourceIdFilter = resourceId + ? exists( + db + .select() + .from(targets) + .where( + and( + eq(targets.targetId, targetHealthCheck.targetId), + eq(targets.resourceId, resourceId) + ) + ) + ) + : undefined; const whereClause = and( eq(targetHealthCheck.orgId, orgId), @@ -91,6 +130,13 @@ export async function listHealthChecks( sql`LOWER(${targetHealthCheck.name})`, `%${query.toLowerCase()}%` ) + : undefined, + hcMode ? eq(targetHealthCheck.hcMode, hcMode) : undefined, + siteId ? eq(targetHealthCheck.siteId, siteId) : undefined, + resourceIdFilter, + hcHealth ? eq(targetHealthCheck.hcHealth, hcHealth) : undefined, + hcEnabled !== undefined + ? eq(targetHealthCheck.hcEnabled, hcEnabled) : undefined ); diff --git a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx index 8bb19fc8c..10e09bfe8 100644 --- a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx +++ b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx @@ -3,7 +3,9 @@ import DismissableBanner from "@app/components/DismissableBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { AxiosResponse } from "axios"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; +import type ResponseT from "@server/types/Response"; import { HeartPulse } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -26,24 +28,70 @@ function parsePositiveInt(s: string | undefined): number | undefined { return n; } +function appendListFilters( + apiSp: URLSearchParams, + searchParams: URLSearchParams +) { + const query = searchParams.get("query"); + if (query) apiSp.set("query", query); + + const hcMode = searchParams.get("hcMode"); + if ( + hcMode === "http" || + hcMode === "tcp" || + hcMode === "snmp" || + hcMode === "ping" + ) { + apiSp.set("hcMode", hcMode); + } + + const hcHealth = searchParams.get("hcHealth"); + if ( + hcHealth === "healthy" || + hcHealth === "unhealthy" || + hcHealth === "unknown" + ) { + apiSp.set("hcHealth", hcHealth); + } + + const hcEnabled = searchParams.get("hcEnabled"); + if (hcEnabled === "true" || hcEnabled === "false") { + apiSp.set("hcEnabled", hcEnabled); + } + + const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined); + if (siteId) { + apiSp.set("siteId", String(siteId)); + } + + const resourceId = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + if (resourceId) { + apiSp.set("resourceId", String(resourceId)); + } +} + export default async function AlertingHealthChecksPage( props: AlertingHealthChecksPageProps ) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); - const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1); + const page = Math.max( + 1, + parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1 + ); const pageSize = Math.max( 1, parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 ); const pageIndex = page - 1; - const query = searchParams.get("query") ?? undefined; const apiSp = new URLSearchParams(); apiSp.set("limit", String(pageSize)); apiSp.set("offset", String(pageIndex * pageSize)); - if (query) apiSp.set("query", query); + appendListFilters(apiSp, searchParams); let healthChecks: ListHealthChecksResponse["healthChecks"] = []; let pagination: ListHealthChecksResponse["pagination"] = { @@ -51,18 +99,80 @@ export default async function AlertingHealthChecksPage( limit: pageSize, offset: pageIndex * pageSize }; + + const siteIdParam = parsePositiveInt( + searchParams.get("siteId") ?? undefined + ); + const resourceIdParam = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + + const header = await authCookieHeader(); + try { - const res = await internal.get>( + const res = await internal.get( `/org/${params.orgId}/health-checks?${apiSp.toString()}`, - await authCookieHeader() + header ); - const responseData = res.data.data; - healthChecks = responseData.healthChecks; - pagination = responseData.pagination; + const responseData = (res.data as ResponseT) + .data; + if (responseData) { + healthChecks = responseData.healthChecks; + pagination = responseData.pagination; + } } catch { // leave defaults } + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get(`/site/${siteIdParam}`, header); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } + + let initialFilterResource: { + name: string; + resourceId: number; + fullDomain: string | null; + niceId: string; + ssl: boolean; + } | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const r = (resourceRes.data as ResponseT).data; + if (r && r.orgId === params.orgId) { + initialFilterResource = { + name: r.name, + resourceId: r.resourceId, + fullDomain: r.fullDomain, + niceId: r.niceId, + ssl: r.ssl + }; + } + } catch { + // leave null + } + } + const t = await getTranslations(); return ( @@ -80,6 +190,12 @@ export default async function AlertingHealthChecksPage( orgId={params.orgId} healthChecks={healthChecks} rowCount={pagination.total} + pagination={{ + pageIndex, + pageSize + }} + initialFilterSite={initialFilterSite} + initialFilterResource={initialFilterResource} />
    ); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 510639b46..404ade547 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -6,23 +6,42 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { HealthCheckRow } from "@app/components/HealthCheckCredenza"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; -import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { + ResourceSelector, + SelectedResource +} from "@app/components/resource-selector"; +import { + ArrowUpDown, + ArrowUpRight, + Funnel, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState, useTransition, useEffect } from "react"; +import { useState, useTransition, useEffect, useMemo } from "react"; import type { PaginationState } from "@tanstack/react-table"; -import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import Link from "next/link"; @@ -30,11 +49,15 @@ import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { cn } from "@app/lib/cn"; type StandaloneHealthChecksTableProps = { orgId: string; healthChecks: HealthCheckRow[]; rowCount: number; + pagination: PaginationState; + initialFilterSite?: Selectedsite | null; + initialFilterResource?: SelectedResource | null; }; function formatTarget(row: HealthCheckRow): string { @@ -43,6 +66,12 @@ function formatTarget(row: HealthCheckRow): string { if (!row.hcPort) return row.hcHostname; return `${row.hcHostname}:${row.hcPort}`; } + if (row.hcMode === "snmp" || row.hcMode === "ping") { + if (row.hcPort) { + return `${row.hcHostname}:${row.hcPort}`; + } + return row.hcHostname; + } // HTTP / default const scheme = row.hcScheme ?? "http"; const host = row.hcHostname; @@ -51,16 +80,13 @@ function formatTarget(row: HealthCheckRow): string { return `${scheme}://${host}${port}${path}`; } -const healthLabel: Record = { - healthy: "Healthy", - unhealthy: "Unhealthy", - unknown: "Unknown" -}; - export default function HealthChecksTable({ orgId, healthChecks, - rowCount + rowCount, + pagination, + initialFilterSite = null, + initialFilterResource = null }: StandaloneHealthChecksTableProps) { const router = useRouter(); const t = useTranslations(); @@ -79,15 +105,56 @@ export default function HealthChecksTable({ const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); - const page = Math.max(1, Number(searchParams.get("page") ?? 1)); - const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); - const pageIndex = page - 1; + const pageSize = pagination.pageSize; const query = searchParams.get("query") ?? undefined; + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + const selectedSite: Selectedsite | null = useMemo(() => { + if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { + return null; + } + if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { + return initialFilterSite; + } + return { + siteId: siteIdNum, + name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), + type: "newt" + }; + }, [initialFilterSite, siteIdQ, siteIdNum, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("standaloneHcFilterResourceIdFallback", { + id: resourceIdNum + }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); + const rows = healthChecks; - const total = rowCount; - const pageCount = Math.max(1, Math.ceil(total / pageSize)); function refreshList() { startRefresh(() => { @@ -102,12 +169,6 @@ export default function HealthChecksTable({ return () => clearInterval(interval); }, [router]); - const paginationState: DataTablePaginationState = { - pageIndex, - pageSize, - pageCount - }; - const handlePaginationChange = (newState: PaginationState) => { searchParams.set("page", (newState.pageIndex + 1).toString()); searchParams.set("pageSize", newState.pageSize.toString()); @@ -124,6 +185,39 @@ export default function HealthChecksTable({ filter({ searchParams }); }, 300); + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + if (value) { + sp.set(column, value); + } + filter({ searchParams: sp }); + } + + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + const handleToggleEnabled = async ( row: HealthCheckRow, enabled: boolean @@ -166,6 +260,27 @@ export default function HealthChecksTable({ } }; + const modeParam = searchParams.get("hcMode"); + const selectedHcMode = + modeParam === "http" || + modeParam === "tcp" || + modeParam === "snmp" || + modeParam === "ping" + ? modeParam + : undefined; + const healthParam = searchParams.get("hcHealth"); + const selectedHcHealth = + healthParam === "healthy" || + healthParam === "unhealthy" || + healthParam === "unknown" + ? healthParam + : undefined; + const enabledParam = searchParams.get("hcEnabled"); + const selectedHcEnabled = + enabledParam === "true" || enabledParam === "false" + ? enabledParam + : undefined; + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -190,12 +305,34 @@ export default function HealthChecksTable({ id: "mode", friendlyName: t("standaloneHcColumnMode"), header: () => ( - {t("standaloneHcColumnMode")} + + handleFilterChange("hcMode", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("standaloneHcColumnMode")} + className="p-3" + /> ), cell: ({ row }) => ( - - {row.original.hcMode?.toUpperCase() ?? "-"} - + {row.original.hcMode?.toUpperCase() ?? "-"} ) }, { @@ -208,9 +345,58 @@ export default function HealthChecksTable({ }, { id: "resource", - friendlyName: "Resource", + friendlyName: t("resource"), header: () => ( - Resource + + + + + +
    + +
    + +
    +
    ), cell: ({ row }) => { const r = row.original; @@ -218,7 +404,9 @@ export default function HealthChecksTable({ return -; } return ( - + + + +
    + +
    + +
    + ), cell: ({ row }) => { const r = row.original; @@ -239,7 +473,9 @@ export default function HealthChecksTable({ return -; } return ( - + @@ -363,7 +642,6 @@ export default function HealthChecksTable({ {t("edit")} )} -
    ); } @@ -405,14 +683,13 @@ export default function HealthChecksTable({ - { setSelected(null); setCredenzaOpen(true); @@ -424,8 +701,9 @@ export default function HealthChecksTable({ enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" - pagination={paginationState} + pagination={pagination} onPaginationChange={handlePaginationChange} + rowCount={rowCount} /> ); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 1690d92a8..7217006e8 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -42,7 +42,7 @@ import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown export type ExtendedColumnDef = ColumnDef< @@ -84,6 +84,8 @@ type ControlledDataTableProps = { isNavigatingToAddPage?: boolean; searchPlaceholder?: string; filters?: DataTableFilter[]; + /** Extra filter controls (e.g. searchable entity pickers) shown after the filter dropdowns. */ + filterExtras?: ReactNode; filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) columnVisibility?: Record; enableColumnVisibility?: boolean; @@ -108,6 +110,7 @@ export function ControlledDataTable({ refreshButtonDisabled = false, searchPlaceholder = "Search...", filters, + filterExtras, filterDisplayMode = "label", columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, @@ -343,6 +346,7 @@ export function ControlledDataTable({ })}
    )} + {filterExtras}
    {onRefresh && ( @@ -350,7 +354,9 @@ export function ControlledDataTable({
    )} - {addActions && addActions.length > 0 && addButtonText ? ( + {addActions && + addActions.length > 0 && + addButtonText ? (
    diff --git a/src/lib/queries.ts b/src/lib/queries.ts index ff909c311..97515e796 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -361,25 +361,50 @@ export const orgQueries = { orgId, limit = 20, offset = 0, - query + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled }: { orgId: string; limit?: number; offset?: number; query?: string; + hcMode?: "http" | "tcp" | "snmp" | "ping"; + siteId?: number; + resourceId?: number; + hcHealth?: "healthy" | "unhealthy" | "unknown"; + hcEnabled?: "true" | "false"; }) => queryOptions({ queryKey: [ "ORG", orgId, "STANDALONE_HEALTH_CHECKS", - { limit, offset, query } + { + limit, + offset, + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled + } ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams(); sp.set("limit", String(limit)); sp.set("offset", String(offset)); if (query) sp.set("query", query); + if (hcMode) sp.set("hcMode", hcMode); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) + sp.set("resourceId", String(resourceId)); + if (hcHealth) sp.set("hcHealth", hcHealth); + if (hcEnabled) sp.set("hcEnabled", hcEnabled); const res = await meta!.api.get< AxiosResponse<{ healthChecks: { From 320543f7f85c3b218bdefbe33a80c9c4c35e0c4f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 19:37:38 -0700 Subject: [PATCH 154/176] change titles --- src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx | 2 +- src/app/[orgId]/settings/health-checks/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx index 10e09bfe8..5cbb9ea3d 100644 --- a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx +++ b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx @@ -11,7 +11,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; export const metadata: Metadata = { - title: "Health checks" + title: "Health Checks" }; type AlertingHealthChecksPageProps = { diff --git a/src/app/[orgId]/settings/health-checks/page.tsx b/src/app/[orgId]/settings/health-checks/page.tsx index e35880f56..2ee133d91 100644 --- a/src/app/[orgId]/settings/health-checks/page.tsx +++ b/src/app/[orgId]/settings/health-checks/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; export const metadata: Metadata = { - title: "Health checks" + title: "Health Checks" }; type LegacyHealthChecksPageProps = { From a7c212ffa4b71bc143c23760eb1ff50cf0c66d29 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 20:20:33 -0700 Subject: [PATCH 155/176] badge fixes --- src/app/[orgId]/settings/access/users/create/page.tsx | 4 ++-- src/components/UsersTable.tsx | 1 + src/components/alert-rule-editor/AlertRuleGraphEditor.tsx | 5 +++-- src/components/ui/badge.tsx | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 2c3292f9e..cec399a32 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -50,6 +50,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import CopyToClipboard from "@app/components/CopyToClipboard"; type UserType = "internal" | "oidc"; @@ -670,9 +671,8 @@ export default function Page() { days: expiresInDays })}

    -
    diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 979c59425..50915c02b 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -382,6 +382,7 @@ export default function UsersTable({ pagination={pagination} rowCount={rowCount} isNavigatingToAddPage={isNavigatingToAddPage} + addButtonText={t("accessUserCreate")} searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} onPaginationChange={handlePaginationChange} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 524f17482..ef31c3476 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -39,6 +39,7 @@ import { useTranslations } from "next-intl"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SwitchInput } from "@app/components/SwitchInput"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { Badge } from "../ui/badge"; const FORM_ID = "alert-rule-form"; @@ -180,9 +181,9 @@ export default function AlertRuleGraphEditor({ >
    {isNew && ( - + {t("alertingDraftBadge")} - + )}
    Date: Tue, 21 Apr 2026 20:29:05 -0700 Subject: [PATCH 156/176] add way to reject a pending site --- messages/en-US.json | 3 +- src/components/PendingSitesTable.tsx | 117 +++++++++++++++++++++------ 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e4d55e4b9..89c6247db 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3139,5 +3139,6 @@ "idpUnassociateMenu": "Unassociate", "idpDeleteAllOrgsMenu": "Delete", "publicIpEndpoint": "Endpoint", - "lastTriggeredAt": "Last Trigger" + "lastTriggeredAt": "Last Trigger", + "reject": "Reject" } diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index a6625037d..d32aee95e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -1,5 +1,6 @@ "use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { @@ -24,7 +25,8 @@ import { ArrowUpRight, Check, ChevronsUpDownIcon, - MoreHorizontal + MoreHorizontal, + X } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -62,6 +64,9 @@ export default function PendingSitesTable({ const [isRefreshing, startTransition] = useTransition(); const [approvingIds, setApprovingIds] = useState>(new Set()); + const [rejectingIds, setRejectingIds] = useState>(new Set()); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedSite, setSelectedSite] = useState(null); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -128,6 +133,33 @@ export default function PendingSitesTable({ } } + async function rejectSite(siteId: number) { + setRejectingIds((prev) => new Set(prev).add(siteId)); + try { + await api.delete(`/site/${siteId}`); + toast({ + title: t("success"), + description: t("siteDeleted"), + variant: "default" + }); + setIsDeleteModalOpen(false); + setSelectedSite(null); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorDelete"), + description: formatAxiosError(e, t("siteErrorDelete")) + }); + } finally { + setRejectingIds((prev) => { + const next = new Set(prev); + next.delete(siteId); + return next; + }); + } + } + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -387,6 +419,7 @@ export default function PendingSitesTable({ cell: ({ row }) => { const siteRow = row.original; const isApproving = approvingIds.has(siteRow.id); + const isRejecting = rejectingIds.has(siteRow.id); return (
    @@ -409,7 +442,18 @@ export default function PendingSitesTable({ + + + + {addActions.map((action, i) => ( + action.onSelect()}> + {action.label} + + ))} + + + ) : onAdd && addButtonText ? ( + + ) : null; + return (
    @@ -367,51 +400,15 @@ export function ControlledDataTable({
    )} - {addActions && - addActions.length > 0 && - addButtonText ? ( -
    - - - - - - {addActions.map((action, i) => ( - - action.onSelect() - } - > - {action.label} - - ))} - - -
    - ) : ( - onAdd && - addButtonText && ( -
    - -
    - ) + {addAction && ( + <> +
    {addAction}
    + {!showAddActionInEmptyState && ( +
    + {addAction} +
    + )} + )}
    @@ -606,14 +603,18 @@ export function ControlledDataTable({ )) ) : ( - - - No results found. - - + + {addAction} +
    + ) + : undefined + } + /> )} diff --git a/src/components/ui/data-table-empty-state.tsx b/src/components/ui/data-table-empty-state.tsx new file mode 100644 index 000000000..793c360f4 --- /dev/null +++ b/src/components/ui/data-table-empty-state.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { TableCell, TableRow } from "@/components/ui/table"; +import { useTranslations } from "next-intl"; +import { type ReactNode } from "react"; + +const PLACEHOLDER_ROW_COUNT = 5; + +type DataTableEmptyStateProps = { + colSpan: number; + action?: ReactNode; +}; + +export function DataTableEmptyState({ + colSpan, + action +}: DataTableEmptyStateProps) { + const t = useTranslations(); + return ( + + +
    +
    + {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map( + (_, i) => ( +
    + ) + )} +
    +
    +

    + {t("noResults")} +

    + {action} +
    +
    + + + ); +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 2c0e5e48c..82aafe1f4 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -29,6 +29,7 @@ import { TableHeader, TableRow } from "@/components/ui/table"; +import { DataTableEmptyState } from "@/components/ui/data-table-empty-state"; import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; @@ -515,6 +516,36 @@ export function DataTable({ return ""; }; + const tableRows = table.getRowModel().rows; + const hasRows = tableRows.length > 0; + const hasAddAction = Boolean( + addButtonText && ((addActions && addActions.length > 0) || onAdd) + ); + const showAddActionInEmptyState = !hasRows && hasAddAction; + const addAction = addActions && addActions.length > 0 && addButtonText ? ( + + + + + + {addActions.map((action, i) => ( + action.onSelect()}> + {action.label} + + ))} + + + ) : onAdd && addButtonText ? ( + + ) : null; + return (
    @@ -651,45 +682,15 @@ export function DataTable({
    )} - {addActions && addActions.length > 0 && addButtonText ? ( -
    - - - - - - {addActions.map((action, i) => ( - - action.onSelect() - } - > - {action.label} - - ))} - - -
    - ) : ( - onAdd && - addButtonText && ( -
    - -
    - ) + {addAction && ( + <> +
    {addAction}
    + {!showAddActionInEmptyState && ( +
    + {addAction} +
    + )} + )}
    @@ -884,14 +885,18 @@ export function DataTable({
    )) ) : ( - - - No results found. - - + + {addAction} +
    + ) + : undefined + } + /> )} From 7f5c164e1606a4b9a7bef24d4b597056c1fe674a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 20:51:59 -0700 Subject: [PATCH 158/176] change logging --- server/apiServer.ts | 2 +- server/internalServer.ts | 2 +- server/lib/telemetry.ts | 2 +- server/nextServer.ts | 2 +- server/private/lib/acmeCertSync.ts | 10 +++++----- server/routers/newt/pingAccumulator.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/apiServer.ts b/server/apiServer.ts index dafec1b85..9a91d473e 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -121,7 +121,7 @@ export function createApiServer() { const httpServer = apiServer.listen(externalPort, (err?: any) => { if (err) throw err; logger.info( - `API server is running on http://localhost:${externalPort}` + `Dashboard API server is running on http://localhost:${externalPort}` ); }); diff --git a/server/internalServer.ts b/server/internalServer.ts index 7ba046e4b..83872e7f9 100644 --- a/server/internalServer.ts +++ b/server/internalServer.ts @@ -36,7 +36,7 @@ export function createInternalServer() { internalServer.listen(internalPort, (err?: any) => { if (err) throw err; logger.info( - `Internal server is running on http://localhost:${internalPort}` + `Internal API server is running on http://localhost:${internalPort}` ); }); diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index fda59f394..8d341bf1d 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -72,7 +72,7 @@ class TelemetryClient { logger.debug("Successfully sent analytics data"); }); }, - 48 * 60 * 60 * 1000 + 336 * 60 * 60 * 1000 ); this.collectAndSendAnalytics().catch((err) => { diff --git a/server/nextServer.ts b/server/nextServer.ts index b862a699c..e5e81b724 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -29,7 +29,7 @@ export async function createNextServer() { nextServer.listen(nextPort, (err?: any) => { if (err) throw err; logger.info( - `Next.js server is running on http://localhost:${nextPort}` + `Dashboard Web UI server is running on http://localhost:${nextPort}` ); }); diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index faa45b08e..62a18b805 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -89,7 +89,7 @@ async function pushCertUpdateToAffectedNewts( return; } - logger.info( + logger.debug( `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` ); @@ -187,7 +187,7 @@ async function pushCertUpdateToAffectedNewts( newt.version ); - logger.info( + logger.debug( `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}` ); } @@ -400,7 +400,7 @@ async function syncAcmeCerts( }) .where(eq(certificates.domain, domain)); - logger.info( + logger.debug( `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); @@ -423,7 +423,7 @@ async function syncAcmeCerts( wildcard }); - logger.info( + logger.debug( `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` ); @@ -461,7 +461,7 @@ export function initAcmeCertSync(): void { const resolver = privateConfigData.acme?.resolver ?? "letsencrypt"; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; - logger.info( + logger.debug( `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` ); diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index b63bf97d3..9b2f04c8e 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -381,7 +381,7 @@ export function startPingAccumulator(): void { // Don't prevent the process from exiting flushTimer.unref(); - logger.info( + logger.debug( `Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)` ); } From 19e0452d844c36b28559536c29d3edf521400326 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 20:54:57 -0700 Subject: [PATCH 159/176] remove arrow icon on server admin --- src/components/LayoutMobileMenu.tsx | 1 - src/components/LayoutSidebar.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 854cad6db..13efdd564 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -101,7 +101,6 @@ export function LayoutMobileMenu({ "serverAdmin" )} -
    )} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a66a8300b..19a095419 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -189,7 +189,6 @@ export function LayoutSidebar({ {t("serverAdmin")} - )} From c9caa44c0687cce1a2617debfcd5f473b7d595fe Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 21:13:31 -0700 Subject: [PATCH 160/176] Making the alerts work --- messages/en-US.json | 2 ++ server/nextServer.ts | 2 +- .../lib/alerts/events/healthCheckEvents.ts | 2 +- .../alertEvents/triggerHealthCheckAlert.ts | 6 ++-- .../routers/alertRule/createAlertRule.ts | 2 +- .../routers/healthChecks/createHealthCheck.ts | 2 +- server/routers/target/createTarget.ts | 4 +-- .../target/handleHealthcheckStatusMessage.ts | 32 +++++++++++++----- server/routers/target/updateTarget.ts | 4 +-- .../AlertRuleGraphEditor.tsx | 33 +++++++++++++++++++ src/lib/alertRuleForm.ts | 6 ++++ 11 files changed, 76 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e4d55e4b9..5c0ca434b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1469,6 +1469,8 @@ "alertingConfigureTrigger": "Configure Trigger", "alertingConfigureActions": "Configure Actions", "alertingBackToRules": "Back to Rules", + "alertingRuleCooldown": "Cooldown (seconds)", + "alertingRuleCooldownDescription": "Minimum time between repeated alerts for the same rule. Set to 0 to fire every time.", "alertingDraftBadge": "Draft - save to store this rule", "alertingSidebarHint": "Click a step on the canvas to edit it here.", "alertingGraphCanvasTitle": "Rule Flow", diff --git a/server/nextServer.ts b/server/nextServer.ts index b862a699c..deb74d309 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -11,7 +11,7 @@ export async function createNextServer() { // const app = next({ dev }); const app = next({ dev: process.env.ENVIRONMENT !== "prod", - turbopack: true + turbopack: false }); const handle = app.getRequestHandler(); diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 2e1f591a2..cd9f3f1c3 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -74,7 +74,7 @@ export async function fireHealthCheckHealthyAlert( * @param healthCheckName - Human-readable name shown in notifications (optional). * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckNotHealthyAlert( +export async function fireHealthCheckUnhealthyAlert( orgId: string, healthCheckId: number, healthCheckName?: string | null, diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts index 246de8cd0..94202b0b2 100644 --- a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { fireHealthCheckHealthyAlert, - fireHealthCheckNotHealthyAlert + fireHealthCheckUnhealthyAlert } from "#private/lib/alerts/events/healthCheckEvents"; const paramsSchema = z.strictObject({ @@ -106,7 +106,7 @@ export async function triggerHealthCheckAlert( healthCheck.name ?? undefined ); } else { - await fireHealthCheckNotHealthyAlert( + await fireHealthCheckUnhealthyAlert( orgId, healthCheckId, healthCheck.name ?? undefined @@ -126,4 +126,4 @@ export async function triggerHealthCheckAlert( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 8a31327ab..b9d17d35d 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -63,7 +63,7 @@ const bodySchema = z ...RESOURCE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), - cooldownSeconds: z.number().int().nonnegative().optional().default(300), + cooldownSeconds: z.number().int().nonnegative().optional().default(0), // Source join tables - which is required depends on eventType siteIds: z.array(z.number().int().positive()).optional().default([]), allSites: z.boolean().optional().default(false), diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index ff5495e55..374ec4ba4 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -39,7 +39,7 @@ const bodySchema = z.strictObject({ hcMethod: z.string().default("GET"), hcInterval: z.number().int().positive().default(30), hcUnhealthyInterval: z.number().int().positive().default(30), - hcTimeout: z.number().int().positive().default(5), + hcTimeout: z.number().int().positive().default(1), hcHeaders: z.string().optional().nullable(), hcFollowRedirects: z.boolean().default(true), hcStatus: z.number().int().optional().nullable(), diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index ea7512b9c..e5c1f246e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -31,8 +31,8 @@ const createTargetSchema = z.strictObject({ hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(5).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 55834d926..b5ac7f79f 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -8,12 +8,13 @@ import { } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import logger from "@server/logger"; import { fireHealthCheckHealthyAlert, - fireHealthCheckNotHealthyAlert + fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; +import { fireResourceHealthyAlert, fireResourceUnhealthyAlert } from "@server/private/lib/alerts/events/resourceEvents"; interface TargetHealthStatus { status: string; @@ -96,10 +97,12 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( targetHealthCheckId: targetHealthCheck.targetHealthCheckId, resourceOrgId: resources.orgId, resourceId: resources.resourceId, + resourceName: resources.name, name: targetHealthCheck.name, - hcStatus: targetHealthCheck.hcHealth + hcHealth: targetHealthCheck.hcHealth }) .from(targetHealthCheck) + .innerJoin(sites, eq(targetHealthCheck.siteId, sites.siteId)) .innerJoin( targets, eq(targetHealthCheck.targetId, targets.targetId) @@ -108,7 +111,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( resources, eq(targets.resourceId, resources.resourceId) ) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) .where( and( eq(targetHealthCheck.targetHealthCheckId, targetIdNum), @@ -126,7 +128,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( } // check if the status has changed - if (targetCheck.hcStatus === healthStatus.status) { + if (targetCheck.hcHealth === healthStatus.status) { logger.debug( `Health status for target ${targetId} is already ${healthStatus.status}, skipping update` ); @@ -178,7 +180,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where( and( eq(targets.resourceId, targetCheck.resourceId), - eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated + ne(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated ) ); @@ -200,17 +202,31 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( status: status, timestamp: Math.floor(Date.now() / 1000) }); + + if (status === "unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + targetCheck.resourceId, + targetCheck.resourceName + ); + } else if (status === "healthy") { + await fireResourceHealthyAlert( + orgId, + targetCheck.resourceId, + targetCheck.resourceName + ); + } } // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { - await fireHealthCheckHealthyAlert( + await fireHealthCheckUnhealthyAlert( orgId, targetCheck.targetHealthCheckId, targetCheck.name ); } else if (healthStatus.status === "healthy") { - await fireHealthCheckNotHealthyAlert( + await fireHealthCheckHealthyAlert( orgId, targetCheck.targetHealthCheckId, targetCheck.name diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 52759bfc8..a633deb4d 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -32,8 +32,8 @@ const updateTargetBodySchema = z hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(5).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 524f17482..85dd61c5a 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -12,6 +12,7 @@ import { Card, CardContent } from "@app/components/ui/card"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -205,6 +206,38 @@ export default function AlertRuleGraphEditor({ )} /> + ( + + + {t("alertingRuleCooldown")} + + + + field.onChange( + Number( + e.target + .value + ) + ) + } + /> + + + {t("alertingRuleCooldownDescription")} + + + + )} + /> string) { .string() .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), + cooldownSeconds: z.number().int().nonnegative().default(0), sourceType: z.enum(["site", "health_check", "resource"]), allSites: z.boolean().default(true), siteIds: z.array(z.number()).default([]), @@ -309,6 +312,7 @@ export function defaultFormValues(): AlertRuleFormValues { return { name: "", enabled: true, + cooldownSeconds: 0, sourceType: "site", allSites: true, siteIds: [], @@ -422,6 +426,7 @@ export function apiResponseToFormValues( return { name: rule.name, enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds ?? 0, sourceType, allSites, siteIds: rule.siteIds, @@ -483,6 +488,7 @@ export function formValuesToApiPayload( name: values.name.trim(), eventType, enabled: values.enabled, + cooldownSeconds: values.cooldownSeconds, allSites: values.allSites, siteIds: values.allSites ? [] : values.siteIds, allHealthChecks: values.allHealthChecks, From 45fb24d0c8475ca1b538a1fbaa10950251cab390 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 21:20:31 -0700 Subject: [PATCH 161/176] Remove hardcoding --- server/emails/templates/AlertNotification.tsx | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 5ee7d6375..b540142d2 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -25,17 +25,6 @@ export type AlertEventType = | "resource_unhealthy" | "resource_toggle"; -// --------------------------------------------------------------------------- -// Local preview / layout testing -// --------------------------------------------------------------------------- -// -// Set to `true` while running `npm run email` or otherwise rendering this -// template without real alert context. Uses `alertNotificationFixture` below -// and ignores props passed by callers (including real alert sends). Must be -// `false` before shipping or triggering real alert emails. - -export const USE_FAKE_ALERT_NOTIFICATION_DATA = true; - export type AlertNotificationProps = { eventType: AlertEventType; orgId: string; @@ -43,21 +32,6 @@ export type AlertNotificationProps = { dashboardLink: string; }; -/** Sample props for previews; also used when `USE_FAKE_ALERT_NOTIFICATION_DATA` is true. */ -export const alertNotificationFixture: AlertNotificationProps = { - eventType: "site_online", - orgId: "org_preview_7a3c2f91", - dashboardLink: - "https://app.pangolin.net/org_preview_7a3c2f91/settings/alerting/rules", - data: { - siteId: 42, - healthCheckName: "Edge API – readiness probe", - targetUrl: "https://api.example.com/internal/health", - lastFailureMessage: "Connection timed out after 5000ms", - consecutiveFailures: 3 - } -}; - function getEventMeta(eventType: AlertEventType): { heading: string; previewText: string; @@ -176,8 +150,7 @@ function formatDataItems( } export const AlertNotification = (props: AlertNotificationProps) => { - const { eventType, orgId, data, dashboardLink } = - USE_FAKE_ALERT_NOTIFICATION_DATA ? alertNotificationFixture : props; + const { eventType, orgId, data, dashboardLink } = props; const meta = getEventMeta(eventType); const dataItems = formatDataItems(data); From cc44b46d91b26341db613beca3ce1ce724dbbebd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 21:30:31 -0700 Subject: [PATCH 162/176] fix url parse error --- src/components/BrandingLogo.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/BrandingLogo.tsx b/src/components/BrandingLogo.tsx index f6152f150..aa5112409 100644 --- a/src/components/BrandingLogo.tsx +++ b/src/components/BrandingLogo.tsx @@ -48,6 +48,7 @@ export default function BrandingLogo(props: BrandingLogoProps) { // we use `img` tag here because the `logoPath` could be any URL // and next.js `Image` component only accepts a restricted number of domains const Component = props.logoPath ? "img" : Image; + const isNextImage = Component === Image; return ( path && ( @@ -56,6 +57,11 @@ export default function BrandingLogo(props: BrandingLogoProps) { alt="Logo" width={props.width} height={props.height} + style={ + isNextImage + ? { width: "auto", height: "auto" } + : undefined + } /> ) ); From ebb46304722c23bd7b313a08c4f501d8fb411bbf Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:02 -0700 Subject: [PATCH 163/176] New translations en-us.json (French) --- messages/fr-FR.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 98b769366..8b9cd90b9 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contactez le service commercial pour activer cette fonctionnalité.", + "contactSalesBookDemo": "Réserver une démo", + "contactSalesOr": "ou", + "contactSalesContactUs": "contactez-nous", "setupCreate": "Créer l'organisation, le site et les ressources", "headerAuthCompatibilityInfo": "Activez ceci pour forcer une réponse 401 Unauthorized lorsque le jeton d'authentification est manquant. Cela est nécessaire pour les navigateurs ou les bibliothèques HTTP spécifiques qui n'envoient pas de credentials sans un challenge du serveur.", "headerAuthCompatibility": "Compatibilité étendue", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Veuillez respecter les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "dismiss": "Rejeter", "subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.", + "trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.", + "trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.", + "trialActive": "Essai gratuit actif", + "trialExpired": "Essai expiré", + "trialHasEnded": "Votre essai est terminé.", + "trialDaysRemaining": "{count, plural, one {# jour restant} other {# jours restants}}", + "trialDaysLeftShort": "{days}j restants dans l'essai", + "trialGoToBilling": "Aller à la page de facturation", "subscriptionViolationViewBilling": "Voir la facturation", "componentsLicenseViolation": "Violation de licence : ce serveur utilise {usedSites} nœuds, ce qui dépasse la limite autorisée de {maxSites} nœuds. Respectez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "ID d'organisation manquant", "orgMissingMessage": "Impossible de régénérer l'invitation sans un ID d'organisation.", "accessUsersManage": "Gérer les utilisateurs", + "accessUserManage": "Gérer l'utilisateur", "accessUsersDescription": "Inviter et gérer les utilisateurs ayant accès à cette organisation", "accessUsersSearch": "Chercher des utilisateurs...", + "accessUsersRoleFilterCount": "{count, plural, one {# rôle} other {# rôles}}", + "accessUsersRoleFilterClear": "Effacer les filtres de rôle", "accessUserCreate": "Créer un utilisateur", "accessUserRemove": "Supprimer un utilisateur", "username": "Nom d'utilisateur", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Voir les logs", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", + "search": "Rechercher…", "searchPlaceholder": "Recherche...", "emptySearchOptions": "Aucune option trouvée", "create": "Créer", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Gérer", "sidebarLogAndAnalytics": "Journaux & Analytiques", "sidebarBluePrints": "Configs", + "sidebarAlerting": "Alertes", + "sidebarHealthChecks": "Vérifications de l'état de santé", "sidebarOrganization": "Organisation", "sidebarManagement": "Gestion", "sidebarBillingAndLicenses": "Facturation & Licences", "sidebarLogsAnalytics": "Analyses", + "alertingTitle": "Alertes", + "alertingDescription": "Définissez des sources, des déclencheurs et des actions pour les notifications", + "alertingRules": "Règles d'alerte", + "alertingSearchRules": "Rechercher des règles…", + "alertingAddRule": "Créer une règle", + "alertingColumnSource": "Source", + "alertingColumnTrigger": "Déclencheur", + "alertingColumnActions": "Actions", + "alertingColumnEnabled": "Activé", + "alertingDeleteQuestion": "Veuillez confirmer que vous souhaitez supprimer cette règle d'alerte.", + "alertingDeleteRule": "Supprimer la règle d'alerte", + "alertingRuleDeleted": "Règle d'alerte supprimée", + "alertingRuleSaved": "Règle d'alerte enregistrée", + "alertingRuleSavedCreatedDescription": "Votre nouvelle règle d'alerte a été créée. Vous pouvez continuer à la modifier sur cette page.", + "alertingRuleSavedUpdatedDescription": "Vos modifications apportées à cette règle d'alerte ont été enregistrées.", + "alertingEditRule": "Modifier la règle d'alerte", + "alertingCreateRule": "Créer une règle d'alerte", + "alertingRuleCredenzaDescription": "Choisissez ce qu'il faut surveiller, quand la déclencher et comment notifier", + "alertingRuleNamePlaceholder": "Site de production hors ligne", + "alertingRuleEnabled": "Règle activée", + "alertingSectionSource": "Source", + "alertingSourceType": "Type de source", + "alertingSourceSite": "Nœud", + "alertingSourceHealthCheck": "Vérification de l'état de santé", + "alertingPickSites": "Nœuds", + "alertingPickHealthChecks": "Vérifications de l'état de santé", + "alertingPickResources": "Ressources", + "alertingAllSites": "Tous les nœuds", + "alertingAllSitesDescription": "Les alertes se déclenchent pour n'importe quel nœud", + "alertingSpecificSites": "Nœuds spécifiques", + "alertingSpecificSitesDescription": "Choisissez des nœuds spécifiques à surveiller", + "alertingAllHealthChecks": "Toutes les vérifications de l'état de santé", + "alertingAllHealthChecksDescription": "Les alertes se déclenchent pour n'importe quelle vérification de l'état de santé", + "alertingSpecificHealthChecks": "Vérifications de l'état de santé spécifiques", + "alertingSpecificHealthChecksDescription": "Choisissez des vérifications de l'état de santé spécifiques à surveiller", + "alertingAllResources": "Toutes les ressources", + "alertingAllResourcesDescription": "Les alertes se déclenchent pour n'importe quelle ressource", + "alertingSpecificResources": "Ressources spécifiques", + "alertingSpecificResourcesDescription": "Choisissez des ressources spécifiques à surveiller", + "alertingSelectResources": "Sélectionner des ressources…", + "alertingResourcesSelected": "{count} ressources sélectionnées", + "alertingResourcesEmpty": "Aucune ressource avec des cibles dans les 10 premiers résultats.", + "alertingSectionTrigger": "Déclencheur", + "alertingTrigger": "Quand alerter", + "alertingTriggerSiteOnline": "Site en ligne", + "alertingTriggerSiteOffline": "Site hors ligne", + "alertingTriggerSiteToggle": "Les changements d'état du site", + "alertingTriggerHcHealthy": "Vérification de l'état de santé sain", + "alertingTriggerHcUnhealthy": "Vérification de l'état de santé non sain", + "alertingTriggerHcToggle": "Les changements d'état de la vérification de l'état de santé", + "alertingTriggerResourceHealthy": "Ressource saine", + "alertingTriggerResourceUnhealthy": "Ressource non saine", + "alertingSearchHealthChecks": "Rechercher des vérifications de l'état de santé…", + "alertingHealthChecksEmpty": "Aucune vérification de l'état de santé disponible.", + "alertingTriggerResourceToggle": "Les changements d'état de la ressource", + "alertingSourceResource": "Ressource", + "alertingSectionActions": "Actions", + "alertingAddAction": "Ajouter une action", + "alertingActionNotify": "Adresse mail", + "alertingActionNotifyDescription": "Envoyez des notifications par e-mail aux utilisateurs ou aux rôles", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Envoyez une requête HTTP à un point de terminaison personnalisé", + "alertingExternalIntegration": "Intégration externe", + "alertingExternalPagerDutyDescription": "Envoyer des alertes à PagerDuty pour la gestion des incidents", + "alertingExternalOpsgenieDescription": "Diriger les alertes vers Opsgenie pour la gestion des appels", + "alertingExternalServiceNowDescription": "Créer des incidents ServiceNow à partir des événements d'alerte", + "alertingExternalIncidentIoDescription": "Déclencher des flux de travail Incident.io à partir d'événements d'alerte", + "alertingActionType": "Type d'action", + "alertingNotifyUsers": "Utilisateurs", + "alertingNotifyRoles": "Rôles", + "alertingNotifyEmails": "Adresses e-mail", + "alertingEmailPlaceholder": "Ajoutez un e-mail et appuyez sur Entrée", + "alertingWebhookMethod": "Méthode HTTP", + "alertingWebhookSecret": "Secret de signature (facultatif)", + "alertingWebhookSecretPlaceholder": "Secret HMAC", + "alertingWebhookHeaders": "En-têtes", + "alertingAddHeader": "Ajouter un en-tête", + "alertingSelectSites": "Sélectionner des sites…", + "alertingSitesSelected": "{count} sites sélectionnés", + "alertingSelectHealthChecks": "Sélectionner des vérifications de l'état de santé…", + "alertingHealthChecksSelected": "{count} vérifications de santé sélectionnées", + "alertingNoHealthChecks": "Aucune cible avec des vérifications de l'état de santé activées", + "alertingHealthCheckStub": "La sélection de la source de vérification de l'état de santé n'est pas encore câblée - vous pouvez toujours configurer les déclencheurs et les actions.", + "alertingSelectUsers": "Sélectionner des utilisateurs…", + "alertingUsersSelected": "{count} utilisateurs sélectionnés", + "alertingSelectRoles": "Sélectionner des rôles…", + "alertingRolesSelected": "{count} rôles sélectionnés", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Tous les nœuds", + "alertingSummaryHealthChecks": "Vérifications de l'état de santé ({count})", + "alertingSummaryAllHealthChecks": "Toutes les vérifications de l'état de santé", + "alertingSummaryResources": "Ressources ({count})", + "alertingSummaryAllResources": "Toutes les ressources", + "alertingErrorNameRequired": "Entrer un nom", + "alertingErrorActionsMin": "Ajoutez au moins une action", + "alertingErrorPickSites": "Sélectionnez au moins un site", + "alertingErrorPickHealthChecks": "Sélectionnez au moins une vérification de l'état de santé", + "alertingErrorPickResources": "Sélectionnez au moins une ressource", + "alertingErrorTriggerSite": "Choisissez un déclencheur de site", + "alertingErrorTriggerHealth": "Choisissez un déclencheur de vérification de l'état de santé", + "alertingErrorTriggerResource": "Choisissez un déclencheur de ressource", + "alertingErrorNotifyRecipients": "Choisissez des utilisateurs, des rôles ou au moins un e-mail", + "alertingConfigureSource": "Configurer la source", + "alertingConfigureTrigger": "Configurer le déclencheur", + "alertingConfigureActions": "Configurer les actions", + "alertingBackToRules": "Retour aux règles", + "alertingRuleCooldown": "Temps de repos (secondes)", + "alertingRuleCooldownDescription": "Temps minimum entre les alertes répétées pour la même règle. Réglez sur 0 pour déclencher à chaque fois.", + "alertingDraftBadge": "Brouillon - enregistrez pour stocker cette règle", + "alertingSidebarHint": "Cliquez sur une étape dans la vue d'ensemble pour la modifier ici.", + "alertingGraphCanvasTitle": "Flux de règle", + "alertingGraphCanvasDescription": "Vue d'ensemble visuelle de la source, du déclencheur et des actions. Sélectionnez un nœud pour le modifier dans le panneau.", + "alertingNodeNotConfigured": "Pas encore configuré", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "Source", + "alertingNodeRoleTrigger": "Déclencheur", + "alertingNodeRoleAction": "Action", + "alertingTabRules": "Règles d'alerte", + "alertingTabHealthChecks": "Vérifications de l'état de santé", + "alertingRulesBannerTitle": "Soyez averti", + "alertingRulesBannerDescription": "Chaque règle associe ce qu'il faut surveiller (un site, une vérification de l'état de santé ou une ressource), quand l'exécuter (par exemple, hors ligne ou non saine), et comment notifier votre équipe par e-mail, webhooks ou intégrations. Utilisez cette liste pour créer, activer et gérer ces règles.", + "alertingHealthChecksBannerTitle": "Surveiller la santé et les ressources", + "alertingHealthChecksBannerDescription": "Les vérifications de l'état de santé sont des moniteurs HTTP ou TCP que vous définissez une fois. Vous pouvez ensuite les utiliser comme sources dans les règles d'alerte pour être averti lorsqu'une cible devient saine ou non saine. Les vérifications de l'état de santé sur les ressources apparaissent également ici.", + "standaloneHcTableTitle": "Vérifications de l'état de santé", + "standaloneHcSearchPlaceholder": "Rechercher des vérifications de l'état de santé…", + "standaloneHcAddButton": "Créer une vérification de l'état de santé", + "standaloneHcCreateTitle": "Créer une vérification de l'état de santé", + "standaloneHcEditTitle": "Modifier la vérification de l'état de santé", + "standaloneHcDescription": "Configurez une vérification HTTP ou TCP de l'état de santé pour une utilisation dans les règles d'alerte.", + "standaloneHcNameLabel": "Nom", + "standaloneHcNamePlaceholder": "Mon moniteur HTTP", + "standaloneHcDeleteTitle": "Supprimer la vérification de l'état de santé", + "standaloneHcDeleteQuestion": "Veuillez confirmer que você souhaitez supprimer cette vérification de l'état de santé.", + "standaloneHcDeleted": "Vérification de l'état de santé supprimée", + "standaloneHcSaved": "Vérification de l'état de santé enregistrée", + "standaloneHcColumnHealth": "Santé", + "standaloneHcColumnMode": "Mode", + "standaloneHcColumnTarget": "Cible", + "standaloneHcHealthStateHealthy": "Sain", + "standaloneHcHealthStateUnhealthy": "En mauvaise santé", + "standaloneHcHealthStateUnknown": "Inconnu", + "standaloneHcFilterAnySite": "Tous les nœuds", + "standaloneHcFilterAnyResource": "Toutes les ressources", + "standaloneHcFilterMode": "Mode", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Santé", + "standaloneHcFilterEnabled": "Activé", + "standaloneHcFilterEnabledOn": "Activé", + "standaloneHcFilterEnabledOff": "Désactivé", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Configs", "blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes", "blueprintAdd": "Ajouter une Config", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "L'intervalle de vérification doit être d'au moins 5 secondes", "healthCheckTimeoutMin": "Le délai doit être d'au moins 1 seconde", "healthCheckRetryMin": "Les tentatives de réessai doivent être d'au moins 1", + "healthCheckMode": "Mode de vérification", + "healthCheckStrategy": "Stratégie", + "healthCheckModeDescription": "Le mode TCP vérifie uniquement la connectivité. Le mode HTTP valide la réponse HTTP.", + "healthyThreshold": "Seuil de santé", + "healthyThresholdDescription": "Succès consécutifs requis avant de marquer comme sain.", + "unhealthyThreshold": "Seuil de non-santé", + "unhealthyThresholdDescription": "Echecs consécutifs requis avant de signaler comme non sain.", + "healthCheckHealthyThresholdMin": "Le seuil de santé doit être d'au moins 1", + "healthCheckUnhealthyThresholdMin": "Le seuil de non-santé doit être d'au moins 1", "httpMethod": "Méthode HTTP", "selectHttpMethod": "Sélectionnez la méthode HTTP", "domainPickerSubdomainLabel": "Sous-domaine", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Hôte", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Méthode HTTP", + "editInternalResourceDialogEnableSsl": "Activer SSL", + "editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "editInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Nom", "createInternalResourceDialogSite": "Site", "selectSite": "Sélectionner un site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Hôte", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Méthode HTTP", + "createInternalResourceDialogScheme": "Méthode HTTP", + "createInternalResourceDialogEnableSsl": "Activer SSL", + "createInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", + "internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP", + "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accepter les connexions client", "siteAcceptClientConnectionsDescription": "Autoriser les utilisateurs et les clients à accéder aux ressources de ce site. Cela peut être modifié plus tard.", @@ -2429,6 +2624,7 @@ "validPassword": "Mot de passe valide", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Client connecté", "resourceBlocked": "Ressource bloquée", "droppedByRule": "Abandonné par la règle", "noSessions": "Aucune session", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Indiquez l'adresse de destination pour la ressource interne. Cela peut être un nom d'hôte, une adresse IP ou une plage CIDR selon le mode sélectionné. Définissez éventuellement un alias DNS interne pour une identification plus facile.", "editInternalResourceDialogPortRestrictionsDescription": "Restreindre l'accès à des ports TCP/UDP spécifiques ou autoriser/bloquer tous les ports.", + "createInternalResourceDialogHttpConfiguration": "Configuration HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Choisissez le domaine que les clients utiliseront pour atteindre cette ressource via HTTP ou HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuration HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Choisissez le domaine que les clients utiliseront pour atteindre cette ressource via HTTP ou HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Nous serons bientôt de retour ! Notre site est actuellement en maintenance planifiée.", "maintenancePageMessageDescription": "Message détaillé expliquant la maintenance", "maintenancePageTimeTitle": "Temps d'achèvement estimé (facultatif)", + "privateMaintenanceScreenTitle": "Écran de maintien de service privé", + "privateMaintenanceScreenMessage": "Ce domaine est utilisé sur une ressource privée. Veuillez vous connecter à l'aide du client Pangolin pour accéder à cette ressource.", "maintenanceTime": "par exemple, 2 heures, le 1er nov. à 17:00", "maintenanceEstimatedTimeDescription": "Quand vous attendez que la maintenance soit terminée", "editDomain": "Modifier le domaine", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Ajouter une destination HTTP", "httpDestEditDescription": "Mettre à jour la configuration pour cette destination de streaming d'événements HTTP.", "httpDestAddDescription": "Configurez un nouveau point de terminaison HTTP pour recevoir les événements de votre organisation.", + "S3DestEditTitle": "Modifier la destination", + "S3DestAddTitle": "Ajouter une destination S3", + "S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.", + "S3DestAddDescription": "Configurer un nouveau point de terminaison S3 pour recevoir les événements de votre organisation.", + "datadogDestEditTitle": "Modifier la destination", + "datadogDestAddTitle": "Ajouter une destination Datadog", + "datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.", + "datadogDestAddDescription": "Configurer un nouveau point de terminaison Datadog pour recevoir les événements de votre organisation.", "httpDestTabSettings": "Réglages", "httpDestTabHeaders": "En-têtes", "httpDestTabBody": "Corps", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Destination créée avec succès", "httpDestUpdateFailed": "Impossible de mettre à jour la destination", "httpDestCreateFailed": "Impossible de créer la destination", + "followRedirects": "Suivre les redirections", + "followRedirectsDescription": "Suivre automatiquement les redirections HTTP pour les requêtes.", + "alertingErrorWebhookUrl": "Veuillez entrer une URL valide pour le webhook.", + "healthCheckStrategyHttp": "Valide la connectivité et vérifie le statut de la réponse HTTP.", + "healthCheckStrategyTcp": "Vérifie uniquement la connectivité TCP, sans inspecter la réponse.", + "healthCheckStrategySnmp": "Effectue une requête SNMP pour vérifier la santé des dispositifs et de l'infrastructure réseau.", + "healthCheckStrategyIcmp": "Utilise des requêtes écho ICMP (pings) pour vérifier si une ressource est accessible et réactive.", + "healthCheckTabStrategy": "Stratégie", + "healthCheckTabConnection": "Connexion", + "healthCheckTabAdvanced": "Avancé", + "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", + "uptime30d": "Disponibilité (30j)", "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", "idpAddActionImportFromOrg": "Importer d'une autre organisation", "idpImportDialogTitle": "Importer le fournisseur d'identité", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Cela ne peut pas être annulé pour cette organisation.", "idpUnassociatedDescription": "Fournisseur d'identités dissocié de cette organisation avec succès", "idpUnassociateMenu": "Dissocier", - "idpDeleteAllOrgsMenu": "Supprimer" + "idpDeleteAllOrgsMenu": "Supprimer", + "publicIpEndpoint": "Point de terminaison", + "lastTriggeredAt": "Dernier déclenchement", + "reject": "Rejeter" } From 520cc0d0bf9f524f3e9c7982c3ccafc693fa4aa2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:04 -0700 Subject: [PATCH 164/176] New translations en-us.json (Bulgarian) --- messages/bg-BG.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 2d6fead50..bf953e4d4 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Свържете се с отдел продажби, за да активирате тази функция.", + "contactSalesBookDemo": "Резервирайте демонстрация", + "contactSalesOr": "или", + "contactSalesContactUs": "свържете се с нас", "setupCreate": "Създайте организацията, сайта и ресурсите", "headerAuthCompatibilityInfo": "Активирайте това, за да принудите отговор '401 Неупълномощено', когато липсва токен за автентификация. Това е необходимо за браузъри или специфични HTTP библиотеки, които не изпращат идентификационни данни без сървърно предизвикателство.", "headerAuthCompatibility": "Разширена съвместимост.", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Засечен е невалиден или изтекъл лиценз. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "dismiss": "Отхвърляне", "subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.", + "trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.", + "trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.", + "trialActive": "Активен пробен период", + "trialExpired": "Пробният период е изтекъл", + "trialHasEnded": "Пробният Ви период е приключил.", + "trialDaysRemaining": "{count, plural, one {# ден остава} other {# дни остават}}", + "trialDaysLeftShort": "{days}д остават до края на пробния период", + "trialGoToBilling": "Отидете на страницата за фактуриране", "subscriptionViolationViewBilling": "Преглед на фактурирането", "componentsLicenseViolation": "Нарушение на лиценза: Сървърът използва {usedSites} сайта, което надвишава лицензионния лимит от {maxSites} сайта. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "componentsSupporterMessage": "Благодарим ви, че подкрепяте Pangolin като {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Липсва идентификатор на организация", "orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.", "accessUsersManage": "Управление на потребители", + "accessUserManage": "Управление на потребител", "accessUsersDescription": "Канете и управлявайте потребители с достъп до тази организация", "accessUsersSearch": "Търсене на потребители...", + "accessUsersRoleFilterCount": "{count, plural, one {# роля} other {# роли}}", + "accessUsersRoleFilterClear": "Изчистване на филтрите за роли", "accessUserCreate": "Създайте потребител", "accessUserRemove": "Премахнете потребител", "username": "Потребителско име", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Преглед на дневници", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", + "search": "Търси…", "searchPlaceholder": "Търсене...", "emptySearchOptions": "Няма намерени опции", "create": "Създаване", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Управление.", "sidebarLogAndAnalytics": "Лог & Анализи", "sidebarBluePrints": "Чертежи", + "sidebarAlerting": "Извеждане на предупреждения", + "sidebarHealthChecks": "Проверки на състоянието", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Фактуриране & Лицензи", "sidebarLogsAnalytics": "Анализи", + "alertingTitle": "Извеждане на предупреждения", + "alertingDescription": "Определете източници, тригери и действия за уведомления", + "alertingRules": "Правила за предупреждение", + "alertingSearchRules": "Търсене на правила…", + "alertingAddRule": "Създаване на правило", + "alertingColumnSource": "Източник", + "alertingColumnTrigger": "Тригер", + "alertingColumnActions": "Действия", + "alertingColumnEnabled": "Активирано", + "alertingDeleteQuestion": "Моля, потвърдете, че искате да изтриете това правило за предупреждение.", + "alertingDeleteRule": "Изтриване на правило за предупреждение", + "alertingRuleDeleted": "Правилото за предупреждение е изтрито", + "alertingRuleSaved": "Правилото за предупреждение е запазено", + "alertingRuleSavedCreatedDescription": "Вашето ново правило за предупреждение беше създадено. Все още можете да го редактирате на тази страница.", + "alertingRuleSavedUpdatedDescription": "Промените, направени по това правило за предупреждение, бяха запазени.", + "alertingEditRule": "Редактиране на правило за предупреждение", + "alertingCreateRule": "Създаване на правило за предупреждение", + "alertingRuleCredenzaDescription": "Изберете какво да наблюдавате, кога да се активира и как да уведомите", + "alertingRuleNamePlaceholder": "Сайтът на производство е недостъпен", + "alertingRuleEnabled": "Правилото е активирано", + "alertingSectionSource": "Източник", + "alertingSourceType": "Тип на източника", + "alertingSourceSite": "Сайт", + "alertingSourceHealthCheck": "Проверка на състоянието", + "alertingPickSites": "Сайтове", + "alertingPickHealthChecks": "Проверки на състоянието", + "alertingPickResources": "Ресурси", + "alertingAllSites": "Всички сайтове", + "alertingAllSitesDescription": "Предупреждението се активира за всеки сайт", + "alertingSpecificSites": "Специфични сайтове", + "alertingSpecificSitesDescription": "Изберете специфични сайтове за наблюдение", + "alertingAllHealthChecks": "Всички проверки на състоянието", + "alertingAllHealthChecksDescription": "Предупреждението се активира за всяка проверка на състоянието", + "alertingSpecificHealthChecks": "Специфични проверки на състоянието", + "alertingSpecificHealthChecksDescription": "Изберете специфични проверки на състоянието за наблюдение", + "alertingAllResources": "Всички ресурси", + "alertingAllResourcesDescription": "Предупреждението се активира за всеки ресурс", + "alertingSpecificResources": "Специфични ресурси", + "alertingSpecificResourcesDescription": "Изберете специфични ресурси за наблюдение", + "alertingSelectResources": "Изберете ресурси…", + "alertingResourcesSelected": "Избрани {count} ресурса", + "alertingResourcesEmpty": "Няма ресурси с целите в първите 10 резултата.", + "alertingSectionTrigger": "Тригер", + "alertingTrigger": "Кога да се активира", + "alertingTriggerSiteOnline": "Сайтът е онлайн", + "alertingTriggerSiteOffline": "Сайтът е офлайн", + "alertingTriggerSiteToggle": "Състоянието на сайта се променя", + "alertingTriggerHcHealthy": "Проверка на състоянието е здрава", + "alertingTriggerHcUnhealthy": "Проверка на състоянието не е здрава", + "alertingTriggerHcToggle": "Състоянието на проверката се променя", + "alertingTriggerResourceHealthy": "Ресурсът е здрав", + "alertingTriggerResourceUnhealthy": "Ресурсът не е здрав", + "alertingSearchHealthChecks": "Търсене на проверки на състоянието…", + "alertingHealthChecksEmpty": "Няма налични проверки на състоянието.", + "alertingTriggerResourceToggle": "Състоянието на ресурса се променя", + "alertingSourceResource": "Ресурс", + "alertingSectionActions": "Действия", + "alertingAddAction": "Добавяне на действие", + "alertingActionNotify": "Имейл", + "alertingActionNotifyDescription": "Изпращане на имейл известия на потребители или роли", + "alertingActionWebhook": "Уеб кука", + "alertingActionWebhookDescription": "Изпращане на HTTP заявка към персонализирана крайна точка", + "alertingExternalIntegration": "Външна интеграция", + "alertingExternalPagerDutyDescription": "Изпратете предупреждения към PagerDuty за управление на инциденти", + "alertingExternalOpsgenieDescription": "Пренасочете предупрежденията към Opsgenie за управление на дежурните отчети", + "alertingExternalServiceNowDescription": "Създавайте инциденти в ServiceNow от събития на предупреждения", + "alertingExternalIncidentIoDescription": "Активирайте работни потоци в Incident.io от събития на предупреждения", + "alertingActionType": "Тип на действието", + "alertingNotifyUsers": "Потребители", + "alertingNotifyRoles": "Роли", + "alertingNotifyEmails": "Имейл адреси", + "alertingEmailPlaceholder": "Добавете имейл и натиснете Enter", + "alertingWebhookMethod": "HTTP метод", + "alertingWebhookSecret": "Секрет за подписване (по избор)", + "alertingWebhookSecretPlaceholder": "HMAC секрет", + "alertingWebhookHeaders": "Заглавия", + "alertingAddHeader": "Добавете заглавие", + "alertingSelectSites": "Изберете сайтове…", + "alertingSitesSelected": "Избрани {count} сайта", + "alertingSelectHealthChecks": "Изберете проверки на състоянието…", + "alertingHealthChecksSelected": "Избрани {count} проверки на състоянието", + "alertingNoHealthChecks": "Няма цели с активирани проверки на състоянието", + "alertingHealthCheckStub": "Изборът на източник за проверки на състоянието все още не е свързан - все още можете да конфигурирате тригери и действия.", + "alertingSelectUsers": "Изберете потребители…", + "alertingUsersSelected": "Избрани {count} потребителя", + "alertingSelectRoles": "Изберете роли…", + "alertingRolesSelected": "Избрани {count} роли", + "alertingSummarySites": "Сайтове ({count})", + "alertingSummaryAllSites": "Всички сайтове", + "alertingSummaryHealthChecks": "Проверки на състоянието ({count})", + "alertingSummaryAllHealthChecks": "Всички проверки на състоянието", + "alertingSummaryResources": "Ресурси ({count})", + "alertingSummaryAllResources": "Всички ресурси", + "alertingErrorNameRequired": "Въведете име", + "alertingErrorActionsMin": "Добавете поне едно действие", + "alertingErrorPickSites": "Изберете поне един сайт", + "alertingErrorPickHealthChecks": "Изберете поне една проверка на състоянието", + "alertingErrorPickResources": "Изберете поне един ресурс", + "alertingErrorTriggerSite": "Изберете тригер за сайт", + "alertingErrorTriggerHealth": "Изберете тригер за проверка на състоянието", + "alertingErrorTriggerResource": "Изберете тригер за ресурс", + "alertingErrorNotifyRecipients": "Изберете потребители, роли или поне един имейл", + "alertingConfigureSource": "Конфигуриране на източник", + "alertingConfigureTrigger": "Конфигуриране на тригер", + "alertingConfigureActions": "Конфигуриране на действия", + "alertingBackToRules": "Назад към правилата", + "alertingRuleCooldown": "Време за изчакване (секунди)", + "alertingRuleCooldownDescription": "Минимално време между повторни предупреждения за същото правило. Задайте на 0, за да се задейства всеки път.", + "alertingDraftBadge": "Чернова - запазете, за да съхраните правилото", + "alertingSidebarHint": "Кликнете върху стъпка на платното, за да я редактирате тук.", + "alertingGraphCanvasTitle": "Последователност на правилото", + "alertingGraphCanvasDescription": "Визуален преглед на източник, тригер и действия. Изберете елемент за редакция в панела.", + "alertingNodeNotConfigured": "Още не е конфигурирано", + "alertingNodeActionsCount": "{count, plural, one {# действие} other {# действия}}", + "alertingNodeRoleSource": "Източник", + "alertingNodeRoleTrigger": "Тригер", + "alertingNodeRoleAction": "Действие", + "alertingTabRules": "Правила за предупреждение", + "alertingTabHealthChecks": "Проверки на състоянието", + "alertingRulesBannerTitle": "Получавайте известия", + "alertingRulesBannerDescription": "Всяко правило свързва това, което да се наблюдава (сайт, проверка на състоянието или ресурс), кога да се активира (например офлайн или нездраве) и как да уведомите екипа чрез имейл, уеб куки или интеграции. Използвайте този списък, за да създавате, активирате и управлявате тези правила.", + "alertingHealthChecksBannerTitle": "Наблюдавайте здравето и ресурсите", + "alertingHealthChecksBannerDescription": "Проверките на състоянието са HTTP или TCP монитори, които определяте веднъж. След това можете да ги използвате като източници в правила за предупреждения, така че да бъдете уведомени, когато целта стане здраве или нездраве. Проверките на състоянието на ресурсите също се появяват тук.", + "standaloneHcTableTitle": "Проверки на състоянието", + "standaloneHcSearchPlaceholder": "Търсене на проверки на състоянието…", + "standaloneHcAddButton": "Създаване на проверка на състоянието", + "standaloneHcCreateTitle": "Създаване на проверка на състоянието", + "standaloneHcEditTitle": "Редактиране на проверка на състоянието", + "standaloneHcDescription": "Конфигурирайте HTTP или TCP проверка на състоянието за използване в правилата за предупреждения.", + "standaloneHcNameLabel": "Име", + "standaloneHcNamePlaceholder": "Моят HTTP монитор", + "standaloneHcDeleteTitle": "Изтриване на проверка на състоянието", + "standaloneHcDeleteQuestion": "Моля, потвърдете, че искате да изтриете тази проверка на състоянието.", + "standaloneHcDeleted": "Проверката на състоянието е изтрита", + "standaloneHcSaved": "Проверката на състоянието е запазена", + "standaloneHcColumnHealth": "Здраве", + "standaloneHcColumnMode": "Режим", + "standaloneHcColumnTarget": "Цел", + "standaloneHcHealthStateHealthy": "Здраве", + "standaloneHcHealthStateUnhealthy": "Нездраве", + "standaloneHcHealthStateUnknown": "Неизвестно", + "standaloneHcFilterAnySite": "Всички сайтове", + "standaloneHcFilterAnyResource": "Всички ресурси", + "standaloneHcFilterMode": "Режим", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Пинг", + "standaloneHcFilterHealth": "Здраве", + "standaloneHcFilterEnabled": "Активирано", + "standaloneHcFilterEnabledOn": "Активирано", + "standaloneHcFilterEnabledOff": "Деактивирано", + "standaloneHcFilterSiteIdFallback": "Сайт {id}", + "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения", "blueprintAdd": "Добави Чертеж", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Интервалът за проверка трябва да е поне 5 секунди", "healthCheckTimeoutMin": "Времето за изчакване трябва да е поне 1 секунда", "healthCheckRetryMin": "Опитите за повторение трябва да са поне 1", + "healthCheckMode": "Режим на проверка", + "healthCheckStrategy": "Стратегия", + "healthCheckModeDescription": "Режимът TCP проверява само свързаността. Режимът HTTP валидира HTTP отговора.", + "healthyThreshold": "Праг за здраве", + "healthyThresholdDescription": "Поредица от успехи, необходими за отбелязване като здраве.", + "unhealthyThreshold": "Праг за нездраве", + "unhealthyThresholdDescription": "Поредица от провали, необходими за отбелязване като нездраве.", + "healthCheckHealthyThresholdMin": "Прагът за здраве трябва да бъде поне 1", + "healthCheckUnhealthyThresholdMin": "Прагът за нездраве трябва да бъде поне 1", "httpMethod": "HTTP Метод", "selectHttpMethod": "Изберете HTTP метод", "domainPickerSubdomainLabel": "Поддомен", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Метод", + "editInternalResourceDialogEnableSsl": "Активирайте SSL", + "editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", "editInternalResourceDialogDestination": "Дестинация", "editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "editInternalResourceDialogDestinationIPDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Име", "createInternalResourceDialogSite": "Сайт", "selectSite": "Изберете сайт...", + "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}", "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Метод", + "createInternalResourceDialogScheme": "Метод", + "createInternalResourceDialogEnableSsl": "Активирайте SSL", + "createInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", "createInternalResourceDialogDestination": "Дестинация", "createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", + "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", + "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Приемане на клиентски връзки", "siteAcceptClientConnectionsDescription": "Позволете на потребителските устройства и клиенти да получават достъп до ресурси на този сайт. Това може да бъде променено по-късно.", @@ -2429,6 +2624,7 @@ "validPassword": "Валидна парола", "validEmail": "Валиден имейл", "validSSO": "Валидно SSO", + "connectedClient": "Свързан клиент", "resourceBlocked": "Блокирани ресурси", "droppedByRule": "Прекратено от правило", "noSessions": "Няма сесии", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Дестинация.", "editInternalResourceDialogDestinationDescription": "Посочете адреса дестинация за вътрешния ресурс. Това може да бъде име на хост, IP адрес или CIDR обхват в зависимост от избрания режим. По избор настройте вътрешен DNS алиас за по-лесно идентифициране.", "editInternalResourceDialogPortRestrictionsDescription": "Ограничете достъпа до конкретни TCP/UDP портове или позволете/блокирайте всички портове.", + "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Изберете домейна, който клиентите ще използват, за да достигнат този ресурс чрез HTTP или HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Изберете домейна, който клиентите ще използват, за да достигнат този ресурс чрез HTTP или HTTPS.", "editInternalResourceDialogTcp": "TCP.", "editInternalResourceDialogUdp": "UDP.", "editInternalResourceDialogIcmp": "ICMP.", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Ще се върнем скоро! Нашият сайт понастоящем е в процес на планирана поддръжка.", "maintenancePageMessageDescription": "Подробно съобщение, обясняващо поддръжката.", "maintenancePageTimeTitle": "Очаквано време за завършване (по избор).", + "privateMaintenanceScreenTitle": "Екран за поддръжка", + "privateMaintenanceScreenMessage": "Този домейн се използва при частен ресурс. Моля, свържете се с клиента на Pangolin, за да получите достъп до този ресурс.", "maintenanceTime": "например, 2 часа, 1 ноември в 17:00.", "maintenanceEstimatedTimeDescription": "Кога очаквате поддръжката да бъде завършена?", "editDomain": "Редактиране на домейна.", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Добавяне на HTTP дестинация", "httpDestEditDescription": "Актуализирайте конфигурацията за този HTTP събитий.", "httpDestAddDescription": "Конфигурирайте нов HTTP крайна точка, за да получавате събития на вашата организация.", + "S3DestEditTitle": "Редактиране на дестинацията", + "S3DestAddTitle": "Добавете S3 дестинация", + "S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.", + "S3DestAddDescription": "Конфигурирайте нов крайна точка на S3, за да получавате събития на вашата организация.", + "datadogDestEditTitle": "Редактиране на дестинация", + "datadogDestAddTitle": "Добавяне на Datadog дестинация", + "datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.", + "datadogDestAddDescription": "Конфигурирайте нова крайна точка на Datadog, за да получавате събития на вашата организация.", "httpDestTabSettings": "Настройки", "httpDestTabHeaders": "Заглавки", "httpDestTabBody": "Тяло", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Дестинацията беше създадена успешно", "httpDestUpdateFailed": "Неуспешно актуализиране на дестинацията", "httpDestCreateFailed": "Неуспешно създаване на дестинацията", + "followRedirects": "Следвайте пренасочвания", + "followRedirectsDescription": "Автоматично следвайте HTTP пренасочвания за заявки.", + "alertingErrorWebhookUrl": "Моля, въведете валид URL адрес за уеб куката.", + "healthCheckStrategyHttp": "Проверява свързаността и проверява статуса на HTTP отговора.", + "healthCheckStrategyTcp": "Проверява само TCP свързаност, без да изследва отговора.", + "healthCheckStrategySnmp": "Прави SNMP get заявка, за да провери здравето на мрежовите устройства и инфраструктура.", + "healthCheckStrategyIcmp": "Използва ICMP echo заявки (пинг), за да провери дали ресурсът е достъпен и отговаря.", + "healthCheckTabStrategy": "Стратегия", + "healthCheckTabConnection": "Връзка", + "healthCheckTabAdvanced": "Разширени", + "healthCheckStrategyNotAvailable": "Тази стратегия не е достъпна. Моля, свържете се с отдел продажби, за да активирате тази функция.", + "uptime30d": "Работно време (30д)", "idpAddActionCreateNew": "Създайте нов доставчик на самоличност", "idpAddActionImportFromOrg": "Импортиране от друга организация", "idpImportDialogTitle": "Импортиране на доставчик на самоличност", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Това не може да бъде отменено за тази организация.", "idpUnassociatedDescription": "Доставчика на самоличност е успешно отвързан от тази организация", "idpUnassociateMenu": "Отвързване", - "idpDeleteAllOrgsMenu": "Изтриване" + "idpDeleteAllOrgsMenu": "Изтриване", + "publicIpEndpoint": "Крайна точка", + "lastTriggeredAt": "Последен тригер", + "reject": "Отхвърляне" } From 5e29572f49993d981d938d1cab816fa493ebc42c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:06 -0700 Subject: [PATCH 165/176] New translations en-us.json (Czech) --- messages/cs-CZ.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index e6e952e4b..0e43a4043 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Obraťte se na prodejce, aby tuto funkci povolil.", + "contactSalesBookDemo": "Zarezervovat demo", + "contactSalesOr": "nebo", + "contactSalesContactUs": "kontaktujte nás", "setupCreate": "Vytvořte organizaci, stránku a zdroje", "headerAuthCompatibilityInfo": "Povolte toto, aby vyvolalo odpověď 401 Neoprávněné, když chybí autentizační token. Toto je potřeba pro prohlížeče nebo specifické HTTP knihovny, které neposílají přihlašovací údaje bez výzvy serveru.", "headerAuthCompatibility": "Rozšířená kompatibilita", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Byly nalezeny neplatné nebo propadlé licenční klíče. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "dismiss": "Zavřít", "subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.", + "trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.", + "trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.", + "trialActive": "Zkušební verze je aktivní", + "trialExpired": "Zkušební verze vypršela", + "trialHasEnded": "Vaše zkušební verze skončila.", + "trialDaysRemaining": "{count, plural, one {# den zbývá} few {# dny zbývají} many {# dní zbývá} other {# dny zbývají}}", + "trialDaysLeftShort": "Zbývá {days} d ve zkušební verzi", + "trialGoToBilling": "Přejděte na fakturační stránku", "subscriptionViolationViewBilling": "Zobrazit fakturaci", "componentsLicenseViolation": "Porušení licenčních podmínek: Tento server používá {usedSites} stránek, což překračuje limit {maxSites} licencovaných stránek. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "componentsSupporterMessage": "Děkujeme, že podporujete Pangolin jako {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Chybí ID organizace", "orgMissingMessage": "Nelze obnovit pozvánku bez ID organizace.", "accessUsersManage": "Spravovat uživatele", + "accessUserManage": "Spravovat uživatele", "accessUsersDescription": "Pozvat a spravovat uživatele s přístupem k této organizaci", "accessUsersSearch": "Hledat uživatele...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} few {# role} many {# rolí} other {# roli}}", + "accessUsersRoleFilterClear": "Vymazat filtry rolí", "accessUserCreate": "Vytvořit uživatele", "accessUserRemove": "Odstranit uživatele", "username": "Uživatelské jméno", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Zobrazit logy", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", + "search": "Vyhledávání…", "searchPlaceholder": "Hledat...", "emptySearchOptions": "Nebyly nalezeny žádné možnosti", "create": "Vytvořit", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Spravovat", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Plány", + "sidebarAlerting": "Upozornění", + "sidebarHealthChecks": "Kontroly stavu", "sidebarOrganization": "Organizace", "sidebarManagement": "Správa", "sidebarBillingAndLicenses": "Fakturace a licence", "sidebarLogsAnalytics": "Analytici", + "alertingTitle": "Upozornění", + "alertingDescription": "Definujte zdroje, spouštěče a akce pro oznámení", + "alertingRules": "Pravidla upozornění", + "alertingSearchRules": "Hledat pravidla…", + "alertingAddRule": "Vytvořit pravidlo", + "alertingColumnSource": "Zdroj", + "alertingColumnTrigger": "Spouštěč", + "alertingColumnActions": "Akce", + "alertingColumnEnabled": "Povoleno", + "alertingDeleteQuestion": "Potvrďte, prosím, zda chcete toto pravidlo upozornění smazat.", + "alertingDeleteRule": "Smazat pravidlo upozornění", + "alertingRuleDeleted": "Pravidlo upozornění bylo smazáno", + "alertingRuleSaved": "Pravidlo upozornění bylo uloženo", + "alertingRuleSavedCreatedDescription": "Vaše nové pravidlo upozornění bylo vytvořeno. Můžete ho dál upravovat na této stránce.", + "alertingRuleSavedUpdatedDescription": "Vaše změny pro toto pravidlo upozornění byly uloženy.", + "alertingEditRule": "Upravit pravidlo upozornění", + "alertingCreateRule": "Vytvořit pravidlo upozornění", + "alertingRuleCredenzaDescription": "Vyberte, co sledovat, kdy ho spustit a jak oznamovat", + "alertingRuleNamePlaceholder": "Produkční stránka je dolů", + "alertingRuleEnabled": "Pravidlo povoleno", + "alertingSectionSource": "Zdroj", + "alertingSourceType": "Typ zdroje", + "alertingSourceSite": "Lokalita", + "alertingSourceHealthCheck": "Kontrola stavu", + "alertingPickSites": "Lokality", + "alertingPickHealthChecks": "Kontroly stavu", + "alertingPickResources": "Zdroje", + "alertingAllSites": "Všechny lokality", + "alertingAllSitesDescription": "Upozornění pro jakoukoli lokalitu", + "alertingSpecificSites": "Specifické lokality", + "alertingSpecificSitesDescription": "Vyberte specifické lokality k sledování", + "alertingAllHealthChecks": "Všechny kontroly stavu", + "alertingAllHealthChecksDescription": "Upozornění pro jakoukoli kontrolu stavu", + "alertingSpecificHealthChecks": "Specifické kontroly stavu", + "alertingSpecificHealthChecksDescription": "Vyberte specifické kontroly stavu k sledování", + "alertingAllResources": "Všechny zdroje", + "alertingAllResourcesDescription": "Upozornění pro jakýkoli zdroj", + "alertingSpecificResources": "Specifické zdroje", + "alertingSpecificResourcesDescription": "Vyberte specifické zdroje k sledování", + "alertingSelectResources": "Vyberte zdroje…", + "alertingResourcesSelected": "{count} zdrojů vybráno", + "alertingResourcesEmpty": "Žádné zdroje s cíly v prvních 10 výsledcích.", + "alertingSectionTrigger": "Spouštěč", + "alertingTrigger": "Kdy upozornit", + "alertingTriggerSiteOnline": "Stránky online", + "alertingTriggerSiteOffline": "Stránky offline", + "alertingTriggerSiteToggle": "Změny stavu stránek", + "alertingTriggerHcHealthy": "Kontrola stavu je zdravá", + "alertingTriggerHcUnhealthy": "Kontrola stavu je nezdravá", + "alertingTriggerHcToggle": "Změny stavu kontroly stavu", + "alertingTriggerResourceHealthy": "Zdroj je zdravý", + "alertingTriggerResourceUnhealthy": "Zdroj je nezdravý", + "alertingSearchHealthChecks": "Hledat kontroly stavu…", + "alertingHealthChecksEmpty": "Nejsou dostupné kontroly stavu.", + "alertingTriggerResourceToggle": "Změny stavu zdroje", + "alertingSourceResource": "Zdroj", + "alertingSectionActions": "Akce", + "alertingAddAction": "Přidat akci", + "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Odesílat emailová upozornění uživatelům nebo rolím", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Odeslání HTTP požadavku na vlastní koncový bod", + "alertingExternalIntegration": "Externí integrace", + "alertingExternalPagerDutyDescription": "Odesílat upozornění do PagerDuty pro řízení incidentů", + "alertingExternalOpsgenieDescription": "Směrujte upozornění do Opsgenie pro řízení, když je někdo na telefonu", + "alertingExternalServiceNowDescription": "Vytvářet incidenty ServiceNow z událostí upozornění", + "alertingExternalIncidentIoDescription": "Spouštět Incident.io workflowy z událostí upozornění", + "alertingActionType": "Typ akce", + "alertingNotifyUsers": "Uživatelé", + "alertingNotifyRoles": "Role", + "alertingNotifyEmails": "Emailové adresy", + "alertingEmailPlaceholder": "Přidejte e-mail a stiskněte Enter", + "alertingWebhookMethod": "HTTP metoda", + "alertingWebhookSecret": "Přihlašovací tajemství (volitelné)", + "alertingWebhookSecretPlaceholder": "HMAC tajemství", + "alertingWebhookHeaders": "Hlavičky", + "alertingAddHeader": "Přidat hlavičku", + "alertingSelectSites": "Vybrat lokality…", + "alertingSitesSelected": "{count} lokalit vybráno", + "alertingSelectHealthChecks": "Vybrat kontroly stavu…", + "alertingHealthChecksSelected": "{count} kontrol stavu vybráno", + "alertingNoHealthChecks": "Žádné cíle s povolenými kontrolami stavu", + "alertingHealthCheckStub": "Výběr zdrojů kontrol stavu ještě není propojen – můžete stále konfigurovat spouštěče a akce.", + "alertingSelectUsers": "Vybrat uživatele…", + "alertingUsersSelected": "{count} uživatelů vybráno", + "alertingSelectRoles": "Vybrat role…", + "alertingRolesSelected": "{count} rolí vybráno", + "alertingSummarySites": "Lokality ({count})", + "alertingSummaryAllSites": "Všechny lokality", + "alertingSummaryHealthChecks": "Kontroly stavu ({count})", + "alertingSummaryAllHealthChecks": "Všechny kontroly stavu", + "alertingSummaryResources": "Zdroje ({count})", + "alertingSummaryAllResources": "Všechny zdroje", + "alertingErrorNameRequired": "Zadejte jméno", + "alertingErrorActionsMin": "Přidat alespoň jednu akci", + "alertingErrorPickSites": "Vyberte alespoň jednu lokalitu", + "alertingErrorPickHealthChecks": "Vyberte alespoň jednu kontrolu stavu", + "alertingErrorPickResources": "Vyberte alespoň jeden zdroj", + "alertingErrorTriggerSite": "Vyberte spouštěč lokality", + "alertingErrorTriggerHealth": "Vyberte spouštěč kontroly stavu", + "alertingErrorTriggerResource": "Vyberte spouštěč zdroje", + "alertingErrorNotifyRecipients": "Vyberte uživatele, role nebo alespoň jeden email", + "alertingConfigureSource": "Konfigurace zdroje", + "alertingConfigureTrigger": "Konfigurace spouštěče", + "alertingConfigureActions": "Konfigurace akcí", + "alertingBackToRules": "Zpět na pravidla", + "alertingRuleCooldown": "Odpočinek (sekundy)", + "alertingRuleCooldownDescription": "Minimální doba mezi opakovanými upozorněními pro stejné pravidlo. Nastavte na 0 pro spuštění pokaždé.", + "alertingDraftBadge": "Koncept - uložit pro uložení tohoto pravidla", + "alertingSidebarHint": "Kliknutím na krok na plátno ho zde upravte.", + "alertingGraphCanvasTitle": "Průběh pravidla", + "alertingGraphCanvasDescription": "Vizuální přehled o zdroji, spouštěči a akcích. Vyberte uzel k jeho editaci v panelu.", + "alertingNodeNotConfigured": "Ještě není nakonfigurováno", + "alertingNodeActionsCount": "{count, plural, one {# akce} few {# akce} many {# akcí} other {# akce}}", + "alertingNodeRoleSource": "Zdroj", + "alertingNodeRoleTrigger": "Spouštěč", + "alertingNodeRoleAction": "Akce", + "alertingTabRules": "Pravidla upozornění", + "alertingTabHealthChecks": "Kontroly stavu", + "alertingRulesBannerTitle": "Dostávat upozornění", + "alertingRulesBannerDescription": "Každé pravidlo spojuje, co sledovat (lokalita, kontrola stavu nebo zdroj), kdy ho spustit (například offline nebo nezdravé), a jak informovat váš tým emailem, webhookem nebo integracemi. Použijte tento seznam k vytvoření, povolení a správě těchto pravidel.", + "alertingHealthChecksBannerTitle": "Monitorujte zdraví a zdroje", + "alertingHealthChecksBannerDescription": "Kontroly stavu jsou HTTP nebo TCP monitory, které nastavíte jednou. Poté je můžete použít jako zdroje v pravidlech upozornění, takže budete informováni, když se cíl stane zdravým nebo nezdravým. Kontroly stavu také zde se objeví.", + "standaloneHcTableTitle": "Kontroly stavu", + "standaloneHcSearchPlaceholder": "Hledat kontroly stavu…", + "standaloneHcAddButton": "Vytvořit kontrolu stavu", + "standaloneHcCreateTitle": "Vytvořit kontrolu stavu", + "standaloneHcEditTitle": "Upravit kontrolu stavu", + "standaloneHcDescription": "Nakonfigurujte HTTP nebo TCP kontrolu stavu pro použití v pravidlech upozornění.", + "standaloneHcNameLabel": "Jméno", + "standaloneHcNamePlaceholder": "Můj HTTP Monitor", + "standaloneHcDeleteTitle": "Smazat kontrolu stavu", + "standaloneHcDeleteQuestion": "Potvrďte, prosím, zda chcete tuto kontrolu stavu smazat.", + "standaloneHcDeleted": "Kontrola stavu byla smazána", + "standaloneHcSaved": "Kontrola stavu byla uložena", + "standaloneHcColumnHealth": "Zdraví", + "standaloneHcColumnMode": "Režim", + "standaloneHcColumnTarget": "Cíl", + "standaloneHcHealthStateHealthy": "Zdravé", + "standaloneHcHealthStateUnhealthy": "Nezdravé", + "standaloneHcHealthStateUnknown": "Neznámý", + "standaloneHcFilterAnySite": "Všechny lokality", + "standaloneHcFilterAnyResource": "Všechny zdroje", + "standaloneHcFilterMode": "Režim", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Zdraví", + "standaloneHcFilterEnabled": "Povoleno", + "standaloneHcFilterEnabledOn": "Povoleno", + "standaloneHcFilterEnabledOff": "Zakázáno", + "standaloneHcFilterSiteIdFallback": "Stránka {id}", + "standaloneHcFilterResourceIdFallback": "Zdroj {id}", "blueprints": "Plány", "blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy", "blueprintAdd": "Přidat plán", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Interval kontroly musí být nejméně 5 sekund", "healthCheckTimeoutMin": "Časový limit musí být nejméně 1 sekunda", "healthCheckRetryMin": "Pokusy opakovat musí být alespoň 1", + "healthCheckMode": "Režim kontroly", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "Režim TCP ověřuje pouze připojení. Režim HTTP ověřuje HTTP odezvu.", + "healthyThreshold": "Zdravý práh", + "healthyThresholdDescription": "Počet po sobě jdoucích úspěchů vyžadovaných před označením jako zdravý.", + "unhealthyThreshold": "Nezdravý práh", + "unhealthyThresholdDescription": "Počet po sobě jdoucích selhání vyžadovaných před označením jako nezdravý.", + "healthCheckHealthyThresholdMin": "Zdravý práh musí být alespoň 1", + "healthCheckUnhealthyThresholdMin": "Nezdravý práh musí být alespoň 1", "httpMethod": "HTTP metoda", "selectHttpMethod": "Vyberte HTTP metodu", "domainPickerSubdomainLabel": "Subdoména", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Přístav", "editInternalResourceDialogModeHost": "Hostitel", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schéma", + "editInternalResourceDialogEnableSsl": "Povolit SSL", + "editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", "editInternalResourceDialogDestination": "Místo určení", "editInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "editInternalResourceDialogDestinationIPDescription": "IP nebo název hostitele zdroje v síti webu.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Jméno", "createInternalResourceDialogSite": "Lokalita", "selectSite": "Vybrat lokalitu...", + "multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}", "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Přístav", "createInternalResourceDialogModeHost": "Hostitel", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schéma", + "createInternalResourceDialogScheme": "Schéma", + "createInternalResourceDialogEnableSsl": "Povolit SSL", + "createInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", "createInternalResourceDialogDestination": "Místo určení", "createInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", + "internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje", + "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "siteConfiguration": "Konfigurace", "siteAcceptClientConnections": "Přijmout připojení klienta", "siteAcceptClientConnectionsDescription": "Povolit uživatelským zařízením a klientům přístup ke zdrojům na tomto webu. To lze později změnit.", @@ -2429,6 +2624,7 @@ "validPassword": "Platné heslo", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Připojený klient", "resourceBlocked": "Zablokované zdroje", "droppedByRule": "Zrušeno pravidlem", "noSessions": "Žádné relace", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Cíl", "editInternalResourceDialogDestinationDescription": "Určete cílovou adresu pro interní prostředek. Může se jednat o hostname, IP adresu, nebo rozsah CIDR v závislosti na vybraném režimu. Volitelně nastavte interní DNS alias pro snazší identifikaci.", "editInternalResourceDialogPortRestrictionsDescription": "Omezte přístup na specifické TCP/UDP porty nebo povolte/blokujte všechny porty.", + "createInternalResourceDialogHttpConfiguration": "Konfigurace HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Zvolte doménu, kterou klienti použijí k dosažení tohoto zdroje přes HTTP nebo HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Konfigurace HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Zvolte doménu, kterou klienti použijí k dosažení tohoto zdroje přes HTTP nebo HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Vrátíme se brzy! Naše stránka právě prochází plánovanou údrbou.", "maintenancePageMessageDescription": "Podrobná zpráva vysvětlující údržbu", "maintenancePageTimeTitle": "Odhadovaný čas dokončení (volitelný)", + "privateMaintenanceScreenTitle": "Soukromá obrazovka údržby", + "privateMaintenanceScreenMessage": "Tato doména je používána na soukromém zdroji. Prosím, připojte se přes klienta Pangolin pro přístup k tomuto zdroji.", "maintenanceTime": "např. 2 hodiny, 1. listopadu v 17:00", "maintenanceEstimatedTimeDescription": "Kdy očekáváte, že údržba bude dokončena", "editDomain": "Upravit doménu", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Přidat cíl HTTP", "httpDestEditDescription": "Aktualizovat konfiguraci pro tuto destinaci HTTP události", "httpDestAddDescription": "Konfigurace nového koncového bodu HTTP pro příjem událostí vaší organizace.", + "S3DestEditTitle": "Upravit cíl", + "S3DestAddTitle": "Přidat S3 cíl", + "S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.", + "S3DestAddDescription": "Konfigurujte nový S3 koncový bod pro přijímání událostí vaší organizace.", + "datadogDestEditTitle": "Upravit cíl", + "datadogDestAddTitle": "Přidat Datadog cíl", + "datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.", + "datadogDestAddDescription": "Konfigurujte nový Datadog koncový bod pro přijímání událostí vaší organizace.", "httpDestTabSettings": "Nastavení", "httpDestTabHeaders": "Záhlaví", "httpDestTabBody": "Tělo", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Cíl byl úspěšně vytvořen", "httpDestUpdateFailed": "Nepodařilo se aktualizovat cíl", "httpDestCreateFailed": "Nepodařilo se vytvořit cíl", + "followRedirects": "Následovat přesměrování", + "followRedirectsDescription": "Automaticky sledovat přesměrování HTTP pro požadavky.", + "alertingErrorWebhookUrl": "Zadejte platnou URL pro webhook.", + "healthCheckStrategyHttp": "Ověření připojení a kontrola stavu HTTP odpovědi.", + "healthCheckStrategyTcp": "Ověření TCP připojení, bez inspekce odpovědi.", + "healthCheckStrategySnmp": "Vytváří SNMP požadavek pro kontrolu stavu síťových zařízení a infrastruktury.", + "healthCheckStrategyIcmp": "Používá se ICMP echo požadavky (pingy) ke kontrole, zda je zdroj dosažitelný a reaguje.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Připojení", + "healthCheckTabAdvanced": "Pokročilé", + "healthCheckStrategyNotAvailable": "Tato strategie není dostupná. Kontaktujte prodejce pro povolení této funkce.", + "uptime30d": "Doba provozu (30d)", "idpAddActionCreateNew": "Vytvořit nového poskytovatele identity", "idpAddActionImportFromOrg": "Importovat z jiné organizace", "idpImportDialogTitle": "Importovat poskytovatele identity", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Toto nelze pro tuto organizaci vrátit.", "idpUnassociatedDescription": "Poskytovatel identity byl úspěšně odpojen od této organizace", "idpUnassociateMenu": "Odpojit", - "idpDeleteAllOrgsMenu": "Odstranit" + "idpDeleteAllOrgsMenu": "Odstranit", + "publicIpEndpoint": "Koncový bod", + "lastTriggeredAt": "Poslední spouštěč", + "reject": "Odmítnout" } From 77b38c757a5aa6653cb5d9b014983dd4caeccdc8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:08 -0700 Subject: [PATCH 166/176] New translations en-us.json (German) --- messages/de-DE.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 43e055c3b..07e5d93ac 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Vertrieb kontaktieren, um diese Funktion zu aktivieren.", + "contactSalesBookDemo": "Demo vereinbaren", + "contactSalesOr": "oder", + "contactSalesContactUs": "kontaktieren Sie uns", "setupCreate": "Organisation, Standort und Ressourcen erstellen", "headerAuthCompatibilityInfo": "Aktivieren Sie dies, um eine 401 Nicht autorisierte Antwort zu erzwingen, wenn ein Authentifizierungs-Token fehlt. Dies ist erforderlich für Browser oder bestimmte HTTP-Bibliotheken, die keine Anmeldedaten ohne Server-Challenge senden.", "headerAuthCompatibility": "Erweiterte Kompatibilität", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "dismiss": "Verwerfen", "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", + "trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.", + "trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.", + "trialActive": "Kostenlose Testversion aktiv", + "trialExpired": "Testversion abgelaufen", + "trialHasEnded": "Ihre Testversion ist beendet.", + "trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}", + "trialDaysLeftShort": "Noch {days}d in der Testversion", + "trialGoToBilling": "Zur Rechnungsseite gehen", "subscriptionViolationViewBilling": "Rechnung anzeigen", "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Organisations-ID fehlt", "orgMissingMessage": "Einladung kann ohne Organisations-ID nicht neu generiert werden.", "accessUsersManage": "Benutzer verwalten", + "accessUserManage": "Benutzer verwalten", "accessUsersDescription": "Benutzer mit Zugriff auf diese Organisation einladen und verwalten", "accessUsersSearch": "Benutzer suchen...", + "accessUsersRoleFilterCount": "{count, plural, one {# Rolle} other {# Rollen}}", + "accessUsersRoleFilterClear": "Rollenfilter löschen", "accessUserCreate": "Benutzer erstellen", "accessUserRemove": "Benutzer entfernen", "username": "Benutzername", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Logs anzeigen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", + "search": "Suche…", "searchPlaceholder": "Suche...", "emptySearchOptions": "Keine Optionen gefunden", "create": "Erstellen", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Verwalten", "sidebarLogAndAnalytics": "Log & Analytik", "sidebarBluePrints": "Blaupausen", + "sidebarAlerting": "Benachrichtigung", + "sidebarHealthChecks": "Gesundheits-Checks", "sidebarOrganization": "Organisation", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Abrechnung & Lizenzen", "sidebarLogsAnalytics": "Analytik", + "alertingTitle": "Benachrichtigung", + "alertingDescription": "Quellen, Auslöser und Aktionen für Benachrichtigungen festlegen", + "alertingRules": "Benachrichtigungsregeln", + "alertingSearchRules": "Suchregeln…", + "alertingAddRule": "Regel erstellen", + "alertingColumnSource": "Quelle", + "alertingColumnTrigger": "Auslöser", + "alertingColumnActions": "Aktionen", + "alertingColumnEnabled": "Aktiviert", + "alertingDeleteQuestion": "Bitte bestätigen Sie, dass Sie diese Benachrichtigungsregel löschen möchten.", + "alertingDeleteRule": "Benachrichtigungsregel löschen", + "alertingRuleDeleted": "Benachrichtigungsregel gelöscht", + "alertingRuleSaved": "Benachrichtigungsregel gespeichert", + "alertingRuleSavedCreatedDescription": "Ihre neue Benachrichtigungsregel wurde erstellt. Sie können sie auf dieser Seite weiter bearbeiten.", + "alertingRuleSavedUpdatedDescription": "Ihre Änderungen an dieser Benachrichtigungsregel wurden gespeichert.", + "alertingEditRule": "Benachrichtigungsregel bearbeiten", + "alertingCreateRule": "Benachrichtigungsregel erstellen", + "alertingRuleCredenzaDescription": "Wählen Sie aus, was beobachtet, wann ausgelöst und wie benachrichtigt werden soll", + "alertingRuleNamePlaceholder": "Produktionsseite ausgefallen", + "alertingRuleEnabled": "Regel aktiviert", + "alertingSectionSource": "Quelle", + "alertingSourceType": "Quellentyp", + "alertingSourceSite": "Standort", + "alertingSourceHealthCheck": "Gesundheits-Check", + "alertingPickSites": "Standorte", + "alertingPickHealthChecks": "Gesundheits-Checks", + "alertingPickResources": "Ressourcen", + "alertingAllSites": "Alle Standorte", + "alertingAllSitesDescription": "Benachrichtigung für jeden Standort", + "alertingSpecificSites": "Bestimmte Standorte", + "alertingSpecificSitesDescription": "Wählen Sie spezifische Standorte zur Beobachtung aus", + "alertingAllHealthChecks": "Alle Gesundheits-Checks", + "alertingAllHealthChecksDescription": "Benachrichtigung für jeden Gesundheits-Check", + "alertingSpecificHealthChecks": "Bestimmte Gesundheits-Checks", + "alertingSpecificHealthChecksDescription": "Wählen Sie spezifische Gesundheits-Checks zur Beobachtung aus", + "alertingAllResources": "Alle Ressourcen", + "alertingAllResourcesDescription": "Benachrichtigung für jede Ressource", + "alertingSpecificResources": "Spezifische Ressourcen", + "alertingSpecificResourcesDescription": "Wählen Sie spezifische Ressourcen zur Beobachtung aus", + "alertingSelectResources": "Ressourcen auswählen…", + "alertingResourcesSelected": "{count} Ressourcen ausgewählt", + "alertingResourcesEmpty": "Keine Ressourcen mit Zielen in den ersten 10 Ergebnissen.", + "alertingSectionTrigger": "Auslöser", + "alertingTrigger": "Wann benachrichtigen", + "alertingTriggerSiteOnline": "Seite online", + "alertingTriggerSiteOffline": "Seite offline", + "alertingTriggerSiteToggle": "Seitenstatus ändern", + "alertingTriggerHcHealthy": "Gesundheits-Check gesund", + "alertingTriggerHcUnhealthy": "Gesundheits-Check ungesund", + "alertingTriggerHcToggle": "Gesundheits-Check-Status ändern", + "alertingTriggerResourceHealthy": "Ressource gesund", + "alertingTriggerResourceUnhealthy": "Ressource ungesund", + "alertingSearchHealthChecks": "Gesundheits-Checks suchen…", + "alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.", + "alertingTriggerResourceToggle": "Ressourcenstatus ändern", + "alertingSourceResource": "Ressource", + "alertingSectionActions": "Aktionen", + "alertingAddAction": "Aktion hinzufügen", + "alertingActionNotify": "E-Mail", + "alertingActionNotifyDescription": "Versenden Sie E-Mail-Benachrichtigungen an Benutzer oder Rollen", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Senden Sie eine HTTP-Anfrage an einen benutzerdefinierten Endpunkt", + "alertingExternalIntegration": "Externe Integration", + "alertingExternalPagerDutyDescription": "Senden Sie Benachrichtigungen an PagerDuty für Incident Management", + "alertingExternalOpsgenieDescription": "Leiten Sie Benachrichtigungen an Opsgenie für On-Call Management", + "alertingExternalServiceNowDescription": "Erstellen Sie ServiceNow-Incidents aus Benachrichtigungsereignissen", + "alertingExternalIncidentIoDescription": "Starten Sie Incident.io-Workflows aus Benachrichtigungsereignissen", + "alertingActionType": "Aktionstyp", + "alertingNotifyUsers": "Benutzer", + "alertingNotifyRoles": "Rollen", + "alertingNotifyEmails": "E-Mail-Adressen", + "alertingEmailPlaceholder": "E-Mail hinzufügen und Enter drücken", + "alertingWebhookMethod": "HTTP-Methode", + "alertingWebhookSecret": "Signatur geheim (optional)", + "alertingWebhookSecretPlaceholder": "HMAC-Geheimnis", + "alertingWebhookHeaders": "Header", + "alertingAddHeader": "Header hinzufügen", + "alertingSelectSites": "Standorte auswählen…", + "alertingSitesSelected": "{count} Standorte ausgewählt", + "alertingSelectHealthChecks": "Gesundheits-Checks auswählen…", + "alertingHealthChecksSelected": "{count} Gesundheits-Checks ausgewählt", + "alertingNoHealthChecks": "Keine Ziele mit aktivierten Gesundheits-Checks", + "alertingHealthCheckStub": "Gesundheits-Quellenauswahl ist noch nicht verdrahtet – Sie können trotzdem Auslöser und Aktionen konfigurieren.", + "alertingSelectUsers": "Benutzer auswählen…", + "alertingUsersSelected": "{count} Benutzer ausgewählt", + "alertingSelectRoles": "Rollen auswählen…", + "alertingRolesSelected": "{count} Rollen ausgewählt", + "alertingSummarySites": "Standorte ({count})", + "alertingSummaryAllSites": "Alle Standorte", + "alertingSummaryHealthChecks": "Gesundheits-Checks ({count})", + "alertingSummaryAllHealthChecks": "Alle Gesundheits-Checks", + "alertingSummaryResources": "Ressourcen ({count})", + "alertingSummaryAllResources": "Alle Ressourcen", + "alertingErrorNameRequired": "Einen Namen eingeben", + "alertingErrorActionsMin": "Mindestens eine Aktion hinzufügen", + "alertingErrorPickSites": "Wählen Sie mindestens einen Standort aus", + "alertingErrorPickHealthChecks": "Wählen Sie mindestens einen Gesundheits-Check aus", + "alertingErrorPickResources": "Wählen Sie mindestens eine Ressource aus", + "alertingErrorTriggerSite": "Wählen Sie einen Auslöser für den Standort", + "alertingErrorTriggerHealth": "Wählen Sie einen Auslöser für den Gesundheits-Check", + "alertingErrorTriggerResource": "Wählen Sie einen Auslöser für die Ressource", + "alertingErrorNotifyRecipients": "Wählen Sie Benutzer, Rollen oder mindestens eine E-Mail aus", + "alertingConfigureSource": "Quelle konfigurieren", + "alertingConfigureTrigger": "Auslöser konfigurieren", + "alertingConfigureActions": "Aktionen konfigurieren", + "alertingBackToRules": "Zurück zu den Regeln", + "alertingRuleCooldown": "Cooldown (Sekunden)", + "alertingRuleCooldownDescription": "Mindest-Zeit zwischen wiederholten Benachrichtigungen für dieselbe Regel. Auf 0 setzen, um jedes Mal auszulösen.", + "alertingDraftBadge": "Entwurf - speichern, um diese Regel zu sichern", + "alertingSidebarHint": "Klicken Sie auf einen Schritt auf der Leinwand, um ihn hier zu bearbeiten.", + "alertingGraphCanvasTitle": "Regelfluss", + "alertingGraphCanvasDescription": "Visuelle Übersicht über Quelle, Auslöser und Aktionen. Wählen Sie einen Knoten aus, um ihn im Panel zu bearbeiten.", + "alertingNodeNotConfigured": "Noch nicht konfiguriert", + "alertingNodeActionsCount": "{count, plural, one {# Aktion} other {# Aktionen}}", + "alertingNodeRoleSource": "Quelle", + "alertingNodeRoleTrigger": "Auslöser", + "alertingNodeRoleAction": "Aktion", + "alertingTabRules": "Benachrichtigungsregeln", + "alertingTabHealthChecks": "Gesundheits-Checks", + "alertingRulesBannerTitle": "Benachrichtigt werden", + "alertingRulesBannerDescription": "Jede Regel verknüpft, was beobachtet werden soll (eine Seite, ein Gesundheits-Check oder eine Ressource), wann es ausgelöst werden soll (zum Beispiel offline oder ungesund), und wie Ihr Team benachrichtigt wird, z. B. per E-Mail, Webhooks oder Integrationen. Verwenden Sie diese Liste, um diese Regeln zu erstellen, zu aktivieren und zu verwalten.", + "alertingHealthChecksBannerTitle": "Gesundheit & Ressourcen überwachen", + "alertingHealthChecksBannerDescription": "Gesundheits-Checks sind HTTP- oder TCP-Monitore, die Sie einmal definieren. Sie können sie dann als Quellen in Benachrichtigungsregeln verwenden, so dass Sie benachrichtigt werden, wenn ein Ziel gesund oder ungesund wird. Gesundheits-Checks für Ressourcen erscheinen ebenfalls hier.", + "standaloneHcTableTitle": "Gesundheits-Checks", + "standaloneHcSearchPlaceholder": "Gesundheits-Checks suchen…", + "standaloneHcAddButton": "Gesundheits-Check erstellen", + "standaloneHcCreateTitle": "Gesundheits-Check erstellen", + "standaloneHcEditTitle": "Gesundheits-Check bearbeiten", + "standaloneHcDescription": "Konfigurieren Sie einen HTTP- oder TCP-Gesundheits-Check zur Verwendung in Benachrichtigungsregeln.", + "standaloneHcNameLabel": "Name", + "standaloneHcNamePlaceholder": "Mein HTTP-Monitor", + "standaloneHcDeleteTitle": "Gesundheits-Check löschen", + "standaloneHcDeleteQuestion": "Bitte bestätigen Sie, dass Sie diesen Gesundheits-Check löschen möchten.", + "standaloneHcDeleted": "Gesundheits-Check gelöscht", + "standaloneHcSaved": "Gesundheits-Check gespeichert", + "standaloneHcColumnHealth": "Gesundheit", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Ziel", + "standaloneHcHealthStateHealthy": "Gesund", + "standaloneHcHealthStateUnhealthy": "Ungesund", + "standaloneHcHealthStateUnknown": "Unbekannt", + "standaloneHcFilterAnySite": "Alle Standorte", + "standaloneHcFilterAnyResource": "Alle Ressourcen", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Gesundheit", + "standaloneHcFilterEnabled": "Aktiviert", + "standaloneHcFilterEnabledOn": "Aktiviert", + "standaloneHcFilterEnabledOff": "Deaktiviert", + "standaloneHcFilterSiteIdFallback": "Standort {id}", + "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blaupausen", "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", "blueprintAdd": "Blueprint hinzufügen", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen", "healthCheckTimeoutMin": "Zeitüberschreitung muss mindestens 1 Sekunde betragen", "healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen", + "healthCheckMode": "Überprüfungsmodus", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "TCP-Modus überprüft nur die Konnektivität. HTTP-Modus validiert die HTTP-Antwort.", + "healthyThreshold": "Gesundheitsschwelle", + "healthyThresholdDescription": "Erforderliche aufeinanderfolgende Erfolge, bevor als gesund markiert wird.", + "unhealthyThreshold": "Ungesunde Schwelle", + "unhealthyThresholdDescription": "Erforderliche aufeinanderfolgende Fehlschläge, bevor als ungesund markiert wird.", + "healthCheckHealthyThresholdMin": "Gesundheitsschwelle muss mindestens 1 betragen", + "healthCheckUnhealthyThresholdMin": "Ungesunde Schwelle muss mindestens 1 betragen", "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schema", + "editInternalResourceDialogEnableSsl": "SSL aktivieren", + "editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", "editInternalResourceDialogDestination": "Ziel", "editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "editInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Standort", "selectSite": "Standort auswählen...", + "multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}", "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schema", + "createInternalResourceDialogScheme": "Schema", + "createInternalResourceDialogEnableSsl": "SSL aktivieren", + "createInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", "createInternalResourceDialogDestination": "Ziel", "createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", + "internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich", + "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "siteConfiguration": "Konfiguration", "siteAcceptClientConnections": "Clientverbindungen akzeptieren", "siteAcceptClientConnectionsDescription": "Erlaube Benutzer-Geräten und Clients Zugriff auf Ressourcen auf diesem Standort. Dies kann später geändert werden.", @@ -2429,6 +2624,7 @@ "validPassword": "Gültiges Passwort", "validEmail": "Gültige E-Mail-Adresse", "validSSO": "Gültige SSO-Anmeldung", + "connectedClient": "Verbundenes Gerät", "resourceBlocked": "Ressource blockiert", "droppedByRule": "Abgelegt durch Regel", "noSessions": "Keine Sitzungen", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Ziel", "editInternalResourceDialogDestinationDescription": "Geben Sie die Zieladresse für die interne Ressource an. Dies kann ein Hostname, eine IP-Adresse oder ein CIDR-Bereich sein, abhängig vom gewählten Modus. Legen Sie optional einen internen DNS-Alias für eine vereinfachte Identifizierung fest.", "editInternalResourceDialogPortRestrictionsDescription": "Den Zugriff auf bestimmte TCP/UDP-Ports beschränken oder alle Ports erlauben/blockieren.", + "createInternalResourceDialogHttpConfiguration": "HTTP-Konfiguration", + "createInternalResourceDialogHttpConfigurationDescription": "Wählen Sie die Domain, die Clients verwenden, um über HTTP oder HTTPS auf diese Ressource zuzugreifen.", + "editInternalResourceDialogHttpConfiguration": "HTTP-Konfiguration", + "editInternalResourceDialogHttpConfigurationDescription": "Wählen Sie die Domain, die Clients verwenden, um über HTTP oder HTTPS auf diese Ressource zuzugreifen.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Wir sind bald wieder da! Unsere Seite wird derzeit planmäßig gewartet.", "maintenancePageMessageDescription": "Detaillierte Meldung zur Erklärung der Wartung", "maintenancePageTimeTitle": "Geschätzte Abschlusszeit (Optional)", + "privateMaintenanceScreenTitle": "Privater Platzhalterschirm", + "privateMaintenanceScreenMessage": "Diese Domain wird auf einer privaten Ressource verwendet. Bitte verbinden Sie sich mit dem Pangolin-Client, um auf diese Ressource zuzugreifen.", "maintenanceTime": "z.B.: 2 Stunden, Nov 1 um 17:00 Uhr", "maintenanceEstimatedTimeDescription": "Wann Sie den Abschluss der Wartung erwarten", "editDomain": "Domain bearbeiten", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "HTTP-Ziel hinzufügen", "httpDestEditDescription": "Aktualisiere die Konfiguration für dieses HTTP-Streaming-Ziel.", "httpDestAddDescription": "Konfigurieren Sie einen neuen HTTP-Endpunkt, um die Ereignisse Ihrer Organisation zu empfangen.", + "S3DestEditTitle": "Ziel bearbeiten", + "S3DestAddTitle": "S3-Ziel hinzufügen", + "S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.", + "S3DestAddDescription": "Neuen S3-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.", + "datadogDestEditTitle": "Ziel bearbeiten", + "datadogDestAddTitle": "Datadog-Ziel hinzufügen", + "datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.", + "datadogDestAddDescription": "Neuen Datadog-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.", "httpDestTabSettings": "Einstellungen", "httpDestTabHeaders": "Kopfzeilen", "httpDestTabBody": "Körper", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Ziel erfolgreich erstellt", "httpDestUpdateFailed": "Fehler beim Aktualisieren des Ziels", "httpDestCreateFailed": "Fehler beim Erstellen des Ziels", + "followRedirects": "Weiterleitungen folgen", + "followRedirectsDescription": "HTTP-Weiterleitungen für Anfragen automatisch folgen.", + "alertingErrorWebhookUrl": "Bitte geben Sie eine gültige URL für das Webhook ein.", + "healthCheckStrategyHttp": "Prüft die Konnektivität und den HTTP-Antwort-Status.", + "healthCheckStrategyTcp": "Verifiziert nur die TCP-Konnektivität, ohne die Antwort zu überprüfen.", + "healthCheckStrategySnmp": "Stellt eine SNMP-Get-Anfrage, um die Gesundheit von Netzwerkgeräten und Infrastruktur zu überprüfen.", + "healthCheckStrategyIcmp": "Verwendet ICMP-Echo-Anfragen (Pings), um zu überprüfen, ob eine Ressource erreichbar und reaktionsfähig ist.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Verbindung", + "healthCheckTabAdvanced": "Fortgeschritten", + "healthCheckStrategyNotAvailable": "Diese Strategie ist nicht verfügbar. Bitte kontaktieren Sie den Vertrieb, um diese Funktion zu aktivieren.", + "uptime30d": "Betriebszeit (30 Tage)", "idpAddActionCreateNew": "Neuen Identitätsanbieter erstellen", "idpAddActionImportFromOrg": "Von einer anderen Organisation importieren", "idpImportDialogTitle": "Identitätsanbieter importieren", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Dies kann für diese Organisation nicht rückgängig gemacht werden.", "idpUnassociatedDescription": "Identitätsanbieter erfolgreich von dieser Organisation gelöst", "idpUnassociateMenu": "Verknüpfung aufheben", - "idpDeleteAllOrgsMenu": "Löschen" + "idpDeleteAllOrgsMenu": "Löschen", + "publicIpEndpoint": "Endpunkt", + "lastTriggeredAt": "Letzter Auslöser", + "reject": "Zurückweisen" } From 87e09dd4072180a9060d716f1148852f47e7a7f0 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:10 -0700 Subject: [PATCH 167/176] New translations en-us.json (Italian) --- messages/it-IT.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index babe33b59..e1e3a586e 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contatta le vendite per abilitare questa funzionalità.", + "contactSalesBookDemo": "Prenota una demo", + "contactSalesOr": "o", + "contactSalesContactUs": "contattaci", "setupCreate": "Creare l'organizzazione, il sito e le risorse", "headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", "headerAuthCompatibility": "Compatibilità estesa", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "dismiss": "Ignora", "subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.", + "trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.", + "trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.", + "trialActive": "Prova Gratuita Attiva", + "trialExpired": "Prova scaduta", + "trialHasEnded": "La tua prova è terminata.", + "trialDaysRemaining": "{count, plural, one {# giorno rimanente} other {# giorni rimanenti}}", + "trialDaysLeftShort": "{days}g rimasti nella prova", + "trialGoToBilling": "Vai alla pagina di fatturazione", "subscriptionViolationViewBilling": "Visualizza fatturazione", "componentsLicenseViolation": "Violazione della licenza: Questo server sta usando i siti {usedSites} che superano il suo limite concesso in licenza per i siti {maxSites} . Segui i termini di licenza per continuare a usare tutte le funzionalità.", "componentsSupporterMessage": "Grazie per aver supportato Pangolin come {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "ID Organizzazione Mancante", "orgMissingMessage": "Impossibile rigenerare l'invito senza un ID organizzazione.", "accessUsersManage": "Gestisci Utenti", + "accessUserManage": "Gestisci Utente", "accessUsersDescription": "Invita e gestisci gli utenti con accesso a questa organizzazione", "accessUsersSearch": "Cerca utenti...", + "accessUsersRoleFilterCount": "{count, plural, one {# ruolo} other {# ruoli}}", + "accessUsersRoleFilterClear": "Cancella filtri ruolo", "accessUserCreate": "Crea Utente", "accessUserRemove": "Rimuovi Utente", "username": "Nome utente", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Visualizza Log", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", + "search": "Cerca…", "searchPlaceholder": "Cerca...", "emptySearchOptions": "Nessuna opzione trovata", "create": "Crea", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Gestisci", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Progetti", + "sidebarAlerting": "Allerta", + "sidebarHealthChecks": "Controlli di salute", "sidebarOrganization": "Organizzazione", "sidebarManagement": "Gestione", "sidebarBillingAndLicenses": "Fatturazione E Licenze", "sidebarLogsAnalytics": "Analisi", + "alertingTitle": "Allerta", + "alertingDescription": "Definisci fonti, trigger e azioni per le notifiche", + "alertingRules": "Regole di allerta", + "alertingSearchRules": "Cerca regole…", + "alertingAddRule": "Crea Regola", + "alertingColumnSource": "Fonte", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Azioni", + "alertingColumnEnabled": "Abilitato", + "alertingDeleteQuestion": "Si prega di confermare di voler eliminare questa regola di allerta.", + "alertingDeleteRule": "Elimina regola di allerta", + "alertingRuleDeleted": "Regola di allerta eliminata", + "alertingRuleSaved": "Regola di allerta salvata", + "alertingRuleSavedCreatedDescription": "La tua nuova regola di allerta è stata creata. Puoi continuare a modificarla su questa pagina.", + "alertingRuleSavedUpdatedDescription": "Le modifiche a questa regola di allerta sono state salvate.", + "alertingEditRule": "Modifica Regola di Allerta", + "alertingCreateRule": "Crea Regola di Allerta", + "alertingRuleCredenzaDescription": "Scegli cosa monitorare, quando attivare e come notificare", + "alertingRuleNamePlaceholder": "Sito di produzione giù", + "alertingRuleEnabled": "Regola abilitata", + "alertingSectionSource": "Fonte", + "alertingSourceType": "Tipo Di Fonte", + "alertingSourceSite": "Sito", + "alertingSourceHealthCheck": "Controllo di Salute", + "alertingPickSites": "Siti", + "alertingPickHealthChecks": "Controlli di Salute", + "alertingPickResources": "Risorse", + "alertingAllSites": "Tutti i Siti", + "alertingAllSitesDescription": "L'allerta scatta per qualsiasi sito", + "alertingSpecificSites": "Siti Specifici", + "alertingSpecificSitesDescription": "Scegli siti specifici da monitorare", + "alertingAllHealthChecks": "Tutti i Controlli di Salute", + "alertingAllHealthChecksDescription": "L'allerta scatta per qualsiasi controllo di salute", + "alertingSpecificHealthChecks": "Controlli di Salute Specifici", + "alertingSpecificHealthChecksDescription": "Scegli controlli di salute specifici da monitorare", + "alertingAllResources": "Tutte le Risorse", + "alertingAllResourcesDescription": "L'allerta scatta per qualsiasi risorsa", + "alertingSpecificResources": "Risorse Specifiche", + "alertingSpecificResourcesDescription": "Scegli risorse specifiche da monitorare", + "alertingSelectResources": "Seleziona risorse…", + "alertingResourcesSelected": "{count} risorse selezionate", + "alertingResourcesEmpty": "Nessuna risorsa con target nei primi 10 risultati.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "Quando allertare", + "alertingTriggerSiteOnline": "Sito online", + "alertingTriggerSiteOffline": "Sito offline", + "alertingTriggerSiteToggle": "I cambiamenti di stato del sito", + "alertingTriggerHcHealthy": "Controllo di Salute Sano", + "alertingTriggerHcUnhealthy": "Controllo di Salute Non Sano", + "alertingTriggerHcToggle": "I cambiamenti di stato del controllo di salute", + "alertingTriggerResourceHealthy": "Risorsa in buona salute", + "alertingTriggerResourceUnhealthy": "Risorsa in cattiva salute", + "alertingSearchHealthChecks": "Cerca controlli di salute…", + "alertingHealthChecksEmpty": "Nessun controllo di salute disponibile.", + "alertingTriggerResourceToggle": "Variazioni di stato della risorsa", + "alertingSourceResource": "Fonte", + "alertingSectionActions": "Azioni", + "alertingAddAction": "Aggiungi Azione", + "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Invia notifiche email agli utenti o ai ruoli", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Invia una richiesta HTTP a un endpoint personalizzato", + "alertingExternalIntegration": "Integrazione esterna", + "alertingExternalPagerDutyDescription": "Invia avvisi a PagerDuty per la gestione degli incidenti", + "alertingExternalOpsgenieDescription": "Indirizza avvisi a Opsgenie per la gestione delle chiamate", + "alertingExternalServiceNowDescription": "Crea incidenti ServiceNow dagli eventi di allerta", + "alertingExternalIncidentIoDescription": "Attiva i flussi di lavoro di Incident.io dagli eventi di allerta", + "alertingActionType": "Tipo di azione", + "alertingNotifyUsers": "Utenti", + "alertingNotifyRoles": "Ruoli", + "alertingNotifyEmails": "Indirizzi email", + "alertingEmailPlaceholder": "Aggiungi email e premi Invio", + "alertingWebhookMethod": "Metodo HTTP", + "alertingWebhookSecret": "Segreto di firma (opzionale)", + "alertingWebhookSecretPlaceholder": "Segreto HMAC", + "alertingWebhookHeaders": "Intestazioni", + "alertingAddHeader": "Aggiungi intestazione", + "alertingSelectSites": "Seleziona siti…", + "alertingSitesSelected": "{count} siti selezionati", + "alertingSelectHealthChecks": "Seleziona controlli di salute…", + "alertingHealthChecksSelected": "{count} controlli di salute selezionati", + "alertingNoHealthChecks": "Nessun obiettivo con controlli di salute abilitati", + "alertingHealthCheckStub": "Selezione fonte controllo di salute non ancora collegata - puoi comunque configurare trigger e azioni.", + "alertingSelectUsers": "Seleziona utenti…", + "alertingUsersSelected": "{count} utenti selezionati", + "alertingSelectRoles": "Seleziona ruoli…", + "alertingRolesSelected": "{count} ruoli selezionati", + "alertingSummarySites": "Siti ({count})", + "alertingSummaryAllSites": "Tutti i siti", + "alertingSummaryHealthChecks": "Controlli di Salute ({count})", + "alertingSummaryAllHealthChecks": "Tutti i controlli di salute", + "alertingSummaryResources": "Risorse ({count})", + "alertingSummaryAllResources": "Tutte le risorse", + "alertingErrorNameRequired": "Inserisci un nome", + "alertingErrorActionsMin": "Aggiungi almeno un'azione", + "alertingErrorPickSites": "Seleziona almeno un sito", + "alertingErrorPickHealthChecks": "Seleziona almeno un controllo di salute", + "alertingErrorPickResources": "Seleziona almeno una risorsa", + "alertingErrorTriggerSite": "Scegli un trigger sito", + "alertingErrorTriggerHealth": "Scegli un trigger controllo di salute", + "alertingErrorTriggerResource": "Scegli un trigger risorsa", + "alertingErrorNotifyRecipients": "Seleziona utenti, ruoli o almeno un indirizzo email", + "alertingConfigureSource": "Configura Fonte", + "alertingConfigureTrigger": "Configura Trigger", + "alertingConfigureActions": "Configura Azioni", + "alertingBackToRules": "Torna alle Regole", + "alertingRuleCooldown": "Tempo di riposo (secondi)", + "alertingRuleCooldownDescription": "Tempo minimo tra avvisi ripetuti per la stessa regola. Imposta a 0 per attivare ogni volta.", + "alertingDraftBadge": "Bozza - salva per memorizzare questa regola", + "alertingSidebarHint": "Clicca su un passaggio nella tela per modificarlo qui.", + "alertingGraphCanvasTitle": "Flusso della regola", + "alertingGraphCanvasDescription": "Panoramica visiva di fonte, trigger e azioni. Seleziona un nodo per modificarlo nel pannello.", + "alertingNodeNotConfigured": "Non ancora configurato", + "alertingNodeActionsCount": "{count, plural, one {# azione} other {# azioni}}", + "alertingNodeRoleSource": "Fonte", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Azione", + "alertingTabRules": "Regole di Allerta", + "alertingTabHealthChecks": "Controlli di Salute", + "alertingRulesBannerTitle": "Ricevi Notifiche", + "alertingRulesBannerDescription": "Ogni regola collega ciò che monitorare (un sito, controllo di salute o risorsa), quando attivare (ad esempio offline o non sano) e come notificare il tuo team via email, webhook o integrazioni. Usa questo elenco per creare, abilitare e gestire queste regole.", + "alertingHealthChecksBannerTitle": "Monitora Salute & Risorse", + "alertingHealthChecksBannerDescription": "I controlli di salute sono monitor HTTP o TCP che definisci una volta. Puoi poi usarli come fonti nelle regole di allerta così ricevi avvisi quando un obiettivo diventa sano o non sano. I controlli di salute sulle risorse appaiono anche qui.", + "standaloneHcTableTitle": "Controlli di Salute", + "standaloneHcSearchPlaceholder": "Cerca controlli di salute…", + "standaloneHcAddButton": "Crea Controllo di Salute", + "standaloneHcCreateTitle": "Crea Controllo di Salute", + "standaloneHcEditTitle": "Modifica Controllo di Salute", + "standaloneHcDescription": "Configura un controllo di salute HTTP o TCP da utilizzare nelle regole di allerta.", + "standaloneHcNameLabel": "Nome", + "standaloneHcNamePlaceholder": "Il mio Monitor HTTP", + "standaloneHcDeleteTitle": "Elimina controllo di salute", + "standaloneHcDeleteQuestion": "Si prega di confermare di voler eliminare questo controllo di integrità.", + "standaloneHcDeleted": "Controllo di salute eliminato", + "standaloneHcSaved": "Controllo di salute salvato", + "standaloneHcColumnHealth": "Salute", + "standaloneHcColumnMode": "Modalità", + "standaloneHcColumnTarget": "Target", + "standaloneHcHealthStateHealthy": "Sano", + "standaloneHcHealthStateUnhealthy": "Non Sano", + "standaloneHcHealthStateUnknown": "Sconosciuto", + "standaloneHcFilterAnySite": "Tutti i siti", + "standaloneHcFilterAnyResource": "Tutte le risorse", + "standaloneHcFilterMode": "Modalità", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Salute", + "standaloneHcFilterEnabled": "Abilitato", + "standaloneHcFilterEnabledOn": "Abilitato", + "standaloneHcFilterEnabledOff": "Disabilitato", + "standaloneHcFilterSiteIdFallback": "Sito {id}", + "standaloneHcFilterResourceIdFallback": "Risorsa {id}", "blueprints": "Progetti", "blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti", "blueprintAdd": "Aggiungi Progetto", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "L'intervallo del controllo deve essere almeno di 5 secondi", "healthCheckTimeoutMin": "Il timeout deve essere di almeno 1 secondo", "healthCheckRetryMin": "I tentativi di riprova devono essere almeno 1", + "healthCheckMode": "Verifica Modalità", + "healthCheckStrategy": "Strategia", + "healthCheckModeDescription": "La modalità TCP verifica solo la connettività. La modalità HTTP valida la risposta HTTP.", + "healthyThreshold": "Soglia di salute", + "healthyThresholdDescription": "Successi consecutivi necessari prima di contrassegnare come sano.", + "unhealthyThreshold": "Soglia non sana", + "unhealthyThresholdDescription": "Fallimenti consecutivi richiesti prima di contrassegnare come non sano.", + "healthCheckHealthyThresholdMin": "La soglia di salute deve essere almeno 1", + "healthCheckUnhealthyThresholdMin": "La soglia non sana deve essere almeno 1", "httpMethod": "Metodo HTTP", "selectHttpMethod": "Seleziona metodo HTTP", "domainPickerSubdomainLabel": "Sottodominio", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Metodo HTTP", + "editInternalResourceDialogEnableSsl": "Abilitare SSL", + "editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", "editInternalResourceDialogDestination": "Destinazione", "editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "editInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Sito", "selectSite": "Seleziona sito...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}", "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Metodo HTTP", + "createInternalResourceDialogScheme": "Metodo HTTP", + "createInternalResourceDialogEnableSsl": "Abilitare SSL", + "createInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", "createInternalResourceDialogDestination": "Destinazione", "createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", + "internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP", + "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "siteConfiguration": "Configurazione", "siteAcceptClientConnections": "Accetta Connessioni Client", "siteAcceptClientConnectionsDescription": "Consenti ai dispositivi utente e ai client di accedere alle risorse di questo sito. Questo può essere modificato in seguito.", @@ -2429,6 +2624,7 @@ "validPassword": "Password Valida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente Connesso", "resourceBlocked": "Risorsa Bloccata", "droppedByRule": "Eliminato dalla regola", "noSessions": "Nessuna Sessione", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Destinazione", "editInternalResourceDialogDestinationDescription": "Specifica l'indirizzo di destinazione per la risorsa interna. Può essere un hostname, indirizzo IP o un intervallo CIDR a seconda della modalità selezionata. Opzionalmente imposta un alias DNS interno per una più facile identificazione.", "editInternalResourceDialogPortRestrictionsDescription": "Limita l'accesso a porte TCP/UDP specifiche o consenti/blocca tutte le porte.", + "createInternalResourceDialogHttpConfiguration": "Configurazione HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Scegli il dominio che i clienti utilizzeranno per accedere a questa risorsa tramite HTTP o HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configurazione HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Scegli il dominio che i clienti utilizzeranno per accedere a questa risorsa tramite HTTP o HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Torneremo presto! Il nostro sito è attualmente in manutenzione programmata.", "maintenancePageMessageDescription": "Messaggio dettagliato che spiega la manutenzione", "maintenancePageTimeTitle": "Tempo di Completamento Stimato (Opzionale)", + "privateMaintenanceScreenTitle": "Schermo segnaposto privato", + "privateMaintenanceScreenMessage": "Questo dominio è utilizzato su una risorsa privata. Connettiti usando il client Pangolin per accedere a questa risorsa.", "maintenanceTime": "es. 2 ore, 1 novembre alle 17:00", "maintenanceEstimatedTimeDescription": "Quando prevedi che la manutenzione sarà completata", "editDomain": "Modifica Dominio", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Aggiungi Destinazione HTTP", "httpDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming di eventi HTTP.", "httpDestAddDescription": "Configura un nuovo endpoint HTTP per ricevere gli eventi della tua organizzazione.", + "S3DestEditTitle": "Modifica Destinazione", + "S3DestAddTitle": "Aggiungi Destinazione S3", + "S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.", + "S3DestAddDescription": "Configura un nuovo endpoint S3 per ricevere gli eventi della tua organizzazione.", + "datadogDestEditTitle": "Modifica Destinazione", + "datadogDestAddTitle": "Aggiungi Destinazione Datadog", + "datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.", + "datadogDestAddDescription": "Configura un nuovo endpoint Datadog per ricevere gli eventi della tua organizzazione.", "httpDestTabSettings": "Impostazioni", "httpDestTabHeaders": "Intestazioni", "httpDestTabBody": "Corpo", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Destinazione creata con successo", "httpDestUpdateFailed": "Impossibile aggiornare la destinazione", "httpDestCreateFailed": "Impossibile creare la destinazione", + "followRedirects": "Segui i reindirizzamenti", + "followRedirectsDescription": "Segui automaticamente i reindirizzamenti HTTP per le richieste.", + "alertingErrorWebhookUrl": "Inserisci un URL valido per il webhook.", + "healthCheckStrategyHttp": "Convalida la connettività e controlla lo stato della risposta HTTP.", + "healthCheckStrategyTcp": "Verifica solo la connettività TCP, senza controllare la risposta.", + "healthCheckStrategySnmp": "Effettua una richiesta SNMP per controllare la salute di dispositivi di rete e infrastrutture.", + "healthCheckStrategyIcmp": "Utilizza richieste ICMP echo (ping) per verificare se una risorsa è raggiungibile e reattiva.", + "healthCheckTabStrategy": "Strategia", + "healthCheckTabConnection": "Connessione", + "healthCheckTabAdvanced": "Avanzato", + "healthCheckStrategyNotAvailable": "Questa strategia non è disponibile. Contatta le vendite per abilitare questa funzionalità.", + "uptime30d": "Uptime (30d)", "idpAddActionCreateNew": "Crea nuovo provider di identità", "idpAddActionImportFromOrg": "Importa da un'altra organizzazione", "idpImportDialogTitle": "Importa Provider di Identità", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Questo non può essere annullato per questa organizzazione.", "idpUnassociatedDescription": "Provider di identità disassociato con successo da questa organizzazione", "idpUnassociateMenu": "Disassocia", - "idpDeleteAllOrgsMenu": "Elimina" + "idpDeleteAllOrgsMenu": "Elimina", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Ultimo trigger", + "reject": "Rifiuta" } From 0620fed9c11fe3e6316e4067f0b27f0d99d64eb1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:11 -0700 Subject: [PATCH 168/176] New translations en-us.json (Korean) --- messages/ko-KR.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 9e55b0d32..c3e08dca4 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "이 기능을 활성화하려면 영업팀에 연락하세요.", + "contactSalesBookDemo": "데모 예약하기", + "contactSalesOr": "또는", + "contactSalesContactUs": "문의하기", "setupCreate": "조직, 사이트 및 리소스를 생성합니다.", "headerAuthCompatibilityInfo": "인증 토큰이 없을 때 401 Unauthorized 응답을 강제하도록 설정합니다. 서버 챌린지 없이 자격 증명을 제공하지 않는 브라우저나 특정 HTTP 라이브러리에 필요합니다.", "headerAuthCompatibility": "확장된 호환성", @@ -19,6 +23,14 @@ "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "dismiss": "해제", "subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.", + "trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.", + "trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.", + "trialActive": "무료 체험 활성화됨", + "trialExpired": "체험 만료됨", + "trialHasEnded": "시험 사용 기간이 종료되었습니다.", + "trialDaysRemaining": "{count, plural, other {#일 남음}}", + "trialDaysLeftShort": "시험 사용 기간 종료까지 {days}일 남음", + "trialGoToBilling": "청구 페이지로 이동", "subscriptionViolationViewBilling": "청구 보기", "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", @@ -267,8 +279,11 @@ "orgMissing": "조직 ID가 누락되었습니다", "orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.", "accessUsersManage": "사용자 관리", + "accessUserManage": "사용자 관리", "accessUsersDescription": "이 조직에 액세스할 사용자 초대 및 관리", "accessUsersSearch": "사용자 검색...", + "accessUsersRoleFilterCount": "{count, plural, other {# 역할}}", + "accessUsersRoleFilterClear": "역할 필터 지우기", "accessUserCreate": "사용자 생성", "accessUserRemove": "사용자 제거", "username": "사용자 이름", @@ -1257,6 +1272,7 @@ "actionViewLogs": "로그 보기", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", + "search": "검색…", "searchPlaceholder": "검색...", "emptySearchOptions": "옵션이 없습니다", "create": "생성", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "관리", "sidebarLogAndAnalytics": "로그 & 통계", "sidebarBluePrints": "청사진", + "sidebarAlerting": "알림", + "sidebarHealthChecks": "상태 확인", "sidebarOrganization": "조직", "sidebarManagement": "관리", "sidebarBillingAndLicenses": "결제 및 라이선스", "sidebarLogsAnalytics": "분석", + "alertingTitle": "알림", + "alertingDescription": "알림에 대한 소스, 트리거 및 작업 정의", + "alertingRules": "알림 규칙", + "alertingSearchRules": "규칙 검색…", + "alertingAddRule": "규칙 생성", + "alertingColumnSource": "소스", + "alertingColumnTrigger": "트리거", + "alertingColumnActions": "작업", + "alertingColumnEnabled": "활성화됨", + "alertingDeleteQuestion": "이 알림 규칙을 삭제하겠습니까.", + "alertingDeleteRule": "알림 규칙 삭제", + "alertingRuleDeleted": "알림 규칙 삭제됨", + "alertingRuleSaved": "알림 규칙 저장됨", + "alertingRuleSavedCreatedDescription": "새 알림 규칙이 생성되었습니다. 이 페이지에서 계속 편집할 수 있습니다.", + "alertingRuleSavedUpdatedDescription": "이 알림 규칙에 대한 변경 사항이 저장되었습니다.", + "alertingEditRule": "알림 규칙 편집", + "alertingCreateRule": "알림 규칙 생성", + "alertingRuleCredenzaDescription": "무엇을 감시할지, 언제 알릴지, 어떻게 알릴지를 선택하세요.", + "alertingRuleNamePlaceholder": "프로덕션 사이트 중단", + "alertingRuleEnabled": "규칙 활성화됨", + "alertingSectionSource": "소스", + "alertingSourceType": "소스 유형", + "alertingSourceSite": "사이트", + "alertingSourceHealthCheck": "상태 확인", + "alertingPickSites": "사이트들", + "alertingPickHealthChecks": "상태 확인들", + "alertingPickResources": "리소스들", + "alertingAllSites": "모든 사이트", + "alertingAllSitesDescription": "모든 사이트에서 알림 발동", + "alertingSpecificSites": "특정 사이트", + "alertingSpecificSitesDescription": "감시할 특정 사이트를 선택하세요", + "alertingAllHealthChecks": "모든 상태 확인", + "alertingAllHealthChecksDescription": "모든 상태 확인에 대한 알림 발동", + "alertingSpecificHealthChecks": "특정 상태 확인", + "alertingSpecificHealthChecksDescription": "감시할 특정 상태 확인을 선택하세요", + "alertingAllResources": "모든 리소스", + "alertingAllResourcesDescription": "모든 리소스에 대한 알림 발동", + "alertingSpecificResources": "특정 리소스", + "alertingSpecificResourcesDescription": "감시할 특정 리소스를 선택하세요", + "alertingSelectResources": "리소스 선택…", + "alertingResourcesSelected": "{count}개의 리소스 선택됨", + "alertingResourcesEmpty": "앞 10개의 결과에서 타겟이 있는 리소스 없음.", + "alertingSectionTrigger": "트리거", + "alertingTrigger": "언제 알림을 받을지", + "alertingTriggerSiteOnline": "사이트 온라인", + "alertingTriggerSiteOffline": "사이트 오프라인", + "alertingTriggerSiteToggle": "사이트 상태 변경", + "alertingTriggerHcHealthy": "상태 확인 정상", + "alertingTriggerHcUnhealthy": "상태 확인 비정상", + "alertingTriggerHcToggle": "상태 확인 상태 변경", + "alertingTriggerResourceHealthy": "리소스 정상", + "alertingTriggerResourceUnhealthy": "리소스 비정상", + "alertingSearchHealthChecks": "상태 확인 검색…", + "alertingHealthChecksEmpty": "사용 가능한 상태 확인이 없습니다.", + "alertingTriggerResourceToggle": "리소스 상태 변경", + "alertingSourceResource": "리소스", + "alertingSectionActions": "작업", + "alertingAddAction": "작업 추가", + "alertingActionNotify": "이메일", + "alertingActionNotifyDescription": "사용자 또는 역할에게 이메일 알림 전송", + "alertingActionWebhook": "웹훅", + "alertingActionWebhookDescription": "사용자 정의 엔드포인트로 HTTP 요청 보내기", + "alertingExternalIntegration": "외부 통합", + "alertingExternalPagerDutyDescription": "사고 관리를 위해 PagerDuty에 알림 보내기", + "alertingExternalOpsgenieDescription": "대기 중인 관리자로 Opsgenie에 알림 보내기", + "alertingExternalServiceNowDescription": "알림 이벤트로 ServiceNow 사고 생성", + "alertingExternalIncidentIoDescription": "알림 이벤트로 Incident.io 워크플로우 트리거", + "alertingActionType": "작업 유형", + "alertingNotifyUsers": "사용자들", + "alertingNotifyRoles": "역할들", + "alertingNotifyEmails": "이메일 주소들", + "alertingEmailPlaceholder": "이메일 추가 후 Enter 키를 누르세요", + "alertingWebhookMethod": "HTTP 메소드", + "alertingWebhookSecret": "서명 비밀 (선택 사항)", + "alertingWebhookSecretPlaceholder": "HMAC 비밀", + "alertingWebhookHeaders": "헤더들", + "alertingAddHeader": "헤더 추가", + "alertingSelectSites": "사이트 선택…", + "alertingSitesSelected": "{count}개의 사이트 선택됨", + "alertingSelectHealthChecks": "상태 확인 선택…", + "alertingHealthChecksSelected": "{count}개의 상태 확인 선택됨", + "alertingNoHealthChecks": "활성화된 상태 확인이 있는 타겟 없음", + "alertingHealthCheckStub": "상태 확인 소스 선택은 아직 연결되지 않았습니다 - 트리거 및 작업을 계속 구성할 수 있습니다.", + "alertingSelectUsers": "사용자 선택…", + "alertingUsersSelected": "{count}명의 사용자 선택됨", + "alertingSelectRoles": "역할 선택…", + "alertingRolesSelected": "{count}개의 역할 선택됨", + "alertingSummarySites": "사이트 ({count})", + "alertingSummaryAllSites": "모든 사이트", + "alertingSummaryHealthChecks": "상태 확인 ({count})", + "alertingSummaryAllHealthChecks": "모든 상태 확인", + "alertingSummaryResources": "리소스 ({count})", + "alertingSummaryAllResources": "모든 리소스", + "alertingErrorNameRequired": "이름을 입력하세요", + "alertingErrorActionsMin": "최소한 하나의 작업 추가", + "alertingErrorPickSites": "최소한 하나의 사이트 선택", + "alertingErrorPickHealthChecks": "최소한 하나의 상태 확인 선택", + "alertingErrorPickResources": "최소한 하나의 리소스 선택", + "alertingErrorTriggerSite": "사이트 트리거 선택", + "alertingErrorTriggerHealth": "상태 확인 트리거 선택", + "alertingErrorTriggerResource": "리소스 트리거 선택", + "alertingErrorNotifyRecipients": "사용자, 역할 또는 최소 하나의 이메일 선택", + "alertingConfigureSource": "소스 구성", + "alertingConfigureTrigger": "트리거 구성", + "alertingConfigureActions": "작업 구성", + "alertingBackToRules": "규칙으로 돌아가기", + "alertingRuleCooldown": "냉각 시간 (초)", + "alertingRuleCooldownDescription": "같은 규칙에 대해 반복된 알림 사이의 최소 시간. 매번 발생하려면 0으로 설정하세요.", + "alertingDraftBadge": "초안 - 이 규칙을 저장하려면 저장", + "alertingSidebarHint": "여기에서 편집하려면 캔버스의 단계를 클릭하세요.", + "alertingGraphCanvasTitle": "규칙 흐름", + "alertingGraphCanvasDescription": "소스, 트리거 및 작업의 시각적 개요입니다. 노드를 선택하여 패널에서 수정할 수 있습니다.", + "alertingNodeNotConfigured": "아직 구성되지 않음", + "alertingNodeActionsCount": "{count, plural, other {# 작업}}", + "alertingNodeRoleSource": "소스", + "alertingNodeRoleTrigger": "트리거", + "alertingNodeRoleAction": "작업", + "alertingTabRules": "알림 규칙", + "alertingTabHealthChecks": "상태 확인", + "alertingRulesBannerTitle": "알림 받기", + "alertingRulesBannerDescription": "각 규칙은 무엇을 감시할지(사이트, 상태 확인, 리소스), 언제 발동할지(예: 오프라인 또는 비정상), 이메일, 웹훅 또는 통합을 통해 팀에 어떻게 알릴지를 연결합니다. 이 목록을 사용하여 규칙을 생성, 활성화 및 관리하세요.", + "alertingHealthChecksBannerTitle": "건강 및 리소스 모니터링", + "alertingHealthChecksBannerDescription": "상태 확인은 한 번 정의한 HTTP 또는 TCP 모니터링입니다. 그런 다음 이를 알림 규칙의 소스로 사용하여 타겟이 정상 또는 비정상이 되었을 때 알림을 받을 수 있습니다. 리소스의 상태 확인도 여기에 나타납니다.", + "standaloneHcTableTitle": "상태 확인", + "standaloneHcSearchPlaceholder": "상태 확인 검색…", + "standaloneHcAddButton": "상태 확인 생성", + "standaloneHcCreateTitle": "상태 확인 생성", + "standaloneHcEditTitle": "상태 확인 편집", + "standaloneHcDescription": "알림 규칙에 사용할 HTTP 또는 TCP 상태 확인을 구성하세요.", + "standaloneHcNameLabel": "이름", + "standaloneHcNamePlaceholder": "My HTTP Monitor", + "standaloneHcDeleteTitle": "상태 확인 삭제", + "standaloneHcDeleteQuestion": "이 상태 확인을 삭제하겠습니까.", + "standaloneHcDeleted": "상태 확인 삭제됨", + "standaloneHcSaved": "상태 확인 저장됨", + "standaloneHcColumnHealth": "건강", + "standaloneHcColumnMode": "모드", + "standaloneHcColumnTarget": "타겟", + "standaloneHcHealthStateHealthy": "정상", + "standaloneHcHealthStateUnhealthy": "비정상", + "standaloneHcHealthStateUnknown": "알 수 없음", + "standaloneHcFilterAnySite": "모든 사이트", + "standaloneHcFilterAnyResource": "모든 리소스", + "standaloneHcFilterMode": "모드", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "핑", + "standaloneHcFilterHealth": "건강", + "standaloneHcFilterEnabled": "활성화됨", + "standaloneHcFilterEnabledOn": "활성화됨", + "standaloneHcFilterEnabledOff": "비활성화됨", + "standaloneHcFilterSiteIdFallback": "사이트 {id}", + "standaloneHcFilterResourceIdFallback": "리소스 {id}", "blueprints": "청사진", "blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다", "blueprintAdd": "청사진 추가", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "확인 간격은 최소 5초여야 합니다.", "healthCheckTimeoutMin": "시간 초과는 최소 1초여야 합니다.", "healthCheckRetryMin": "재시도 횟수는 최소 1회여야 합니다.", + "healthCheckMode": "확인 모드", + "healthCheckStrategy": "전략", + "healthCheckModeDescription": "TCP 모드는 연결성만 확인합니다. HTTP 모드는 HTTP 응답을 확인합니다.", + "healthyThreshold": "건강 임계값", + "healthyThresholdDescription": "정상으로 표시되기 전에 연속 성공이 필요합니다.", + "unhealthyThreshold": "비정상 임계값", + "unhealthyThresholdDescription": "비정상으로 표시되기 전에 연속 실패가 필요합니다.", + "healthCheckHealthyThresholdMin": "정상 임계값은 최소 1 이상이어야 합니다", + "healthCheckUnhealthyThresholdMin": "비정상 임계값은 최소 1 이상이어야 합니다", "httpMethod": "HTTP 메소드", "selectHttpMethod": "HTTP 메소드 선택", "domainPickerSubdomainLabel": "서브도메인", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "포트", "editInternalResourceDialogModeHost": "호스트", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "스킴", + "editInternalResourceDialogEnableSsl": "SSL 활성화", + "editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", "editInternalResourceDialogDestination": "대상지", "editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "editInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "이름", "createInternalResourceDialogSite": "사이트", "selectSite": "사이트 선택...", + "multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}", "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "포트", "createInternalResourceDialogModeHost": "호스트", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "스킴", + "createInternalResourceDialogScheme": "스킴", + "createInternalResourceDialogEnableSsl": "SSL 활성화", + "createInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", "createInternalResourceDialogDestination": "대상지", "createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", + "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", + "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "siteConfiguration": "설정", "siteAcceptClientConnections": "클라이언트 연결 허용", "siteAcceptClientConnectionsDescription": "사용자 장치와 클라이언트가 이 사이트의 리소스에 접근할 수 있도록 허용하세요. 나중에 변경할 수 있습니다.", @@ -2429,6 +2624,7 @@ "validPassword": "유효한 비밀번호", "validEmail": "유효한 이메일", "validSSO": "유효한 SSO", + "connectedClient": "연결된 클라이언트", "resourceBlocked": "리소스 차단됨", "droppedByRule": "룰에 의해 드롭됨", "noSessions": "세션 없음", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "대상지", "editInternalResourceDialogDestinationDescription": "내부 리소스의 목적지 주소를 지정하세요. 선택한 모드에 따라 이 주소는 호스트명, IP 주소, 또는 CIDR 범위가 될 수 있습니다. 더욱 쉽게 식별할 수 있도록 내부 DNS 별칭을 설정할 수 있습니다.", "editInternalResourceDialogPortRestrictionsDescription": "특정 TCP/UDP 포트에 대한 접근을 제한하거나 모든 포트를 허용/차단하십시오.", + "createInternalResourceDialogHttpConfiguration": "HTTP 구성", + "createInternalResourceDialogHttpConfigurationDescription": "이 리소스에 HTTP 또는 HTTPS로 도달하기 위한 도메인을 선택하세요.", + "editInternalResourceDialogHttpConfiguration": "HTTP 구성", + "editInternalResourceDialogHttpConfigurationDescription": "이 리소스에 HTTP 또는 HTTPS로 도달하기 위한 도메인을 선택하세요.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "곧 돌아오겠습니다! 사이트는 현재 예정된 유지보수를 진행 중입니다.", "maintenancePageMessageDescription": "유지보수를 설명하는 상세 메시지", "maintenancePageTimeTitle": "예상 완료 시간(선택 사항)", + "privateMaintenanceScreenTitle": "프라이빗 플레이스홀더 화면", + "privateMaintenanceScreenMessage": "이 도메인은 개인 리소스에서 사용 중입니다. Pangolin 클라이언트를 사용하여 이 리소스에 액세스하세요.", "maintenanceTime": "예: 2시간, 11월 1일 오후 5시", "maintenanceEstimatedTimeDescription": "유지보수가 완료될 것으로 예상되는 시간", "editDomain": "도메인 수정", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "HTTP 대상지 추가", "httpDestEditDescription": "이 HTTP 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", "httpDestAddDescription": "조직의 이벤트 수신을 위한 새로운 HTTP 엔드포인트를 구성하세요.", + "S3DestEditTitle": "대상지 수정", + "S3DestAddTitle": "S3 대상지 추가", + "S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", + "S3DestAddDescription": "조직의 이벤트를 받기 위한 새로운 S3 엔드포인트를 구성하세요.", + "datadogDestEditTitle": "대상지 수정", + "datadogDestAddTitle": "Datadog 대상지 추가", + "datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", + "datadogDestAddDescription": "조직의 이벤트를 받기 위한 새로운 Datadog 엔드포인트를 구성하세요.", "httpDestTabSettings": "설정", "httpDestTabHeaders": "헤더", "httpDestTabBody": "본문", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "대상지가 성공적으로 생성되었습니다", "httpDestUpdateFailed": "대상지를 업데이트하는 데 실패했습니다", "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다", + "followRedirects": "리디렉션 따라가기", + "followRedirectsDescription": "요청에 대해 HTTP 리디렉션을 자동으로 따라갑니다.", + "alertingErrorWebhookUrl": "웹훅의 유효한 URL을 입력하세요.", + "healthCheckStrategyHttp": "연결성을 확인하고 HTTP 응답 상태를 확인합니다.", + "healthCheckStrategyTcp": "응답을 검사하지 않고 TCP 연결성만 확인합니다.", + "healthCheckStrategySnmp": "네트워크 장비 및 인프라의 상태를 확인하기 위해 SNMP get 요청을 보냅니다.", + "healthCheckStrategyIcmp": "ICMP 에코 요청(핑)을 사용하여 리소스에 대한 접근 가능성을 확인합니다.", + "healthCheckTabStrategy": "전략", + "healthCheckTabConnection": "연결", + "healthCheckTabAdvanced": "고급", + "healthCheckStrategyNotAvailable": "이 전략은 사용할 수 없습니다. 기능을 활성화하려면 영업팀에 문의하세요.", + "uptime30d": "업타임 (30일)", "idpAddActionCreateNew": "새로운 아이덴티티 공급자 생성", "idpAddActionImportFromOrg": "다른 조직에서 가져오기", "idpImportDialogTitle": "아이덴티티 공급자 가져오기", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "이 조직에서 이것은 되돌릴 수 없습니다.", "idpUnassociatedDescription": "아이덴티티 공급자가 이 조직에서 성공적으로 연관 해제되었습니다", "idpUnassociateMenu": "연관 해제", - "idpDeleteAllOrgsMenu": "삭제" + "idpDeleteAllOrgsMenu": "삭제", + "publicIpEndpoint": "엔드포인트", + "lastTriggeredAt": "마지막 트리거", + "reject": "거부" } From a83126a67e001864ed2352bc9254dbac9a936af2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:13 -0700 Subject: [PATCH 169/176] New translations en-us.json (Dutch) --- messages/nl-NL.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index f3803d445..855ae603d 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Neem contact op met de verkoopafdeling om deze functie in te schakelen.", + "contactSalesBookDemo": "Boek een demo", + "contactSalesOr": "of", + "contactSalesContactUs": "neem contact met ons op", "setupCreate": "Maak de organisatie, site en bronnen aan", "headerAuthCompatibilityInfo": "Schakel dit in om een 401 Niet Geautoriseerd antwoord af te dwingen wanneer een authenticatietoken ontbreekt. Dit is vereist voor browsers of specifieke HTTP-bibliotheken die geen referenties verzenden zonder een serveruitdaging.", "headerAuthCompatibility": "Uitgebreide compatibiliteit", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ongeldige of verlopen licentiesleutels gedetecteerd. Volg de licentievoorwaarden om alle functies te blijven gebruiken.", "dismiss": "Uitschakelen", "subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.", + "trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.", + "trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.", + "trialActive": "Gratis proefversie actief", + "trialExpired": "Proefversie verlopen", + "trialHasEnded": "Uw proefperiode is geëindigd.", + "trialDaysRemaining": "{count, plural, one {# dag resterend} other {# dagen resterend}}", + "trialDaysLeftShort": "{days}d over in proefversie", + "trialGoToBilling": "Ga naar factureringspagina", "subscriptionViolationViewBilling": "Facturering bekijken", "componentsLicenseViolation": "Licentie overtreding: Deze server gebruikt {usedSites} sites die de gelicentieerde limiet van {maxSites} sites overschrijden. Volg de licentievoorwaarden om door te gaan met het gebruik van alle functies.", "componentsSupporterMessage": "Bedankt voor het ondersteunen van Pangolin als {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Organisatie-ID ontbreekt", "orgMissingMessage": "Niet in staat om de uitnodiging te regenereren zonder organisatie-ID.", "accessUsersManage": "Gebruikers beheren", + "accessUserManage": "Beheer gebruiker", "accessUsersDescription": "Nodig uit en beheer gebruikers met toegang tot deze organisatie", "accessUsersSearch": "Gebruikers zoeken...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# rollen}}", + "accessUsersRoleFilterClear": "Rolfilters wissen", "accessUserCreate": "Gebruiker aanmaken", "accessUserRemove": "Gebruiker verwijderen", "username": "Gebruikersnaam", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Logboeken bekijken", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", + "search": "Zoeken…", "searchPlaceholder": "Zoeken...", "emptySearchOptions": "Geen opties gevonden", "create": "Aanmaken", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Beheren", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blauwdrukken", + "sidebarAlerting": "Waarschuwingen", + "sidebarHealthChecks": "Gezondheidscontroles", "sidebarOrganization": "Organisatie", "sidebarManagement": "Beheer", "sidebarBillingAndLicenses": "Facturatie & Licenties", "sidebarLogsAnalytics": "Analyses", + "alertingTitle": "Waarschuwingen", + "alertingDescription": "Definieer bronnen, triggers en acties voor meldingen", + "alertingRules": "Waarschuwingsregels", + "alertingSearchRules": "Zoek regels…", + "alertingAddRule": "Regel aanmaken", + "alertingColumnSource": "Bron", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Acties", + "alertingColumnEnabled": "Ingeschakeld", + "alertingDeleteQuestion": "Bevestig alstublieft dat u deze waarschuwingsregel wilt verwijderen.", + "alertingDeleteRule": "Verwijder waarschuwingsregel", + "alertingRuleDeleted": "Waarschuwingsregel verwijderd", + "alertingRuleSaved": "Waarschuwingsregel opgeslagen", + "alertingRuleSavedCreatedDescription": "Uw nieuwe waarschuwingsregel is aangemaakt. U kunt deze op deze pagina blijven bewerken.", + "alertingRuleSavedUpdatedDescription": "Uw wijzigingen in deze waarschuwingsregel zijn opgeslagen.", + "alertingEditRule": "Bewerk waarschuwingsregel", + "alertingCreateRule": "Waarschuwingsregel aanmaken", + "alertingRuleCredenzaDescription": "Kies wat te bekijken, wanneer het moet gebeuren en hoe te waarschuwen", + "alertingRuleNamePlaceholder": "Productiesite offline", + "alertingRuleEnabled": "Regel ingeschakeld", + "alertingSectionSource": "Bron", + "alertingSourceType": "Brontype", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Gezondheidscontrole", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Gezondheidscontroles", + "alertingPickResources": "Bronnen", + "alertingAllSites": "Alle sites", + "alertingAllSitesDescription": "Waarschuwing voor elke site", + "alertingSpecificSites": "Specifieke sites", + "alertingSpecificSitesDescription": "Kies specifieke sites om in de gaten te houden", + "alertingAllHealthChecks": "Alle Gezondheidscontroles", + "alertingAllHealthChecksDescription": "Waarschuwing voor elke gezondheidscontrole", + "alertingSpecificHealthChecks": "Specifieke Gezondheidscontroles", + "alertingSpecificHealthChecksDescription": "Kies specifieke gezondheidscontroles om in de gaten te houden", + "alertingAllResources": "Alle bronnen", + "alertingAllResourcesDescription": "Waarschuwing voor elke bron", + "alertingSpecificResources": "Specifieke bronnen", + "alertingSpecificResourcesDescription": "Kies specifieke bronnen om in de gaten te houden", + "alertingSelectResources": "Selecteer bronnen…", + "alertingResourcesSelected": "{count} bronnen geselecteerd", + "alertingResourcesEmpty": "Geen bronnen met doelen in de eerste 10 resultaten.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "Wanneer te waarschuwen", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site status wijzigt", + "alertingTriggerHcHealthy": "Gezondheidscontrole gezond", + "alertingTriggerHcUnhealthy": "Gezondheidscontrole ongezond", + "alertingTriggerHcToggle": "Gezondheidscontrole status verandert", + "alertingTriggerResourceHealthy": "Bron gezond", + "alertingTriggerResourceUnhealthy": "Bron ongezond", + "alertingSearchHealthChecks": "Zoek gezondheidscontroles…", + "alertingHealthChecksEmpty": "Geen gezondheidscontroles beschikbaar.", + "alertingTriggerResourceToggle": "Bronstatus wijzigt", + "alertingSourceResource": "Bron", + "alertingSectionActions": "Acties", + "alertingAddAction": "Actie toevoegen", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Stuur e-mailmeldingen naar gebruikers of rollen", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Stuur een HTTP-verzoek naar een aangepast eindpunt", + "alertingExternalIntegration": "Externe integratie", + "alertingExternalPagerDutyDescription": "Stuur waarschuwingen naar PagerDuty voor incidentbeheer", + "alertingExternalOpsgenieDescription": "Routeer waarschuwingen naar Opsgenie voor wachtdienstbeheer", + "alertingExternalServiceNowDescription": "Maak ServiceNow-incidenten aan vanuit waarschuwingsgebeurtenissen", + "alertingExternalIncidentIoDescription": "Trigger Incident.io workflows van waarschuwingsgebeurtenissen", + "alertingActionType": "Actietype", + "alertingNotifyUsers": "Gebruikers", + "alertingNotifyRoles": "Rollen", + "alertingNotifyEmails": "E-mailadressen", + "alertingEmailPlaceholder": "Voeg e-mail toe en druk op Enter", + "alertingWebhookMethod": "HTTP-methode", + "alertingWebhookSecret": "Ondertekengeheim (optioneel)", + "alertingWebhookSecretPlaceholder": "HMAC-geheim", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Header toevoegen", + "alertingSelectSites": "Selecteer sites…", + "alertingSitesSelected": "{count} sites geselecteerd", + "alertingSelectHealthChecks": "Selecteer gezondheidscontroles…", + "alertingHealthChecksSelected": "{count} gezondheidscontroles geselecteerd", + "alertingNoHealthChecks": "Geen doelen met ingeschakelde gezondheidscontroles", + "alertingHealthCheckStub": "Gezondheidscontrole brondeselectie is nog niet gekoppeld - u kunt nog steeds triggers en acties configureren.", + "alertingSelectUsers": "Selecteer gebruikers…", + "alertingUsersSelected": "{count} gebruikers geselecteerd", + "alertingSelectRoles": "Selecteer rollen…", + "alertingRolesSelected": "{count} rollen geselecteerd", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Alle sites", + "alertingSummaryHealthChecks": "Gezondheidscontroles ({count})", + "alertingSummaryAllHealthChecks": "Alle gezondheidscontroles", + "alertingSummaryResources": "Bronnen ({count})", + "alertingSummaryAllResources": "Alle bronnen", + "alertingErrorNameRequired": "Voer een naam in", + "alertingErrorActionsMin": "Voeg minimaal één actie toe", + "alertingErrorPickSites": "Selecteer minimaal één site", + "alertingErrorPickHealthChecks": "Selecteer minimaal één gezondheidscontrole", + "alertingErrorPickResources": "Selecteer minimaal één bron", + "alertingErrorTriggerSite": "Kies een site-trigger", + "alertingErrorTriggerHealth": "Kies een gezondheidscontrole-trigger", + "alertingErrorTriggerResource": "Kies een bron-trigger", + "alertingErrorNotifyRecipients": "Kies gebruikers, rollen of ten minste één e-mail", + "alertingConfigureSource": "Bron configureren", + "alertingConfigureTrigger": "Trigger configureren", + "alertingConfigureActions": "Acties configureren", + "alertingBackToRules": "Terug naar regels", + "alertingRuleCooldown": "Aflkoelperiode (seconden)", + "alertingRuleCooldownDescription": "Minimale tijd tussen herhaalwaarschuwingen voor dezelfde regel. Zet op 0 om elke keer te laten vuren.", + "alertingDraftBadge": "Concept - opslaan om deze regel op te slaan", + "alertingSidebarHint": "Klik op een stap in het canvas om deze hier te bewerken.", + "alertingGraphCanvasTitle": "Regelstroom", + "alertingGraphCanvasDescription": "Visueel overzicht van bron, trigger en acties. Selecteer een node om deze in het paneel te bewerken.", + "alertingNodeNotConfigured": "Nog niet geconfigureerd", + "alertingNodeActionsCount": "{count, plural, one {# actie} other {# acties}}", + "alertingNodeRoleSource": "Bron", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Actie", + "alertingTabRules": "Waarschuwingsregels", + "alertingTabHealthChecks": "Gezondheidscontroles", + "alertingRulesBannerTitle": "Meldingen ontvangen", + "alertingRulesBannerDescription": "Elke regel koppelt wat te bekijken (een site, gezondheidscontrole of bron), wanneer te vuren (bijvoorbeeld offline of ongezond), en hoe uw team te waarschuwen via e-mail, webhooks of integraties. Gebruik deze lijst om die regels te maken, in te schakelen en te beheren.", + "alertingHealthChecksBannerTitle": "Gezondheid & bronnen bewaken", + "alertingHealthChecksBannerDescription": "Gezondheidscontroles zijn HTTP- of TCP-monitoren die u één keer definieert. U kunt ze vervolgens als bronnen in waarschuwingsregels gebruiken, zodat u meldingen krijgt wanneer een doelwit gezond of ongezond wordt. Gezondheidscontroles van bronnen verschijnen ook hier.", + "standaloneHcTableTitle": "Gezondheidscontroles", + "standaloneHcSearchPlaceholder": "Zoek gezondheidscontroles…", + "standaloneHcAddButton": "Gezondheidscontrole aanmaken", + "standaloneHcCreateTitle": "Gezondheidscontrole aanmaken", + "standaloneHcEditTitle": "Gezondheidscontrole bewerken", + "standaloneHcDescription": "Configureer een HTTP- of TCP-gezondheidscontrole voor gebruik in waarschuwingsregels.", + "standaloneHcNameLabel": "Naam", + "standaloneHcNamePlaceholder": "Mijn HTTP-monitor", + "standaloneHcDeleteTitle": "Gezondheidscontrole verwijderen", + "standaloneHcDeleteQuestion": "Bevestig alstublieft dat u deze gezondheidscontrole wilt verwijderen.", + "standaloneHcDeleted": "Gezondheidscontrole verwijderd", + "standaloneHcSaved": "Gezondheidscontrole opgeslagen", + "standaloneHcColumnHealth": "Gezondheid", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Doelwit", + "standaloneHcHealthStateHealthy": "Gezond", + "standaloneHcHealthStateUnhealthy": "Ongezond", + "standaloneHcHealthStateUnknown": "Onbekend", + "standaloneHcFilterAnySite": "Alle sites", + "standaloneHcFilterAnyResource": "Alle bronnen", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Gezondheid", + "standaloneHcFilterEnabled": "Ingeschakeld", + "standaloneHcFilterEnabledOn": "Ingeschakeld", + "standaloneHcFilterEnabledOff": "Uitgeschakeld", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Bron {id}", "blueprints": "Blauwdrukken", "blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.", "blueprintAdd": "Blauwdruk toevoegen", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Controle interval moet minimaal 5 seconden zijn", "healthCheckTimeoutMin": "Timeout moet minimaal 1 seconde zijn", "healthCheckRetryMin": "Herhaal pogingen moet minimaal 1 zijn", + "healthCheckMode": "Controlemodus", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "TCP-modus verifieert alleen connectiviteit. HTTP-modus valideert de HTTP-respons.", + "healthyThreshold": "Gezonde drempel", + "healthyThresholdDescription": "Opeenvolgende successen vereist voordat gemarkeerd wordt als gezond.", + "unhealthyThreshold": "Ongezonde drempel", + "unhealthyThresholdDescription": "Opeenvolgende fouten vereist voordat gemarkeerd wordt als ongezond.", + "healthCheckHealthyThresholdMin": "Gezonde drempel moet minimaal 1 zijn", + "healthCheckUnhealthyThresholdMin": "Ongezonde drempel moet minimaal 1 zijn", "httpMethod": "HTTP-methode", "selectHttpMethod": "Selecteer HTTP-methode", "domainPickerSubdomainLabel": "Subdomein", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Poort", "editInternalResourceDialogModeHost": "Hostnaam", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schema", + "editInternalResourceDialogEnableSsl": "SSL inschakelen", + "editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", "editInternalResourceDialogDestination": "Bestemming", "editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "editInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Naam", "createInternalResourceDialogSite": "Site", "selectSite": "Selecteer site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Poort", "createInternalResourceDialogModeHost": "Hostnaam", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schema", + "createInternalResourceDialogScheme": "Schema", + "createInternalResourceDialogEnableSsl": "SSL inschakelen", + "createInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", "createInternalResourceDialogDestination": "Bestemming", "createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", + "internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen", + "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "siteConfiguration": "Configuratie", "siteAcceptClientConnections": "Accepteer clientverbindingen", "siteAcceptClientConnectionsDescription": "Sta gebruikersapparaten en clients toegang toe tot bronnen op deze site. Dit kan later worden gewijzigd.", @@ -2429,6 +2624,7 @@ "validPassword": "Geldig wachtwoord", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Verbonden Client", "resourceBlocked": "Bron geblokkeerd", "droppedByRule": "Achtergelaten door regel", "noSessions": "Geen sessies", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Bestemming", "editInternalResourceDialogDestinationDescription": "Specificeer het bestemmingsadres voor de interne bron. Dit kan een hostnaam, IP-adres of CIDR-bereik zijn, afhankelijk van de geselecteerde modus. Stel optioneel een interne DNS-alias in voor eenvoudigere identificatie.", "editInternalResourceDialogPortRestrictionsDescription": "Beperk toegang tot specifieke TCP/UDP-poorten of sta alle poorten toe/blokkeer.", + "createInternalResourceDialogHttpConfiguration": "HTTP-configuratie", + "createInternalResourceDialogHttpConfigurationDescription": "Kies het domein dat cliënten zullen gebruiken om deze bron via HTTP of HTTPS te bereiken.", + "editInternalResourceDialogHttpConfiguration": "HTTP-configuratie", + "editInternalResourceDialogHttpConfigurationDescription": "Kies het domein dat cliënten zullen gebruiken om deze bron via HTTP of HTTPS te bereiken.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "We keren snel terug! Onze site ondergaat momenteel gepland onderhoud.", "maintenancePageMessageDescription": "Gedetailleerd bericht dat het onderhoud uitlegt", "maintenancePageTimeTitle": "Geschatte voltooiingstijd (optioneel)", + "privateMaintenanceScreenTitle": "Privéscherm maintenance screen", + "privateMaintenanceScreenMessage": "Dit domein wordt gebruikt op een privébron. Verbind met de Pangolin client om toegang te krijgen tot deze bron.", "maintenanceTime": "bijv. 2 uur, 1 nov om 17:00", "maintenanceEstimatedTimeDescription": "Wanneer u verwacht dat het onderhoud voltooid is", "editDomain": "Domein bewerken", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Voeg HTTP bestemming toe", "httpDestEditDescription": "Werk de configuratie voor deze HTTP-event streaming bestemming bij.", "httpDestAddDescription": "Configureer een nieuw HTTP-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", + "S3DestEditTitle": "Bestemming bewerken", + "S3DestAddTitle": "S3-bestemming toevoegen", + "S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.", + "S3DestAddDescription": "Configureer een nieuw S3-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", + "datadogDestEditTitle": "Bestemming bewerken", + "datadogDestAddTitle": "Datadog-bestemming toevoegen", + "datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.", + "datadogDestAddDescription": "Configureer een nieuw Datadog-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", "httpDestTabSettings": "Instellingen", "httpDestTabHeaders": "Kopteksten", "httpDestTabBody": "Lichaam", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Bestemming succesvol aangemaakt", "httpDestUpdateFailed": "Bijwerken bestemming mislukt", "httpDestCreateFailed": "Aanmaken bestemming mislukt", + "followRedirects": "Volg omleidingen", + "followRedirectsDescription": "Volg automatisch HTTP-omleidingen voor verzoeken.", + "alertingErrorWebhookUrl": "Voer een geldige URL voor de webhook in.", + "healthCheckStrategyHttp": "Valideert connectiviteit en controleert de HTTP-responsstatus.", + "healthCheckStrategyTcp": "Verifieert alleen TCP-connectiviteit zonder de respons te inspecteren.", + "healthCheckStrategySnmp": "Maakt een SNMP-verzoek om de gezondheid van netwerkapparaten en infrastructuur te controleren.", + "healthCheckStrategyIcmp": "Gebruikt ICMP-verzoeken (pings) om te controleren of een bron bereikbaar en responsief is.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Verbinding", + "healthCheckTabAdvanced": "Geavanceerd", + "healthCheckStrategyNotAvailable": "Deze strategie is niet beschikbaar. Neem contact op met sales om deze functie in te schakelen.", + "uptime30d": "Beschikbaarheid (30d)", "idpAddActionCreateNew": "Nieuwe identiteitsprovider aanmaken", "idpAddActionImportFromOrg": "Importeer vanuit een andere organisatie", "idpImportDialogTitle": "Importeer Identiteitsprovider", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Dit kan niet ongedaan worden gemaakt voor deze organisatie.", "idpUnassociatedDescription": "Identiteitsprovider succesvol losgekoppeld van deze organisatie", "idpUnassociateMenu": "Ontkoppelen", - "idpDeleteAllOrgsMenu": "Verwijderen" + "idpDeleteAllOrgsMenu": "Verwijderen", + "publicIpEndpoint": "Eindpunt", + "lastTriggeredAt": "Laatste Trigger", + "reject": "Afwijzen" } From a218f5dc8273523ec16344ba6b86270acb97e4e7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:15 -0700 Subject: [PATCH 170/176] New translations en-us.json (Polish) --- messages/pl-PL.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 2e55ad2a8..1fc66fadb 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Skontaktuj się z działem sprzedaży, aby włączyć tę funkcję.", + "contactSalesBookDemo": "Umów się na demo", + "contactSalesOr": "lub", + "contactSalesContactUs": "skontaktuj się z nami", "setupCreate": "Utwórz organizację, witrynę i zasoby", "headerAuthCompatibilityInfo": "Włącz to, aby wymusić odpowiedź Unauthorized 401, gdy brakuje tokena uwierzytelniania. Jest to wymagane dla przeglądarek lub określonych bibliotek HTTP, które nie wysyłają poświadczeń bez wyzwania serwera.", "headerAuthCompatibility": "Rozszerzona kompatybilność", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Wykryto nieprawidłowe lub wygasłe klucze licencyjne. Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "dismiss": "Odrzuć", "subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.", + "trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.", + "trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.", + "trialActive": "Okres próbny aktywny", + "trialExpired": "Okres próbny wygasł", + "trialHasEnded": "Twój okres próbny dobiegł końca.", + "trialDaysRemaining": "{count, plural, one {# dzień pozostaje} few {# dni pozostają} many {# dni pozostaje} other {# dni pozostają}}", + "trialDaysLeftShort": "Pozostało {days}d próbny", + "trialGoToBilling": "Przejdź do strony rozliczeń", "subscriptionViolationViewBilling": "Zobacz rozliczenie", "componentsLicenseViolation": "Naruszenie licencji: Ten serwer używa stron {usedSites} , które przekraczają limit licencyjny stron {maxSites} . Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "componentsSupporterMessage": "Dziękujemy za wsparcie Pangolina jako {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Brak ID organizacji", "orgMissingMessage": "Nie można ponownie wygenerować zaproszenia bez ID organizacji.", "accessUsersManage": "Zarządzaj użytkownikami", + "accessUserManage": "Zarządzaj użytkownikiem", "accessUsersDescription": "Zaproś użytkowników z dostępem do tej organizacji i zarządzaj nimi", "accessUsersSearch": "Szukaj użytkowników...", + "accessUsersRoleFilterCount": "{count, plural, one {# rola} few {# role} many {# ról} other {# ról}}", + "accessUsersRoleFilterClear": "Wyczyść filtry ról", "accessUserCreate": "Utwórz użytkownika", "accessUserRemove": "Usuń użytkownika", "username": "Nazwa użytkownika", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Zobacz dzienniki", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", + "search": "Szukaj…", "searchPlaceholder": "Szukaj...", "emptySearchOptions": "Nie znaleziono opcji", "create": "Utwórz", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Zarządzaj", "sidebarLogAndAnalytics": "Dziennik & Analityka", "sidebarBluePrints": "Schematy", + "sidebarAlerting": "Alarmowanie", + "sidebarHealthChecks": "Kontrole zdrowia", "sidebarOrganization": "Organizacja", "sidebarManagement": "Zarządzanie", "sidebarBillingAndLicenses": "Płatność i licencje", "sidebarLogsAnalytics": "Analityka", + "alertingTitle": "Alarmowanie", + "alertingDescription": "Zdefiniuj źródła, ustawienia, i działania dla powiadomień", + "alertingRules": "Reguły alarmowe", + "alertingSearchRules": "Szukaj reguł…", + "alertingAddRule": "Utwórz Regułę", + "alertingColumnSource": "Źródło", + "alertingColumnTrigger": "Ustawienie", + "alertingColumnActions": "Akcje", + "alertingColumnEnabled": "Włączone", + "alertingDeleteQuestion": "Potwierdź, że chcesz usunąć tę regułę alarmową.", + "alertingDeleteRule": "Usuń regułę alarmową", + "alertingRuleDeleted": "Reguła alarmowa usunięta", + "alertingRuleSaved": "Reguła alarmowa zapisana", + "alertingRuleSavedCreatedDescription": "Nowa reguła alarmowa została utworzona. Możesz ją kontynuować edytować na tej stronie.", + "alertingRuleSavedUpdatedDescription": "Twoje zmiany w tej regule alarmowej zostały zapisane.", + "alertingEditRule": "Edytuj regułę alarmową", + "alertingCreateRule": "Utwórz regułę alarmową", + "alertingRuleCredenzaDescription": "Wybierz, co obserwować, kiedy uruchamiać i jak powiadamiać.", + "alertingRuleNamePlaceholder": "Strona produkcyjna w dół", + "alertingRuleEnabled": "Reguła włączona", + "alertingSectionSource": "Źródło", + "alertingSourceType": "Typ źródła", + "alertingSourceSite": "Witryna", + "alertingSourceHealthCheck": "Kontrola zdrowia", + "alertingPickSites": "Witryny", + "alertingPickHealthChecks": "Kontrole zdrowia", + "alertingPickResources": "Zasoby", + "alertingAllSites": "Wszystkie witryny", + "alertingAllSitesDescription": "Alarm uruchomiony dla dowolnej witryny", + "alertingSpecificSites": "Określone witryny", + "alertingSpecificSitesDescription": "Wybierz określone witryny do obserwacji", + "alertingAllHealthChecks": "Wszystkie Kontrole Zdrowia", + "alertingAllHealthChecksDescription": "Alarm uruchomiony dla dowolnej kontroli zdrowia", + "alertingSpecificHealthChecks": "Określone Kontrole Zdrowia", + "alertingSpecificHealthChecksDescription": "Wybierz określone kontrole zdrowia do obserwacji", + "alertingAllResources": "Wszystkie zasoby", + "alertingAllResourcesDescription": "Alarm uruchomiony dla dowolnego zasobu", + "alertingSpecificResources": "Określone Zasoby", + "alertingSpecificResourcesDescription": "Wybierz określone zasoby do obserwacji", + "alertingSelectResources": "Wybierz zasoby…", + "alertingResourcesSelected": "{count} zasobów wybrano", + "alertingResourcesEmpty": "Brak zasobów z celami w pierwszych 10 wynikach.", + "alertingSectionTrigger": "Ustawienie", + "alertingTrigger": "Kiedy alarmować", + "alertingTriggerSiteOnline": "Strona online", + "alertingTriggerSiteOffline": "Strona offline", + "alertingTriggerSiteToggle": "Status strony zmienia się", + "alertingTriggerHcHealthy": "Kontrola zdrowia zdrowa", + "alertingTriggerHcUnhealthy": "Kontrola zdrowia niezdrowa", + "alertingTriggerHcToggle": "Status kontroli zdrowia zmienia się", + "alertingTriggerResourceHealthy": "Zasób zdrowy", + "alertingTriggerResourceUnhealthy": "Zasób niezdrowy", + "alertingSearchHealthChecks": "Szukaj kontroli zdrowia…", + "alertingHealthChecksEmpty": "Brak dostępnych kontroli zdrowia.", + "alertingTriggerResourceToggle": "Zmiany statusu zasobu", + "alertingSourceResource": "Zasób", + "alertingSectionActions": "Akcje", + "alertingAddAction": "Dodaj Akcję", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Wyślij powiadomienia e-mail do użytkowników lub ról", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Wyślij żądanie HTTP do niestandardowego punktu końcowego", + "alertingExternalIntegration": "Integracja Zewnętrzna", + "alertingExternalPagerDutyDescription": "Przesyłaj alerty do PagerDuty do zarządzania incydentami", + "alertingExternalOpsgenieDescription": "Kieruj alerty do Opsgenie dla zarządzania dyżurem", + "alertingExternalServiceNowDescription": "Twórz incydenty ServiceNow z alertów", + "alertingExternalIncidentIoDescription": "Wyzwalaj przepływy Incident.io z alertów", + "alertingActionType": "Typ akcji", + "alertingNotifyUsers": "Użytkownicy", + "alertingNotifyRoles": "Role", + "alertingNotifyEmails": "Adres e-mail", + "alertingEmailPlaceholder": "Dodaj e-mail i naciśnij Enter", + "alertingWebhookMethod": "Metoda HTTP", + "alertingWebhookSecret": "Sekret podpisu (opcjonalny)", + "alertingWebhookSecretPlaceholder": "Sekret HMAC", + "alertingWebhookHeaders": "Nagłówki", + "alertingAddHeader": "Dodaj nagłówek", + "alertingSelectSites": "Wybierz witryny…", + "alertingSitesSelected": "{count} witryny wybrano", + "alertingSelectHealthChecks": "Wybierz wyniki zdrowia…", + "alertingHealthChecksSelected": "{count} wyniki zdrowia wybrane", + "alertingNoHealthChecks": "Brak celów z aktywowanymi kontrolami zdrowia", + "alertingHealthCheckStub": "Wybór źródła kontroli zdrowia jeszcze nie skonfigurowany - możesz nadal skonfigurować wyzwalacze i akcje.", + "alertingSelectUsers": "Wybierz użytkowników…", + "alertingUsersSelected": "{count} użytkowników wybrano", + "alertingSelectRoles": "Wybierz role…", + "alertingRolesSelected": "{count} ról wybrano", + "alertingSummarySites": "Witryny ({count})", + "alertingSummaryAllSites": "Wszystkie witryny", + "alertingSummaryHealthChecks": "Kontrole zdrowia ({count})", + "alertingSummaryAllHealthChecks": "Wszystkie kontrole zdrowia", + "alertingSummaryResources": "Zasoby ({count})", + "alertingSummaryAllResources": "Wszystkie zasoby", + "alertingErrorNameRequired": "Wprowadź nazwę", + "alertingErrorActionsMin": "Dodaj co najmniej jedną akcję", + "alertingErrorPickSites": "Wybierz co najmniej jedną witrynę", + "alertingErrorPickHealthChecks": "Wybierz co najmniej jedną kontrolę zdrowia", + "alertingErrorPickResources": "Wybierz co najmniej jeden zasób", + "alertingErrorTriggerSite": "Wybierz wyzwalacz witryny", + "alertingErrorTriggerHealth": "Wybierz wyzwalacz kontroli zdrowia", + "alertingErrorTriggerResource": "Wybierz wyzwalacz zasobu", + "alertingErrorNotifyRecipients": "Wybierz użytkowników, role lub co najmniej jeden e-mail", + "alertingConfigureSource": "Skonfiguruj źródło", + "alertingConfigureTrigger": "Skonfiguruj wyzwalacz", + "alertingConfigureActions": "Skonfiguruj akcje", + "alertingBackToRules": "Powrót do reguł", + "alertingRuleCooldown": "Czas ochłodzenia (sekundy)", + "alertingRuleCooldownDescription": "Minimalny czas między powtórzonymi alarmami dla tej samej reguły. Ustaw na 0, aby wyzwalać za każdym razem.", + "alertingDraftBadge": "Szkic - zapisz, aby zachować tę regułę", + "alertingSidebarHint": "Kliknij krok na kanwie, aby edytować go tutaj.", + "alertingGraphCanvasTitle": "Przepływ reguł", + "alertingGraphCanvasDescription": "Wizualny podgląd źródła, wyzwalacza i akcji. Wybierz węzeł, aby edytować go w panelu.", + "alertingNodeNotConfigured": "Nie skonfigurowano jeszcze", + "alertingNodeActionsCount": "{count, plural, one {# akcja} few {# akcje} many {# akcji} other {# akcji}}", + "alertingNodeRoleSource": "Źródło", + "alertingNodeRoleTrigger": "Wyzwalacz", + "alertingNodeRoleAction": "Akcja", + "alertingTabRules": "Reguły Alarmowe", + "alertingTabHealthChecks": "Kontrole Zdrowia", + "alertingRulesBannerTitle": "Otrzymaj Powiadomienie", + "alertingRulesBannerDescription": "Każda reguła wiąże ze sobą co obserwować (np. witryna, kontrola zdrowia czy zasób), kiedy uruchomić (np. offline lub niezdrowy), oraz jak powiadomić zespół przez e-mail, webhooks lub integracje. Użyj tej listy, aby utworzyć, włączyć i zarządzać tymi regułami.", + "alertingHealthChecksBannerTitle": "Monitor Zdrowia i Zasobów", + "alertingHealthChecksBannerDescription": "Kontrole zdrowia to monitory HTTP lub TCP, które definiujesz raz. Następnie możesz używać ich jako źródeł w regułach alarmowych, aby otrzymywać powiadomienia, kiedy cel stanie się zdrowy lub niezdrowy. Kontrole zdrowia w zasobach również pojawiają się tutaj.", + "standaloneHcTableTitle": "Kontrole Zdrowia", + "standaloneHcSearchPlaceholder": "Szukaj kontroli zdrowia…", + "standaloneHcAddButton": "Utwórz Kontrolę Zdrowia", + "standaloneHcCreateTitle": "Utwórz Kontrolę Zdrowia", + "standaloneHcEditTitle": "Edytuj Kontrolę Zdrowia", + "standaloneHcDescription": "Skonfiguruj kontrolę zdrowia HTTP lub TCP do wykorzystania w regułach alarmowych.", + "standaloneHcNameLabel": "Nazwa", + "standaloneHcNamePlaceholder": "Mój Monitor HTTP", + "standaloneHcDeleteTitle": "Usuń kontrolę zdrowia", + "standaloneHcDeleteQuestion": "Potwierdź, że chcesz usunąć tę kontrolę zdrowia.", + "standaloneHcDeleted": "Kontrola zdrowia usunięta", + "standaloneHcSaved": "Kontrola zdrowia zapisana", + "standaloneHcColumnHealth": "Zdrowie", + "standaloneHcColumnMode": "Tryb", + "standaloneHcColumnTarget": "Cel", + "standaloneHcHealthStateHealthy": "Zdrowy", + "standaloneHcHealthStateUnhealthy": "Niezdrowy", + "standaloneHcHealthStateUnknown": "Nieznany", + "standaloneHcFilterAnySite": "Wszystkie witryny", + "standaloneHcFilterAnyResource": "Wszystkie zasoby", + "standaloneHcFilterMode": "Tryb", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Zdrowie", + "standaloneHcFilterEnabled": "Włączone", + "standaloneHcFilterEnabledOn": "Włączone", + "standaloneHcFilterEnabledOff": "Wyłączone", + "standaloneHcFilterSiteIdFallback": "Witryna {id}", + "standaloneHcFilterResourceIdFallback": "Zasób {id}", "blueprints": "Schematy", "blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje", "blueprintAdd": "Dodaj schemat", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Interwał sprawdzania musi wynosić co najmniej 5 sekund", "healthCheckTimeoutMin": "Limit czasu musi wynosić co najmniej 1 sekundę", "healthCheckRetryMin": "Liczba prób ponowienia musi wynosić co najmniej 1", + "healthCheckMode": "Tryb kontroli", + "healthCheckStrategy": "Strategia", + "healthCheckModeDescription": "Tryb TCP weryfikuje tylko łączność. Tryb HTTP ocenia odpowiedź HTTP.", + "healthyThreshold": "Próg zdrowia", + "healthyThresholdDescription": "Wymagane sukcesy pod rząd, zanim oznaczy się jako zdrowe.", + "unhealthyThreshold": "Próg niezdrowia", + "unhealthyThresholdDescription": "Wymagane niepowodzenia z rzędu, zanim oznaczy się jako niezdrowe.", + "healthCheckHealthyThresholdMin": "Próg zdrowia musi wynosić co najmniej 1", + "healthCheckUnhealthyThresholdMin": "Próg niezdrowia musi wynosić co najmniej 1", "httpMethod": "Metoda HTTP", "selectHttpMethod": "Wybierz metodę HTTP", "domainPickerSubdomainLabel": "Poddomena", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schemat", + "editInternalResourceDialogEnableSsl": "Włącz SSL", + "editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", "editInternalResourceDialogDestination": "Miejsce docelowe", "editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "editInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Nazwa", "createInternalResourceDialogSite": "Witryna", "selectSite": "Wybierz stronę...", + "multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}", "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schemat", + "createInternalResourceDialogScheme": "Schemat", + "createInternalResourceDialogEnableSsl": "Włącz SSL", + "createInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", "createInternalResourceDialogDestination": "Miejsce docelowe", "createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", + "internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP", + "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "siteConfiguration": "Konfiguracja", "siteAcceptClientConnections": "Akceptuj połączenia klienta", "siteAcceptClientConnectionsDescription": "Zezwalaj urządzeniom i klientom na dostęp do zasobów na tej stronie. Może to zostać zmienione później.", @@ -2429,6 +2624,7 @@ "validPassword": "Prawidłowe hasło", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Połączony Klient", "resourceBlocked": "Zasób zablokowany", "droppedByRule": "Upuszczone przez regułę", "noSessions": "Brak sesji", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Miejsce docelowe", "editInternalResourceDialogDestinationDescription": "Określ adres docelowy dla wewnętrznego zasobu. Może to być nazwa hosta, adres IP lub zakres CIDR, w zależności od wybranego trybu. Opcjonalnie ustaw wewnętrzny alias DNS dla łatwiejszej identyfikacji.", "editInternalResourceDialogPortRestrictionsDescription": "Ogranicz dostęp do konkretnych portów TCP/UDP lub zezwól/zablokuj wszystkie porty.", + "createInternalResourceDialogHttpConfiguration": "Konfiguracja HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Wybierz domenę, której klienci będą używać, aby dotrzeć do tego zasobu przez HTTP lub HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Konfiguracja HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Wybierz domenę, której klienci będą używać, aby dotrzeć do tego zasobu przez HTTP lub HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Wrócimy wkrótce! Nasza strona przechodzi obecnie zaplanowaną konserwację.", "maintenancePageMessageDescription": "Szczegółowy komunikat wyjaśniający konserwację", "maintenancePageTimeTitle": "Szacowany czas zakończenia (opcjonalnie)", + "privateMaintenanceScreenTitle": "Ekraan prywatnego utrzymania", + "privateMaintenanceScreenMessage": "Ta domena jest wykorzystywana na prywatnym zasobie. Połącz się za pomocą klienta Pangolin, aby uzyskać dostęp do tego zasobu.", "maintenanceTime": "np. 2 godziny, 1 listopad o 17:00", "maintenanceEstimatedTimeDescription": "Kiedy oczekujesz zakończenia konserwacji", "editDomain": "Edytuj domenę", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Dodaj cel HTTP", "httpDestEditDescription": "Aktualizuj konfigurację dla tego celu przesyłania strumieniowego zdarzeń HTTP.", "httpDestAddDescription": "Skonfiguruj nowy punkt końcowy HTTP, aby otrzymywać wydarzenia organizacji.", + "S3DestEditTitle": "Edytuj Miejsce Docelowe", + "S3DestAddTitle": "Dodaj Miejsce Docelowe S3", + "S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.", + "S3DestAddDescription": "Skonfiguruj nowy punkt końcowy S3, aby odbierać zdarzenia Twojej organizacji.", + "datadogDestEditTitle": "Edytuj Miejsce Docelowe", + "datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog", + "datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.", + "datadogDestAddDescription": "Skonfiguruj nowy punkt końcowy Datadog, aby odbierać zdarzenia Twojej organizacji.", "httpDestTabSettings": "Ustawienia", "httpDestTabHeaders": "Nagłówki", "httpDestTabBody": "Ciało", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Cel został utworzony pomyślnie", "httpDestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego", "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego", + "followRedirects": "Podążaj za przekierowaniami", + "followRedirectsDescription": "Automatycznie podążaj za przekierowaniami HTTP dla żądań.", + "alertingErrorWebhookUrl": "Proszę wprowadzić poprawny URL dla web hooka.", + "healthCheckStrategyHttp": "Weryfikuje łączność i sprawdza status odpowiedzi HTTP.", + "healthCheckStrategyTcp": "Weryfikuje wyłącznie łączność TCP, bez sprawdzania odpowiedzi.", + "healthCheckStrategySnmp": "Wykonuje żądanie SNMP get w celu sprawdzenia stanu urządzeń sieciowych i infrastruktury.", + "healthCheckStrategyIcmp": "Używa żądań ICMP echo (pingów), aby sprawdzić, czy zasób jest dostępny i reagujący.", + "healthCheckTabStrategy": "Strategia", + "healthCheckTabConnection": "Łączenie", + "healthCheckTabAdvanced": "Zaawansowane", + "healthCheckStrategyNotAvailable": "Strategia ta nie jest dostępna. Skontaktuj się z działem sprzedaży, aby włączyć tę funkcję.", + "uptime30d": "Czas działania (30d)", "idpAddActionCreateNew": "Utwórz nowego dostawcę tożsamości", "idpAddActionImportFromOrg": "Importuj z innej organizacji", "idpImportDialogTitle": "Importuj dostawcę tożsamości", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Tego nie można cofnąć dla tej organizacji.", "idpUnassociatedDescription": "Dostawca tożsamości pomyślnie odłączony od tej organizacji", "idpUnassociateMenu": "Odłącz", - "idpDeleteAllOrgsMenu": "Usuń" + "idpDeleteAllOrgsMenu": "Usuń", + "publicIpEndpoint": "Koniec punktu pracy", + "lastTriggeredAt": "Ostatnie Wyzwolenie", + "reject": "Odrzuć" } From cba1a67b8f20de8041f83b021efc800c4e47081a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:16 -0700 Subject: [PATCH 171/176] New translations en-us.json (Portuguese) --- messages/pt-PT.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 2fa228639..949f06ac6 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contacte vendas para ativar esta funcionalidade.", + "contactSalesBookDemo": "Agende uma demonstração", + "contactSalesOr": "ou", + "contactSalesContactUs": "contacte-nos", "setupCreate": "Criar a organização, o site e os recursos", "headerAuthCompatibilityInfo": "Habilite isso para forçar uma resposta 401 Unauthorized quando um token de autenticação estiver faltando. Isso é necessário para navegadores ou bibliotecas HTTP específicas que não enviam credenciais sem um desafio do servidor.", "headerAuthCompatibility": "Compatibilidade Estendida", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", "dismiss": "Rejeitar", "subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.", + "trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.", + "trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.", + "trialActive": "Avaliação Gratuita Ativa", + "trialExpired": "Avaliação Expirada", + "trialHasEnded": "Sua avaliação terminou.", + "trialDaysRemaining": "{count, plural, one {# dia restante} other {# dias restantes}}", + "trialDaysLeftShort": "{days}d restante na avaliação", + "trialGoToBilling": "Ir para a página de faturamento", "subscriptionViolationViewBilling": "Ver faturamento", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", "componentsSupporterMessage": "Obrigado por apoiar o Pangolin como um {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "ID da Organização Ausente", "orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.", "accessUsersManage": "Gerir Utilizadores", + "accessUserManage": "Gerir Utilizador", "accessUsersDescription": "Convidar e gerenciar usuários com acesso a esta organização", "accessUsersSearch": "Procurar utilizadores...", + "accessUsersRoleFilterCount": "{count, plural, one {# função} other {# funções}}", + "accessUsersRoleFilterClear": "Limpar filtros de funções", "accessUserCreate": "Criar Usuário", "accessUserRemove": "Remover utilizador", "username": "Usuário:", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Visualizar registros", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", + "search": "Pesquisar…", "searchPlaceholder": "Buscar...", "emptySearchOptions": "Nenhuma opção encontrada", "create": "Criar", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Gerir", "sidebarLogAndAnalytics": "Registo & Análise", "sidebarBluePrints": "Diagramas", + "sidebarAlerting": "Alertas", + "sidebarHealthChecks": "Verificações de Saúde", "sidebarOrganization": "Organização", "sidebarManagement": "Gestão", "sidebarBillingAndLicenses": "Faturamento e Licenças", "sidebarLogsAnalytics": "Análises", + "alertingTitle": "Alertas", + "alertingDescription": "Defina fontes, gatilhos e ações para notificações", + "alertingRules": "Regras de alerta", + "alertingSearchRules": "Pesquisar regras…", + "alertingAddRule": "Criar Regra", + "alertingColumnSource": "Fonte", + "alertingColumnTrigger": "Gatilho", + "alertingColumnActions": "Ações", + "alertingColumnEnabled": "Ativado", + "alertingDeleteQuestion": "Por favor, confirme que deseja excluir esta regra de alerta.", + "alertingDeleteRule": "Excluir regra de alerta", + "alertingRuleDeleted": "Regra de alerta excluída", + "alertingRuleSaved": "Regra de alerta salva", + "alertingRuleSavedCreatedDescription": "Sua nova regra de alerta foi criada. Você pode continuar editando-a nesta página.", + "alertingRuleSavedUpdatedDescription": "As suas alterações para esta regra de alerta foram salvas.", + "alertingEditRule": "Editar Regra de Alerta", + "alertingCreateRule": "Criar Regra de Alerta", + "alertingRuleCredenzaDescription": "Escolha o que observar, quando disparar e como notificar", + "alertingRuleNamePlaceholder": "Site de produção fora do ar", + "alertingRuleEnabled": "Regra ativada", + "alertingSectionSource": "Fonte", + "alertingSourceType": "Tipo de Fonte", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Verificação de Saúde", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Verificações de Saúde", + "alertingPickResources": "Recursos", + "alertingAllSites": "Todos os Sites", + "alertingAllSitesDescription": "Alerta disparado para qualquer site", + "alertingSpecificSites": "Sites Específicos", + "alertingSpecificSitesDescription": "Escolha sites específicos para observar", + "alertingAllHealthChecks": "Todas as Verificações de Saúde", + "alertingAllHealthChecksDescription": "Alerta disparado para qualquer verificação de saúde", + "alertingSpecificHealthChecks": "Verificações de Saúde Específicas", + "alertingSpecificHealthChecksDescription": "Escolha verificações de saúde específicas para observar", + "alertingAllResources": "Todos os Recursos", + "alertingAllResourcesDescription": "Alerta disparado para qualquer recurso", + "alertingSpecificResources": "Recursos Específicos", + "alertingSpecificResourcesDescription": "Escolha recursos específicos para observar", + "alertingSelectResources": "Selecionar recursos…", + "alertingResourcesSelected": "{count} recursos selecionados", + "alertingResourcesEmpty": "Nenhum recurso com alvos nos primeiros 10 resultados.", + "alertingSectionTrigger": "Gatilho", + "alertingTrigger": "Quando alertar", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Status do site muda", + "alertingTriggerHcHealthy": "Verificação de saúde saudável", + "alertingTriggerHcUnhealthy": "Verificação de saúde não saudável", + "alertingTriggerHcToggle": "Status da verificação de saúde muda", + "alertingTriggerResourceHealthy": "Recurso saudável", + "alertingTriggerResourceUnhealthy": "Recurso não saudável", + "alertingSearchHealthChecks": "Pesquisar verificações de saúde…", + "alertingHealthChecksEmpty": "Nenhuma verificação de saúde disponível.", + "alertingTriggerResourceToggle": "Status do recurso muda", + "alertingSourceResource": "Recurso", + "alertingSectionActions": "Ações", + "alertingAddAction": "Adicionar Ação", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Enviar notificações por e-mail para usuários ou funções", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Envie uma solicitação HTTP para um endpoint personalizado", + "alertingExternalIntegration": "Integração Externa", + "alertingExternalPagerDutyDescription": "Envie alertas para PagerDuty para gerenciamento de incidentes", + "alertingExternalOpsgenieDescription": "Direcione alertas para Opsgenie para gestão de plantão", + "alertingExternalServiceNowDescription": "Crie incidentes do ServiceNow a partir de eventos de alerta", + "alertingExternalIncidentIoDescription": "Dispare fluxos de trabalho do Incident.io a partir de eventos de alerta", + "alertingActionType": "Tipo de Ação", + "alertingNotifyUsers": "Utilizadores", + "alertingNotifyRoles": "Papéis", + "alertingNotifyEmails": "Endereços de e-mail", + "alertingEmailPlaceholder": "Adicione o e-mail e pressione Enter", + "alertingWebhookMethod": "Método HTTP", + "alertingWebhookSecret": "Segredo de assinatura (opcional)", + "alertingWebhookSecretPlaceholder": "Segredo HMAC", + "alertingWebhookHeaders": "Cabeçalhos", + "alertingAddHeader": "Adicionar cabeçalho", + "alertingSelectSites": "Selecionar sites…", + "alertingSitesSelected": "{count} sites selecionados", + "alertingSelectHealthChecks": "Selecionar verificações de saúde…", + "alertingHealthChecksSelected": "{count} verificações de saúde selecionadas", + "alertingNoHealthChecks": "Nenhum alvo com verificações de saúde ativadas", + "alertingHealthCheckStub": "A seleção da fonte de verificação de saúde ainda não está configurada - você ainda pode configurar gatilhos e ações.", + "alertingSelectUsers": "Selecionar utilizadores…", + "alertingUsersSelected": "{count} utilizadores selecionados", + "alertingSelectRoles": "Selecionar funções…", + "alertingRolesSelected": "{count} funções selecionadas", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Todos os sites", + "alertingSummaryHealthChecks": "Verificações de saúde ({count})", + "alertingSummaryAllHealthChecks": "Todas as verificações de saúde", + "alertingSummaryResources": "Recursos ({count})", + "alertingSummaryAllResources": "Todos os recursos", + "alertingErrorNameRequired": "Digite um nome", + "alertingErrorActionsMin": "Adicione pelo menos uma ação", + "alertingErrorPickSites": "Selecione pelo menos um site", + "alertingErrorPickHealthChecks": "Selecione pelo menos uma verificação de saúde", + "alertingErrorPickResources": "Selecione pelo menos um recurso", + "alertingErrorTriggerSite": "Escolha um gatilho de site", + "alertingErrorTriggerHealth": "Escolha um gatilho de verificação de saúde", + "alertingErrorTriggerResource": "Escolha um gatilho de recurso", + "alertingErrorNotifyRecipients": "Escolha utilizadores, funções ou pelo menos um e-mail", + "alertingConfigureSource": "Configurar Fonte", + "alertingConfigureTrigger": "Configurar Gatilho", + "alertingConfigureActions": "Configurar Ações", + "alertingBackToRules": "Voltar às Regras", + "alertingRuleCooldown": "Tempo de Resfriamento (segundos)", + "alertingRuleCooldownDescription": "Tempo mínimo entre alertas repetidos para a mesma regra. Defina para 0 para disparar todas as vezes.", + "alertingDraftBadge": "Rascunho - salvar para armazenar esta regra", + "alertingSidebarHint": "Clique em um passo na tela para editá-lo aqui.", + "alertingGraphCanvasTitle": "Fluxo de Regras", + "alertingGraphCanvasDescription": "Visão geral visual de fonte, gatilho e ações. Selecione um nó para editá-lo no painel.", + "alertingNodeNotConfigured": "Ainda não configurado", + "alertingNodeActionsCount": "{count, plural, one {# ação} other {# ações}}", + "alertingNodeRoleSource": "Fonte", + "alertingNodeRoleTrigger": "Gatilho", + "alertingNodeRoleAction": "Ação", + "alertingTabRules": "Regras de Alerta", + "alertingTabHealthChecks": "Verificações de Saúde", + "alertingRulesBannerTitle": "Seja Notificado", + "alertingRulesBannerDescription": "Cada regra une o que observar (um site, verificação de saúde ou recurso), quando disparar (por exemplo, offline ou não saudável) e como notificar sua equipe por e-mail, webhooks ou integrações. Use esta lista para criar, ativar e gerenciar essas regras.", + "alertingHealthChecksBannerTitle": "Monitorar Saúde & Recursos", + "alertingHealthChecksBannerDescription": "As verificações de saúde são monitores HTTP ou TCP que você define uma vez. Você pode, então, usá-los como fontes em regras de alerta, para ser notificado quando um alvo se tornar saudável ou não saudável. As verificações de saúde em recursos também aparecem aqui.", + "standaloneHcTableTitle": "Verificações de Saúde", + "standaloneHcSearchPlaceholder": "Pesquisar verificações de saúde…", + "standaloneHcAddButton": "Criar Verificação de Saúde", + "standaloneHcCreateTitle": "Criar Verificação de Saúde", + "standaloneHcEditTitle": "Editar Verificação de Saúde", + "standaloneHcDescription": "Configure uma verificação de saúde HTTP ou TCP para uso em regras de alerta.", + "standaloneHcNameLabel": "Nome", + "standaloneHcNamePlaceholder": "Meu Monitor HTTP", + "standaloneHcDeleteTitle": "Excluir verificação de saúde", + "standaloneHcDeleteQuestion": "Por favor, confirme que deseja excluir esta verificação de saúde.", + "standaloneHcDeleted": "Verificação de saúde excluída", + "standaloneHcSaved": "Verificação de saúde salva", + "standaloneHcColumnHealth": "Saúde", + "standaloneHcColumnMode": "Modo", + "standaloneHcColumnTarget": "Alvo", + "standaloneHcHealthStateHealthy": "Saudável", + "standaloneHcHealthStateUnhealthy": "Não Saudável", + "standaloneHcHealthStateUnknown": "Desconhecido", + "standaloneHcFilterAnySite": "Todos os sites", + "standaloneHcFilterAnyResource": "Todos os recursos", + "standaloneHcFilterMode": "Modo", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Saúde", + "standaloneHcFilterEnabled": "Ativado", + "standaloneHcFilterEnabledOn": "Ativado", + "standaloneHcFilterEnabledOff": "Desativado", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Diagramas", "blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores", "blueprintAdd": "Adicionar Diagrama", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "O intervalo de verificação deve ser de pelo menos 5 segundos", "healthCheckTimeoutMin": "O tempo limite deve ser de pelo menos 1 segundo", "healthCheckRetryMin": "As tentativas de repetição devem ser pelo menos 1", + "healthCheckMode": "Modo de Verificação", + "healthCheckStrategy": "Estratégia", + "healthCheckModeDescription": "Modo TCP verifica apenas a conectividade. Modo HTTP valida a resposta HTTP.", + "healthyThreshold": "Limite de Saúde", + "healthyThresholdDescription": "Sucessos consecutivos necessários antes de marcar como saudável.", + "unhealthyThreshold": "Limite de Não Saúde", + "unhealthyThresholdDescription": "Falhas consecutivas necessárias antes de marcar como não saudável.", + "healthCheckHealthyThresholdMin": "Limite de saúde deve ser pelo menos 1", + "healthCheckUnhealthyThresholdMin": "Limite de não saúde deve ser pelo menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Selecionar método HTTP", "domainPickerSubdomainLabel": "Subdomínio", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Servidor", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Esquema", + "editInternalResourceDialogEnableSsl": "Ativar SSL", + "editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "editInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Site", "selectSite": "Selecionar site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Servidor", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Esquema", + "createInternalResourceDialogScheme": "Esquema", + "createInternalResourceDialogEnableSsl": "Ativar SSL", + "createInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", + "internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP", + "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "siteConfiguration": "Configuração", "siteAcceptClientConnections": "Aceitar Conexões de Clientes", "siteAcceptClientConnectionsDescription": "Permitir que dispositivos de usuário e clientes acessem recursos neste site. Isso pode ser alterado mais tarde.", @@ -2429,6 +2624,7 @@ "validPassword": "Senha válida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente Conectado", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Derrubado pela regra", "noSessions": "Sem Sessões", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique o endereço de destino para o recurso interno. Isso pode ser um nome de host, endereço IP ou intervalo CIDR, dependendo do modo selecionado. Opcionalmente, defina um alias interno de DNS para facilitar a identificação.", "editInternalResourceDialogPortRestrictionsDescription": "Restrinja o acesso a portas TCP/UDP específicas ou permita/bloqueie todas as portas.", + "createInternalResourceDialogHttpConfiguration": "Configuração HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Escolha o domínio que os clientes usarão para acessar este recurso via HTTP ou HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuração HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Escolha o domínio que os clientes usarão para acessar este recurso via HTTP ou HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Voltaremos em breve! Nosso site está passando por manutenção programada.", "maintenancePageMessageDescription": "Mensagem detalhada explicando a manutenção", "maintenancePageTimeTitle": "Hora de Conclusão Estimada (Opcional)", + "privateMaintenanceScreenTitle": "Tela de Placeholder Privada", + "privateMaintenanceScreenMessage": "Este domínio está sendo usado em um recurso privado. Por favor, conecte-se usando o cliente Pangolin para acessar este recurso.", "maintenanceTime": "por exemplo, 2 horas, 1 de Nov às 17h00", "maintenanceEstimatedTimeDescription": "Quando você espera que a manutenção seja concluída", "editDomain": "Editar Domínio", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Adicionar Destino HTTP", "httpDestEditDescription": "Atualizar a configuração para este destino de transmissão de eventos HTTP.", "httpDestAddDescription": "Configure um novo ponto de extremidade HTTP para receber eventos da sua organização.", + "S3DestEditTitle": "Editar Destino", + "S3DestAddTitle": "Adicionar Destino S3", + "S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.", + "S3DestAddDescription": "Configure um novo endpoint S3 para receber os eventos da sua organização.", + "datadogDestEditTitle": "Editar Destino", + "datadogDestAddTitle": "Adicionar Destino Datadog", + "datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.", + "datadogDestAddDescription": "Configure um novo endpoint Datadog para receber os eventos da sua organização.", "httpDestTabSettings": "Confirgurações", "httpDestTabHeaders": "Cabeçalhos", "httpDestTabBody": "Conteúdo", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Destino criado com sucesso", "httpDestUpdateFailed": "Falha ao atualizar destino", "httpDestCreateFailed": "Falha ao criar destino", + "followRedirects": "Seguir Redirecionamentos", + "followRedirectsDescription": "Siga automaticamente os redirecionamentos HTTP para requisições.", + "alertingErrorWebhookUrl": "Por favor, insira um URL válido para o webhook.", + "healthCheckStrategyHttp": "Valida conectividade e verifica o status da resposta HTTP.", + "healthCheckStrategyTcp": "Verifica apenas conectividade TCP, sem inspecionar a resposta.", + "healthCheckStrategySnmp": "Faz uma solicitação SNMP para verificar a saúde dos dispositivos e infraestruturas de rede.", + "healthCheckStrategyIcmp": "Usa solicitações de eco ICMP (pings) para verificar se um recurso é acessível e responsivo.", + "healthCheckTabStrategy": "Estratégia", + "healthCheckTabConnection": "Conexão", + "healthCheckTabAdvanced": "Avançado", + "healthCheckStrategyNotAvailable": "Esta estratégia não está disponível. Por favor, contacte vendas para ativar esta funcionalidade.", + "uptime30d": "Uptime (30d)", "idpAddActionCreateNew": "Criar novo provedor de identidade", "idpAddActionImportFromOrg": "Importar de outra organização", "idpImportDialogTitle": "Importar Provedor de Identidade", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Isso não pode ser desfeito para esta organização.", "idpUnassociatedDescription": "Provedor de identidade desassociado desta organização com sucesso", "idpUnassociateMenu": "Desassociar", - "idpDeleteAllOrgsMenu": "Excluir" + "idpDeleteAllOrgsMenu": "Excluir", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Último Gatilho", + "reject": "Rejeitar" } From 1ce11d0f5fe4cc079524f7958540d86cfd00d9e9 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:18 -0700 Subject: [PATCH 172/176] New translations en-us.json (Russian) --- messages/ru-RU.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 871b292d9..d5496e660 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Свяжитесь с отделом продаж, чтобы включить эту функцию.", + "contactSalesBookDemo": "Записаться на демонстрацию", + "contactSalesOr": "или", + "contactSalesContactUs": "свяжитесь с нами", "setupCreate": "Создать организацию, сайт и ресурсы", "headerAuthCompatibilityInfo": "Включите это, чтобы принудительно вернуть ответ 401 Unauthorized, если отсутствует токен аутентификации. Это требуется для браузеров или определенных библиотек HTTP, которые не отправляют учетные данные без запроса сервера.", "headerAuthCompatibility": "Дополнительная совместимость", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.", "dismiss": "Отменить", "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", + "trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.", + "trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.", + "trialActive": "Бесплатный пробный период активен", + "trialExpired": "Пробный период истек", + "trialHasEnded": "Ваш пробный период окончен.", + "trialDaysRemaining": "{count, plural, one {# день остался} few {# дня осталось} many {# дней осталось} other {# дней осталось}}", + "trialDaysLeftShort": "Осталось {days}д в пробном периоде", + "trialGoToBilling": "Перейти на страницу выставления счетов", "subscriptionViolationViewBilling": "Просмотр биллинга", "componentsLicenseViolation": "Нарушение лицензии: Сервер использует {usedSites} сайтов, что превышает лицензионный лимит в {maxSites} сайтов. Соблюдайте условия лицензии для использования всех функций.", "componentsSupporterMessage": "Спасибо за поддержку Pangolin в качестве {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Отсутствует ID организации", "orgMissingMessage": "Невозможно восстановить приглашение без ID организации.", "accessUsersManage": "Управление пользователями", + "accessUserManage": "Управление пользователем", "accessUsersDescription": "Пригласить и управлять пользователями с доступом к этой организации", "accessUsersSearch": "Поиск пользователей...", + "accessUsersRoleFilterCount": "{count, plural, one {# роль} few {# роли} many {# ролей} other {# роли}}", + "accessUsersRoleFilterClear": "Очистить фильтры ролей", "accessUserCreate": "Создать пользователя", "accessUserRemove": "Удалить пользователя", "username": "Имя пользователя", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Просмотр журналов", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", + "search": "Поиск…", "searchPlaceholder": "Поиск...", "emptySearchOptions": "Опции не найдены", "create": "Создать", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Управление", "sidebarLogAndAnalytics": "Журнал и аналитика", "sidebarBluePrints": "Чертежи", + "sidebarAlerting": "Оповещения", + "sidebarHealthChecks": "Проверки здоровья", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Биллинг и лицензии", "sidebarLogsAnalytics": "Статистика", + "alertingTitle": "Оповещения", + "alertingDescription": "Определите источники, триггеры и действия для уведомлений", + "alertingRules": "Правила оповещений", + "alertingSearchRules": "Поиск правил…", + "alertingAddRule": "Создать правило", + "alertingColumnSource": "Источник", + "alertingColumnTrigger": "Триггер", + "alertingColumnActions": "Действия", + "alertingColumnEnabled": "Включено", + "alertingDeleteQuestion": "Пожалуйста, подтвердите удаление этого правила оповещений.", + "alertingDeleteRule": "Удалить правило оповещений", + "alertingRuleDeleted": "Правило оповещений удалено", + "alertingRuleSaved": "Правило оповещений сохранено", + "alertingRuleSavedCreatedDescription": "Ваше новое правило оповещений создано. Вы можете продолжать редактировать его на этой странице.", + "alertingRuleSavedUpdatedDescription": "Ваши изменения в этом правиле оповещений были сохранены.", + "alertingEditRule": "Редактировать правило оповещений", + "alertingCreateRule": "Создать правило оповещений", + "alertingRuleCredenzaDescription": "Выберите, что отслеживать, когда срабатывать и как уведомлять", + "alertingRuleNamePlaceholder": "Рабочий сайт не доступен", + "alertingRuleEnabled": "Правило включено", + "alertingSectionSource": "Источник", + "alertingSourceType": "Тип источника", + "alertingSourceSite": "Сайт", + "alertingSourceHealthCheck": "Проверка здоровья", + "alertingPickSites": "Сайты", + "alertingPickHealthChecks": "Проверки здоровья", + "alertingPickResources": "Ресурсы", + "alertingAllSites": "Все сайты", + "alertingAllSitesDescription": "Оповещение срабатывает на любом сайте", + "alertingSpecificSites": "Конкретные сайты", + "alertingSpecificSitesDescription": "Выберите конкретные сайты для отслеживания", + "alertingAllHealthChecks": "Все проверки здоровья", + "alertingAllHealthChecksDescription": "Оповещение срабатывает на любой проверке здоровья", + "alertingSpecificHealthChecks": "Конкретные проверки здоровья", + "alertingSpecificHealthChecksDescription": "Выберите конкретные проверки здоровья для отслеживания", + "alertingAllResources": "Все ресурсы", + "alertingAllResourcesDescription": "Оповещение срабатывает на любом ресурсе", + "alertingSpecificResources": "Конкретные ресурсы", + "alertingSpecificResourcesDescription": "Выберите конкретные ресурсы для отслеживания", + "alertingSelectResources": "Выберите ресурсы…", + "alertingResourcesSelected": "Выбрано {count} ресурсов", + "alertingResourcesEmpty": "Нет ресурсов с целью в первых 10 результатах.", + "alertingSectionTrigger": "Триггер", + "alertingTrigger": "Когда оповестить", + "alertingTriggerSiteOnline": "Сайт онлайн", + "alertingTriggerSiteOffline": "Сайт офлайн", + "alertingTriggerSiteToggle": "Статус сайта изменяется", + "alertingTriggerHcHealthy": "Проверка здоровья успешна", + "alertingTriggerHcUnhealthy": "Проверка здоровья не успешна", + "alertingTriggerHcToggle": "Статус проверки здоровья изменяется", + "alertingTriggerResourceHealthy": "Ресурс в нормальном состоянии", + "alertingTriggerResourceUnhealthy": "Ресурс в ненормальном состоянии", + "alertingSearchHealthChecks": "Поиск проверок здоровья…", + "alertingHealthChecksEmpty": "Нет доступных проверок здоровья.", + "alertingTriggerResourceToggle": "Статус ресурса изменяется", + "alertingSourceResource": "Ресурс", + "alertingSectionActions": "Действия", + "alertingAddAction": "Добавить действие", + "alertingActionNotify": "Электронная почта", + "alertingActionNotifyDescription": "Отправляйте email уведомления пользователям или ролям", + "alertingActionWebhook": "Веб-хук", + "alertingActionWebhookDescription": "Отправьте HTTP-запрос на пользовательскую конечную точку", + "alertingExternalIntegration": "Внешняя интеграция", + "alertingExternalPagerDutyDescription": "Отправляйте оповещения в PagerDuty для управления инцидентами", + "alertingExternalOpsgenieDescription": "Маршрутизируйте оповещения в Opsgenie для управления дежурной службой", + "alertingExternalServiceNowDescription": "Создавайте инциденты ServiceNow из событий оповещений", + "alertingExternalIncidentIoDescription": "Запускайте рабочие процессы Incident.io из событий оповещений", + "alertingActionType": "Тип действия", + "alertingNotifyUsers": "Пользователи", + "alertingNotifyRoles": "Роли", + "alertingNotifyEmails": "Email адреса", + "alertingEmailPlaceholder": "Добавьте email и нажмите Enter", + "alertingWebhookMethod": "HTTP метод", + "alertingWebhookSecret": "Секрет подписания (необязательно)", + "alertingWebhookSecretPlaceholder": "HMAC секрет", + "alertingWebhookHeaders": "Заголовки", + "alertingAddHeader": "Добавить заголовок", + "alertingSelectSites": "Выберите сайты…", + "alertingSitesSelected": "Выбрано {count} сайтов", + "alertingSelectHealthChecks": "Выберите проверки здоровья…", + "alertingHealthChecksSelected": "Выбрано {count} проверок здоровья", + "alertingNoHealthChecks": "Цели без включенных проверок здоровья отсутствуют", + "alertingHealthCheckStub": "Выбор источника проверки здоровья ещё не подключён - вы все ещё можете настроить триггеры и действия.", + "alertingSelectUsers": "Выберите пользователей…", + "alertingUsersSelected": "Выбрано {count} пользователей", + "alertingSelectRoles": "Выберите роли…", + "alertingRolesSelected": "Выбрано {count} ролей", + "alertingSummarySites": "Сайты ({count})", + "alertingSummaryAllSites": "Все сайты", + "alertingSummaryHealthChecks": "Проверки здоровья ({count})", + "alertingSummaryAllHealthChecks": "Все проверки здоровья", + "alertingSummaryResources": "Ресурсы ({count})", + "alertingSummaryAllResources": "Все ресурсы", + "alertingErrorNameRequired": "Введите название", + "alertingErrorActionsMin": "Добавьте как минимум одно действие", + "alertingErrorPickSites": "Выберите как минимум один сайт", + "alertingErrorPickHealthChecks": "Выберите как минимум одну проверку здоровья", + "alertingErrorPickResources": "Выберите как минимум один ресурс", + "alertingErrorTriggerSite": "Выберите триггер сайта", + "alertingErrorTriggerHealth": "Выберите триггер проверки здоровья", + "alertingErrorTriggerResource": "Выберите триггер ресурса", + "alertingErrorNotifyRecipients": "Выберите пользователей, роли или как минимум один email", + "alertingConfigureSource": "Настроить источник", + "alertingConfigureTrigger": "Настроить триггер", + "alertingConfigureActions": "Настроить действия", + "alertingBackToRules": "Назад к правилам", + "alertingRuleCooldown": "Охлаждение (секунды)", + "alertingRuleCooldownDescription": "Минимальное время между повторными оповещениями для одного и того же правила. Установите 0 для каждого вызова.", + "alertingDraftBadge": "Черновик - сохраните, чтобы сохранить это правило", + "alertingSidebarHint": "Кликните по шагу на холсте, чтобы редактировать его здесь.", + "alertingGraphCanvasTitle": "Поток правил", + "alertingGraphCanvasDescription": "Визуальный обзор источника, триггера и действий. Выберите узел, чтобы редактировать его в панели.", + "alertingNodeNotConfigured": "Ещё не настроено", + "alertingNodeActionsCount": "{count, plural, one {# действие} few {# действия} many {# действий} other {# действий}}", + "alertingNodeRoleSource": "Источник", + "alertingNodeRoleTrigger": "Триггер", + "alertingNodeRoleAction": "Действие", + "alertingTabRules": "Правила оповещений", + "alertingTabHealthChecks": "Проверки здоровья", + "alertingRulesBannerTitle": "Получить уведомление", + "alertingRulesBannerDescription": "Каждое правило объединяет, что отслеживать (сайт, проверка состояния или ресурс), когда срабатывать (например, оффлайн или нарушение), и как уведомлять вашу команду через email, вебхуки или интеграции. Используйте этот список для создания, включения и управления этими правилами.", + "alertingHealthChecksBannerTitle": "Мониторинг здоровья и ресурсов", + "alertingHealthChecksBannerDescription": "Проверки здоровья — это HTTP или TCP мониторы, которые вы определяете один раз. Затем вы можете использовать их в правилах оповещений, чтобы получать уведомления, когда цель становится здоровой или нездоровой. Проверки здоровья для ресурсов также появляются здесь.", + "standaloneHcTableTitle": "Проверки здоровья", + "standaloneHcSearchPlaceholder": "Поиск проверок здоровья…", + "standaloneHcAddButton": "Создать проверку здоровья", + "standaloneHcCreateTitle": "Создать проверку здоровья", + "standaloneHcEditTitle": "Редактировать проверку здоровья", + "standaloneHcDescription": "Настройте проверку здоровья HTTP или TCP для использования в правилах оповещений.", + "standaloneHcNameLabel": "Имя", + "standaloneHcNamePlaceholder": "Мой HTTP монитор", + "standaloneHcDeleteTitle": "Удалить проверку здоровья", + "standaloneHcDeleteQuestion": "Пожалуйста, подтвердите удаление этой проверки здоровья.", + "standaloneHcDeleted": "Проверка здоровья удалена", + "standaloneHcSaved": "Проверка здоровья сохранена", + "standaloneHcColumnHealth": "Здоровье", + "standaloneHcColumnMode": "Режим", + "standaloneHcColumnTarget": "Цель", + "standaloneHcHealthStateHealthy": "Здоровый", + "standaloneHcHealthStateUnhealthy": "Нездоровый", + "standaloneHcHealthStateUnknown": "Неизвестно", + "standaloneHcFilterAnySite": "Все сайты", + "standaloneHcFilterAnyResource": "Все ресурсы", + "standaloneHcFilterMode": "Режим", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Пинг", + "standaloneHcFilterHealth": "Здоровье", + "standaloneHcFilterEnabled": "Включено", + "standaloneHcFilterEnabledOn": "Включено", + "standaloneHcFilterEnabledOff": "Отключено", + "standaloneHcFilterSiteIdFallback": "Сайт {id}", + "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски", "blueprintAdd": "Добавить чертёж", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Интервал проверки должен составлять не менее 5 секунд", "healthCheckTimeoutMin": "Тайм-аут должен составлять не менее 1 секунды", "healthCheckRetryMin": "Количество попыток должно быть не менее 1", + "healthCheckMode": "Режим проверки", + "healthCheckStrategy": "Стратегия", + "healthCheckModeDescription": "Режим TCP проверяет только возможность подключения. Режим HTTP проверяет ответ HTTP.", + "healthyThreshold": "Порог здорового состояния", + "healthyThresholdDescription": "Последовательные успехи, необходимые перед тем, как пометить как здоровый.", + "unhealthyThreshold": "Порог нездорового состояния", + "unhealthyThresholdDescription": "Последовательные неудачи, необходимые перед тем, как пометить как нездоровый.", + "healthCheckHealthyThresholdMin": "Порог здорового состояния должен быть не менее 1", + "healthCheckUnhealthyThresholdMin": "Порог нездорового состояния должен быть не менее 1", "httpMethod": "HTTP метод", "selectHttpMethod": "Выберите HTTP метод", "domainPickerSubdomainLabel": "Поддомен", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "СИДР", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Схема", + "editInternalResourceDialogEnableSsl": "Включить SSL", + "editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.", "editInternalResourceDialogDestination": "Пункт назначения", "editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "editInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Имя", "createInternalResourceDialogSite": "Сайт", "selectSite": "Выберите сайт...", + "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}", "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "СИДР", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Схема", + "createInternalResourceDialogScheme": "Схема", + "createInternalResourceDialogEnableSsl": "Включить SSL", + "createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.", "createInternalResourceDialogDestination": "Пункт назначения", "createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", + "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", + "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Принимать подключения клиентов", "siteAcceptClientConnectionsDescription": "Разрешить пользовательским устройствам и клиентам доступ к ресурсам на этом сайте. Это может быть изменено позже.", @@ -2429,6 +2624,7 @@ "validPassword": "Допустимый пароль", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Подключенный клиент", "resourceBlocked": "Ресурс заблокирован", "droppedByRule": "Отброшено по правилам", "noSessions": "Нет сессий", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Пункт назначения", "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", + "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", "maintenancePageMessageDescription": "Подробное сообщение, объясняющее обслуживание", "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", + "privateMaintenanceScreenTitle": "Экраны частной заглушки", + "privateMaintenanceScreenMessage": "Этот домен используется на частном ресурсе. Пожалуйста, подключитесь с помощью клиента Pangolin для доступа к этому ресурсу.", "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", "editDomain": "Редактировать домен", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Добавить HTTP адрес", "httpDestEditDescription": "Обновление конфигурации для этого HTTP события потокового назначения.", "httpDestAddDescription": "Настройте новую HTTP-конечную точку для получения событий вашей организации.", + "S3DestEditTitle": "Редактировать пункт назначения", + "S3DestAddTitle": "Добавить S3 пункт назначения", + "S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.", + "S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.", + "datadogDestEditTitle": "Редактировать пункт назначения", + "datadogDestAddTitle": "Добавить пункт назначения Datadog", + "datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.", + "datadogDestAddDescription": "Настройте новую конечную точку Datadog для получения событий вашей организации.", "httpDestTabSettings": "Настройки", "httpDestTabHeaders": "Заголовки", "httpDestTabBody": "Тело", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Адрес назначения успешно создан", "httpDestUpdateFailed": "Не удалось обновить место назначения", "httpDestCreateFailed": "Не удалось создать место назначения", + "followRedirects": "Следовать за перенаправлениями", + "followRedirectsDescription": "Автоматически следуйте HTTP перенаправлениям для запросов.", + "alertingErrorWebhookUrl": "Пожалуйста, введите корректный URL для вебхука.", + "healthCheckStrategyHttp": "Проверяет возможность подключения и проверяет статус HTTP ответа.", + "healthCheckStrategyTcp": "Проверяет только подключение TCP, не инспектируя ответ.", + "healthCheckStrategySnmp": "Выполняет SNMP get-запрос, чтобы проверить состояние сетевых устройств и инфраструктуры.", + "healthCheckStrategyIcmp": "Использует эхо-запросы ICMP (ping), чтобы проверить, доступен ли и отзывчив ли ресурс.", + "healthCheckTabStrategy": "Стратегия", + "healthCheckTabConnection": "Подключение", + "healthCheckTabAdvanced": "Дополнительно", + "healthCheckStrategyNotAvailable": "Эта стратегия недоступна. Пожалуйста, свяжитесь с отделом продаж для включения этой функции.", + "uptime30d": "Время работы (30 дней)", "idpAddActionCreateNew": "Создать нового поставщика удостоверений", "idpAddActionImportFromOrg": "Импортировать из другой организации", "idpImportDialogTitle": "Импортировать поставщика удостоверений", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Это не может быть отменено для этой организации.", "idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией", "idpUnassociateMenu": "Рассоединить", - "idpDeleteAllOrgsMenu": "Удалить" + "idpDeleteAllOrgsMenu": "Удалить", + "publicIpEndpoint": "Конечная точка", + "lastTriggeredAt": "Последний триггер", + "reject": "Отклонить" } From 059ea57b88210aeec6297e91a7f9fdf6fdbae49c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:20 -0700 Subject: [PATCH 173/176] New translations en-us.json (Turkish) --- messages/tr-TR.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 754b529ac..b7b3c877c 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Bu özelliği etkinleştirmek için satış ekibiyle iletişime geçin.", + "contactSalesBookDemo": "Demo ayırt", + "contactSalesOr": "veya", + "contactSalesContactUs": "bize ulaşın", "setupCreate": "Organizasyonu, siteyi ve kaynakları oluşturun", "headerAuthCompatibilityInfo": "Kimlik doğrulama belirteci eksik olduğunda 401 Yetkisiz yanıtı zorlamak için bunu etkinleştirin. Bu, sunucu sorunu olmadan kimlik bilgilerini göndermeyen tarayıcılar veya belirli HTTP kütüphaneleri için gereklidir.", "headerAuthCompatibility": "Genişletilmiş Uyumluluk", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Geçersiz veya süresi dolmuş lisans anahtarları tespit edildi. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "dismiss": "Kapat", "subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.", + "trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.", + "trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.", + "trialActive": "Ücretsiz Deneme Aktif", + "trialExpired": "Deneme Süresi Doldu", + "trialHasEnded": "Deneme süreniz sona erdi.", + "trialDaysRemaining": "{count, plural, one {# gün kaldı} other {# gün kaldı}}", + "trialDaysLeftShort": "Deneme süresi için {days}g kaldı", + "trialGoToBilling": "Fatura sayfasına git", "subscriptionViolationViewBilling": "Faturalamayı görüntüle", "componentsLicenseViolation": "Lisans İhlali: Bu sunucu, lisanslı sınırı olan {maxSites} sitesini aşarak {usedSites} site kullanmaktadır. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "componentsSupporterMessage": "Pangolin'e {tier} olarak destek olduğunuz için teşekkür ederiz!", @@ -267,8 +279,11 @@ "orgMissing": "Organizasyon Kimliği Eksik", "orgMissingMessage": "Organizasyon kimliği olmadan daveti yeniden oluşturmanız mümkün değildir.", "accessUsersManage": "Kullanıcıları Yönet", + "accessUserManage": "Kullanıcıyı Yönet", "accessUsersDescription": "Bu organizasyona erişimi olan kullanıcıları davet edin ve yönetin", "accessUsersSearch": "Kullanıcıları ara...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# roller}}", + "accessUsersRoleFilterClear": "Rol filtrelerini temizle", "accessUserCreate": "Kullanıcı Oluştur", "accessUserRemove": "Kullanıcıyı Kaldır", "username": "Kullanıcı Adı", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Kayıtları Görüntüle", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", + "search": "Ara…", "searchPlaceholder": "Ara...", "emptySearchOptions": "Seçenek bulunamadı", "create": "Oluştur", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Yönet", "sidebarLogAndAnalytics": "Kayıt & Analiz", "sidebarBluePrints": "Planlar", + "sidebarAlerting": "Uyarı", + "sidebarHealthChecks": "Sağlık kontrolleri", "sidebarOrganization": "Organizasyon", "sidebarManagement": "Yönetim", "sidebarBillingAndLicenses": "Faturalandırma & Lisanslar", "sidebarLogsAnalytics": "Analitik", + "alertingTitle": "Uyarı", + "alertingDescription": "Bildirimler için kaynakları, tetikleyicileri ve eylemleri tanımlayın", + "alertingRules": "Uyarı kuralları", + "alertingSearchRules": "Kuralları ara…", + "alertingAddRule": "Kural Oluştur", + "alertingColumnSource": "Kaynak", + "alertingColumnTrigger": "Tetikle", + "alertingColumnActions": "İşlemler", + "alertingColumnEnabled": "Etkin", + "alertingDeleteQuestion": "Bu uyarı kuralını silmek istediğinizi onaylayın lütfen.", + "alertingDeleteRule": "Uyarı kuralını sil", + "alertingRuleDeleted": "Uyarı kuralı silindi", + "alertingRuleSaved": "Uyarı kuralı kaydedildi", + "alertingRuleSavedCreatedDescription": "Yeni uyarı kuralınız oluşturuldu. Bu sayfada düzenlemeye devam edebilirsiniz.", + "alertingRuleSavedUpdatedDescription": "Bu uyarı kuralındaki değişiklikleriniz kaydedildi.", + "alertingEditRule": "Uyarı Kuralını Düzenle", + "alertingCreateRule": "Uyarı Kuralı Oluştur", + "alertingRuleCredenzaDescription": "Ne izlenecek, ne zaman tetiklenecek ve nasıl bildirilecek, bunları seçin", + "alertingRuleNamePlaceholder": "Üretim sitesi kapalı", + "alertingRuleEnabled": "Kural etkinleştirildi", + "alertingSectionSource": "Kaynak", + "alertingSourceType": "Kaynak türü", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Sağlık kontrolü", + "alertingPickSites": "Siteler", + "alertingPickHealthChecks": "Sağlık kontrolleri", + "alertingPickResources": "Kaynaklar", + "alertingAllSites": "Tüm Siteler", + "alertingAllSitesDescription": "Herhangi bir site için uyarı tetiklenir", + "alertingSpecificSites": "Belirli Siteler", + "alertingSpecificSitesDescription": "İzlemek için belirli siteleri seçin", + "alertingAllHealthChecks": "Tüm Sağlık Kontrolleri", + "alertingAllHealthChecksDescription": "Herhangi bir sağlık kontrolü için uyarı tetiklenir", + "alertingSpecificHealthChecks": "Belirli Sağlık Kontrolleri", + "alertingSpecificHealthChecksDescription": "İzlemek için belirli sağlık kontrollerini seçin", + "alertingAllResources": "Tüm Kaynaklar", + "alertingAllResourcesDescription": "Herhangi bir kaynak için uyarı tetiklenir", + "alertingSpecificResources": "Belirli Kaynaklar", + "alertingSpecificResourcesDescription": "İzlemek için belirli kaynakları seçin", + "alertingSelectResources": "Kaynakları seçin…", + "alertingResourcesSelected": "{count} kaynak seçildi", + "alertingResourcesEmpty": "İlk 10 sonuçta hedefleri olan kaynak yok.", + "alertingSectionTrigger": "Tetikle", + "alertingTrigger": "Uyarı zamanı", + "alertingTriggerSiteOnline": "Site çevrimiçi", + "alertingTriggerSiteOffline": "Site çevrimdışı", + "alertingTriggerSiteToggle": "Site durumu değişiyor", + "alertingTriggerHcHealthy": "Sağlık kontrolü sağlıklı", + "alertingTriggerHcUnhealthy": "Sağlık kontrolü sağlıksız", + "alertingTriggerHcToggle": "Sağlık kontrolü durumu değişiyor", + "alertingTriggerResourceHealthy": "Kaynak sağlıklı", + "alertingTriggerResourceUnhealthy": "Kaynak sağlıksız", + "alertingSearchHealthChecks": "Sağlık kontrollerini ara…", + "alertingHealthChecksEmpty": "Mevcut sağlık kontrolü yok.", + "alertingTriggerResourceToggle": "Kaynak durumu değişiyor", + "alertingSourceResource": "Kaynak", + "alertingSectionActions": "İşlemler", + "alertingAddAction": "Eylem Ekle", + "alertingActionNotify": "E-posta", + "alertingActionNotifyDescription": "Kullanıcılara veya role e-posta bildirimleri gönder", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Özel bir uç noktaya HTTP isteği gönderin", + "alertingExternalIntegration": "Harici Entegrasyon", + "alertingExternalPagerDutyDescription": "Olay yönetimi için uyarıları PagerDuty'ye gönderin", + "alertingExternalOpsgenieDescription": "Nöbetçi yönetimi için uyarıları Opsgenie'ye yönlendirin", + "alertingExternalServiceNowDescription": "Uyarı olaylarından ServiceNow olayları oluşturun", + "alertingExternalIncidentIoDescription": "Uyarı olaylarından Incident.io iş akışlarını tetikleyin", + "alertingActionType": "Eylem türü", + "alertingNotifyUsers": "Kullanıcılar", + "alertingNotifyRoles": "Roller", + "alertingNotifyEmails": "E-posta adresleri", + "alertingEmailPlaceholder": "E-posta ekleyin ve Enter tuşuna basın", + "alertingWebhookMethod": "HTTP yöntemi", + "alertingWebhookSecret": "İmza sırrı (isteğe bağlı)", + "alertingWebhookSecretPlaceholder": "HMAC sırrı", + "alertingWebhookHeaders": "Başlıklar", + "alertingAddHeader": "Başlık ekle", + "alertingSelectSites": "Siteleri seçin…", + "alertingSitesSelected": "{count} site seçildi", + "alertingSelectHealthChecks": "Sağlık kontrolleri seçin…", + "alertingHealthChecksSelected": "{count} sağlık kontrolü seçildi", + "alertingNoHealthChecks": "Hedefleri etkinleştirilmiş sağlık kontrolleri yok", + "alertingHealthCheckStub": "Sağlık kontrolü kaynak seçimi henüz bağlanmadı - yine de tetikleyicileri ve eylemleri yapılandırabilirsiniz.", + "alertingSelectUsers": "Kullanıcıları seçin…", + "alertingUsersSelected": "{count} kullanıcı seçildi", + "alertingSelectRoles": "Rolleri seçin…", + "alertingRolesSelected": "{count} rol seçildi", + "alertingSummarySites": "Siteler ({count})", + "alertingSummaryAllSites": "Tüm siteler", + "alertingSummaryHealthChecks": "Sağlık kontrolleri ({count})", + "alertingSummaryAllHealthChecks": "Tüm sağlık kontrolleri", + "alertingSummaryResources": "Kaynaklar ({count})", + "alertingSummaryAllResources": "Tüm kaynaklar", + "alertingErrorNameRequired": "Bir ad girin", + "alertingErrorActionsMin": "En az bir eylem ekleyin", + "alertingErrorPickSites": "En az bir site seçin", + "alertingErrorPickHealthChecks": "En az bir sağlık kontrolü seçin", + "alertingErrorPickResources": "En az bir kaynak seçin", + "alertingErrorTriggerSite": "Bir site tetikleyicisi seçin", + "alertingErrorTriggerHealth": "Bir sağlık kontrolü tetikleyicisi seçin", + "alertingErrorTriggerResource": "Bir kaynak tetikleyicisi seçin", + "alertingErrorNotifyRecipients": "Kullanıcıları, rolleri veya en az bir e-posta seçin", + "alertingConfigureSource": "Kaynağı Yapılandır", + "alertingConfigureTrigger": "Tetikleyiciyi Yapılandır", + "alertingConfigureActions": "Eylemleri Yapılandır", + "alertingBackToRules": "Kurallara Geri Dön", + "alertingRuleCooldown": "Serinleme süresi (saniye)", + "alertingRuleCooldownDescription": "Aynı kural için tekrarlanan uyarılar arasında minimum süre. Her seferinde tetiklenmesi için 0 olarak ayarlayın.", + "alertingDraftBadge": "Taslak - bu kuralı kaydetmek için kaydedin", + "alertingSidebarHint": "Düzenlemek için kanvas üzerindeki bir adıma tıklayın.", + "alertingGraphCanvasTitle": "Kural Akışı", + "alertingGraphCanvasDescription": "Kaynak, tetikleyici ve eylemlerin görsel genel bakışı. Düzenlemek için bir düğümü seçin.", + "alertingNodeNotConfigured": "Henüz yapılandırılmadı", + "alertingNodeActionsCount": "{count, plural, one {# eylem} other {# eylemler}}", + "alertingNodeRoleSource": "Kaynak", + "alertingNodeRoleTrigger": "Tetikle", + "alertingNodeRoleAction": "Aksiyon", + "alertingTabRules": "Uyarı Kuralları", + "alertingTabHealthChecks": "Sağlık Kontrolleri", + "alertingRulesBannerTitle": "Bildirim Alın", + "alertingRulesBannerDescription": "Her kural neyin izleneceğini (bir site, sağlık kontrolü veya kaynak), ne zaman tetikleneceğini (örneğin çevrimdışı veya sağlıksız) ve ekibinize e-posta, web kancaları veya entegrasyonlar aracılığıyla nasıl bildirileceğini bağlar. Bu listeyi kullanarak kuralları oluşturun, etkinleştirin ve yönetin.", + "alertingHealthChecksBannerTitle": "Sağlık ve Kaynakları İzleyin", + "alertingHealthChecksBannerDescription": "Sağlık kontrolleri bir kez tanımladığınız HTTP veya TCP monitörleridir. Ardından hedef sağlıklı veya sağlıksız olduğunda bildirilmeniz için onları uyarı kurallarında kaynak olarak kullanabilirsiniz. Kaynaklar üzerindeki sağlık kontrolleri de burada görünür.", + "standaloneHcTableTitle": "Sağlık Kontrolleri", + "standaloneHcSearchPlaceholder": "Sağlık kontrollerini ara…", + "standaloneHcAddButton": "Sağlık Kontrolü Oluştur", + "standaloneHcCreateTitle": "Sağlık Kontrolü Oluştur", + "standaloneHcEditTitle": "Sağlık Kontrolünü Düzenle", + "standaloneHcDescription": "Uyarı kurallarında kullanılmak üzere bir HTTP veya TCP sağlık kontrolü yapılandırın.", + "standaloneHcNameLabel": "Ad", + "standaloneHcNamePlaceholder": "HTTP Monitörüm", + "standaloneHcDeleteTitle": "Sağlık kontrolünü sil", + "standaloneHcDeleteQuestion": "Bu sağlık kontrolünü silmek istediğinizi onaylayın lütfen.", + "standaloneHcDeleted": "Sağlık kontrolü silindi", + "standaloneHcSaved": "Sağlık kontrolü kaydedildi", + "standaloneHcColumnHealth": "Sağlık", + "standaloneHcColumnMode": "Mod", + "standaloneHcColumnTarget": "Hedef", + "standaloneHcHealthStateHealthy": "Sağlıklı", + "standaloneHcHealthStateUnhealthy": "Sağlıksız", + "standaloneHcHealthStateUnknown": "Bilinmiyor", + "standaloneHcFilterAnySite": "Tüm siteler", + "standaloneHcFilterAnyResource": "Tüm kaynaklar", + "standaloneHcFilterMode": "Mod", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Sağlık", + "standaloneHcFilterEnabled": "Etkin", + "standaloneHcFilterEnabledOn": "Etkin", + "standaloneHcFilterEnabledOff": "Devre Dışı", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Kaynak {id}", "blueprints": "Planlar", "blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin", "blueprintAdd": "Plan Ekle", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Kontrol aralığı en az 5 saniye olmalıdır", "healthCheckTimeoutMin": "Zaman aşımı en az 1 saniye olmalıdır", "healthCheckRetryMin": "Tekrar deneme girişimleri en az 1 olmalıdır", + "healthCheckMode": "Modu Kontrol Et", + "healthCheckStrategy": "Strateji", + "healthCheckModeDescription": "TCP modu yalnızca bağlantıyı doğrular. HTTP modu HTTP yanıtını doğrular.", + "healthyThreshold": "Sağlıklı Eşik", + "healthyThresholdDescription": "Sağlıklı olarak işaretlenmeden önce gereken ardışık başarılar.", + "unhealthyThreshold": "Sağlıksız Eşik", + "unhealthyThresholdDescription": "Sağlıksız olarak işaretlenmeden önce gereken ardışık başarısızlıklar.", + "healthCheckHealthyThresholdMin": "Sağlıklı eşik en az 1 olmalıdır", + "healthCheckUnhealthyThresholdMin": "Sağlıksız eşik en az 1 olmalıdır", "httpMethod": "HTTP Yöntemi", "selectHttpMethod": "HTTP yöntemini seçin", "domainPickerSubdomainLabel": "Alt Alan Adı", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Bağlantı Noktası", "editInternalResourceDialogModeHost": "Ev Sahibi", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Şema", + "editInternalResourceDialogEnableSsl": "SSL'i Etkinleştir", + "editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", "editInternalResourceDialogDestination": "Hedef", "editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "editInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Ad", "createInternalResourceDialogSite": "Site", "selectSite": "Site seç...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}", "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Bağlantı Noktası", "createInternalResourceDialogModeHost": "Ev Sahibi", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Şema", + "createInternalResourceDialogScheme": "Şema", + "createInternalResourceDialogEnableSsl": "SSL'i Etkinleştir", + "createInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", "createInternalResourceDialogDestination": "Hedef", "createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", + "internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir", + "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "siteConfiguration": "Yapılandırma", "siteAcceptClientConnections": "İstemci Bağlantılarını Kabul Et", "siteAcceptClientConnectionsDescription": "Kullanıcı cihazları ve istemcilerin bu sitedeki kaynaklara erişmesine izin verin. Bu daha sonra değiştirilebilir.", @@ -2429,6 +2624,7 @@ "validPassword": "Geçerli Şifre", "validEmail": "Geçerli E-posta", "validSSO": "Geçerli SSO", + "connectedClient": "Bağlı İstemci", "resourceBlocked": "Kaynak Engellendi", "droppedByRule": "Kurallara Göre Çıkartıldı", "noSessions": "Oturum Yok", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Hedef", "editInternalResourceDialogDestinationDescription": "Dahili kaynak için hedef adresi belirtin. Seçilen moda bağlı olarak bu bir ana bilgisayar adı, IP adresi veya CIDR aralığı olabilir. Daha kolay tanımlama için isteğe bağlı olarak dahili bir DNS takma adı ayarlayın.", "editInternalResourceDialogPortRestrictionsDescription": "Belirtilen TCP/UDP portlarına erişimi kısıtlayın veya tüm portlara izin/engelleme verin.", + "createInternalResourceDialogHttpConfiguration": "HTTP yapılandırması", + "createInternalResourceDialogHttpConfigurationDescription": "HTTP veya HTTPS üzerinden bu kaynağa ulaşmak için istemcilerin kullanacağı alan adını seçin.", + "editInternalResourceDialogHttpConfiguration": "HTTP yapılandırması", + "editInternalResourceDialogHttpConfigurationDescription": "HTTP veya HTTPS üzerinden bu kaynağa ulaşmak için istemcilerin kullanacağı alan adını seçin.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Yakında geri döneceğiz! Sitemiz şu anda planlı bakım altındadır.", "maintenancePageMessageDescription": "Bakımın detaylarını açıklayan mesaj", "maintenancePageTimeTitle": "Tahmini Tamamlanma Süresi (İsteğe Bağlı)", + "privateMaintenanceScreenTitle": "Özel Yer Tutucu Ekran", + "privateMaintenanceScreenMessage": "Bu alan adı özel bir kaynak üzerinde kullanılmaktadır. Bu kaynağa erişmek için Pangolin istemcisini kullanarak bağlanın.", "maintenanceTime": "ör. 2 saat, 1 Kasım saat 17:00", "maintenanceEstimatedTimeDescription": "Bakımın ne zaman tamamlanmasını bekliyorsunuz", "editDomain": "Alan Adını Düzenle", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "HTTP Hedefi Ekle", "httpDestEditDescription": "Bu HTTP olay akışı hedefine yapılandırmayı güncelleyin.", "httpDestAddDescription": "Organizasyonunuzun olaylarını almak için yeni bir HTTP uç noktası yapılandırın.", + "S3DestEditTitle": "Hedefi Düzenle", + "S3DestAddTitle": "S3 Hedefi Ekle", + "S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.", + "S3DestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir S3 uç noktası yapılandırın.", + "datadogDestEditTitle": "Hedefi Düzenle", + "datadogDestAddTitle": "Datadog Hedefi Ekle", + "datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.", + "datadogDestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir Datadog uç noktası yapılandırın.", "httpDestTabSettings": "Ayarlar", "httpDestTabHeaders": "Başlıklar", "httpDestTabBody": "Gövde", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Hedef başarıyla oluşturuldu", "httpDestUpdateFailed": "Hedef güncellenemedi", "httpDestCreateFailed": "Hedef oluşturulamadı", + "followRedirects": "Yönlendirmeleri Takip Et", + "followRedirectsDescription": "İstekler için HTTP yönlendirmelerini otomatik olarak takip edin.", + "alertingErrorWebhookUrl": "Webhook için geçerli bir URL girin lütfen.", + "healthCheckStrategyHttp": "Bağlantıyı doğrular ve HTTP yanıt durumunu kontrol eder.", + "healthCheckStrategyTcp": "Yanıtı denetlemeden sadece TCP bağlantısını doğrular.", + "healthCheckStrategySnmp": "Ağ aygıtlarının ve altyapısının sağlığını kontrol etmek için bir SNMP alma isteği yapar.", + "healthCheckStrategyIcmp": "Bir kaynağın erişilebilir ve yanıt verebilir olup olmadığını kontrol etmek için ICMP yankı isteklerini (ping) kullanır.", + "healthCheckTabStrategy": "Strateji", + "healthCheckTabConnection": "Bağlantı", + "healthCheckTabAdvanced": "Gelişmiş", + "healthCheckStrategyNotAvailable": "Bu strateji kullanılamıyor. Bu özelliği etkinleştirmek için lütfen satış ekibiyle iletişime geçin.", + "uptime30d": "Çalışma Süresi (30g)", "idpAddActionCreateNew": "Yeni kimlik sağlayıcı oluştur", "idpAddActionImportFromOrg": "Başka bir kuruluştan içe aktar", "idpImportDialogTitle": "Kimlik Sağlayıcı İçe Aktar", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Bu işlem bu kuruluş için geri alınamaz.", "idpUnassociatedDescription": "Kimlik sağlayıcı bu kuruluştan başarıyla ayrıldı", "idpUnassociateMenu": "İlişkiyi Kes", - "idpDeleteAllOrgsMenu": "Sil" + "idpDeleteAllOrgsMenu": "Sil", + "publicIpEndpoint": "Uç Nokta", + "lastTriggeredAt": "Son Tetikleyici", + "reject": "Reddet" } From c40dd7bb432c1f9cca03a4d93b0794f5d2f85cf5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:22 -0700 Subject: [PATCH 174/176] New translations en-us.json (Chinese Simplified) --- messages/zh-CN.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 038d4cb01..1a79d3c35 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "联系销售以启用此功能。", + "contactSalesBookDemo": "预订演示", + "contactSalesOr": "或", + "contactSalesContactUs": "联系我们", "setupCreate": "创建组织、站点和资源", "headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。", "headerAuthCompatibility": "扩展兼容性", @@ -19,6 +23,14 @@ "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", "dismiss": "忽略", "subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。", + "trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。", + "trialBannerExpired": "您的试用已到期。立即升级以恢复访问。", + "trialActive": "免费试用中", + "trialExpired": "试用到期", + "trialHasEnded": "您的试用已结束。", + "trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}", + "trialDaysLeftShort": "试用期剩余 {days} 天", + "trialGoToBilling": "转到账单页面", "subscriptionViolationViewBilling": "查看计费", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", @@ -267,8 +279,11 @@ "orgMissing": "缺少组织 ID", "orgMissingMessage": "没有组织ID,无法重新生成邀请。", "accessUsersManage": "管理用户", + "accessUserManage": "管理用户", "accessUsersDescription": "邀请和管理访问此组织的用户", "accessUsersSearch": "搜索用户...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}", + "accessUsersRoleFilterClear": "清除角色过滤器", "accessUserCreate": "创建用户", "accessUserRemove": "删除用户", "username": "用户名", @@ -1257,6 +1272,7 @@ "actionViewLogs": "查看日志", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", + "search": "搜索…", "searchPlaceholder": "搜索...", "emptySearchOptions": "未找到选项", "create": "创建", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", + "sidebarAlerting": "告警", + "sidebarHealthChecks": "健康检查", "sidebarOrganization": "组织", "sidebarManagement": "管理", "sidebarBillingAndLicenses": "帐单和许可证", "sidebarLogsAnalytics": "分析", + "alertingTitle": "告警", + "alertingDescription": "定义通知的来源、触发器和操作", + "alertingRules": "告警规则", + "alertingSearchRules": "搜索规则…", + "alertingAddRule": "创建规则", + "alertingColumnSource": "来源", + "alertingColumnTrigger": "触发", + "alertingColumnActions": "操作", + "alertingColumnEnabled": "已启用", + "alertingDeleteQuestion": "请确认您要删除此告警规则。", + "alertingDeleteRule": "删除告警规则", + "alertingRuleDeleted": "告警规则已删除", + "alertingRuleSaved": "告警规则已保存", + "alertingRuleSavedCreatedDescription": "您的新告警规则已创建。您可以在此页面继续编辑它。", + "alertingRuleSavedUpdatedDescription": "对此告警规则的更改已保存。", + "alertingEditRule": "编辑告警规则", + "alertingCreateRule": "创建告警规则", + "alertingRuleCredenzaDescription": "选择要监视的内容、何时触发以及如何通知", + "alertingRuleNamePlaceholder": "生产站点故障", + "alertingRuleEnabled": "规则已启用", + "alertingSectionSource": "来源", + "alertingSourceType": "来源类型", + "alertingSourceSite": "站点", + "alertingSourceHealthCheck": "健康检查", + "alertingPickSites": "站点", + "alertingPickHealthChecks": "健康检查", + "alertingPickResources": "资源", + "alertingAllSites": "所有站点", + "alertingAllSitesDescription": "任何站点的告警触发", + "alertingSpecificSites": "特定站点", + "alertingSpecificSitesDescription": "选择要监视的特定站点", + "alertingAllHealthChecks": "所有健康检查", + "alertingAllHealthChecksDescription": "任何健康检查的告警触发", + "alertingSpecificHealthChecks": "特定健康检查", + "alertingSpecificHealthChecksDescription": "选择要监视的特定健康检查", + "alertingAllResources": "所有资源", + "alertingAllResourcesDescription": "任何资源的告警触发", + "alertingSpecificResources": "特定资源", + "alertingSpecificResourcesDescription": "选择要监视的特定资源", + "alertingSelectResources": "选择资源…", + "alertingResourcesSelected": "{count} 个资源已选择", + "alertingResourcesEmpty": "在前 10 个结果中没有带目标的资源。", + "alertingSectionTrigger": "触发", + "alertingTrigger": "何时告警", + "alertingTriggerSiteOnline": "站点在线", + "alertingTriggerSiteOffline": "站点离线", + "alertingTriggerSiteToggle": "站点状态变更", + "alertingTriggerHcHealthy": "健康检查正常", + "alertingTriggerHcUnhealthy": "健康检查不正常", + "alertingTriggerHcToggle": "健康检查状态变更", + "alertingTriggerResourceHealthy": "资源正常", + "alertingTriggerResourceUnhealthy": "资源不正常", + "alertingSearchHealthChecks": "搜索健康检查…", + "alertingHealthChecksEmpty": "无可用健康检查。", + "alertingTriggerResourceToggle": "资源状态变更", + "alertingSourceResource": "资源", + "alertingSectionActions": "操作", + "alertingAddAction": "新增操作", + "alertingActionNotify": "电子邮件", + "alertingActionNotifyDescription": "向用户或角色发送电子邮件通知", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "发送 HTTP 请求到自定义终端", + "alertingExternalIntegration": "外部集成", + "alertingExternalPagerDutyDescription": "将告警发送给 PagerDuty 以进行事件管理", + "alertingExternalOpsgenieDescription": "将告警路由到 Opsgenie 进行电话值班管理", + "alertingExternalServiceNowDescription": "从告警事件创建 ServiceNow 事件", + "alertingExternalIncidentIoDescription": "从告警事件触发 Incident.io 工作流程", + "alertingActionType": "操作类型", + "alertingNotifyUsers": "用户", + "alertingNotifyRoles": "角色", + "alertingNotifyEmails": "电子邮件地址", + "alertingEmailPlaceholder": "添加电子邮件并按回车键", + "alertingWebhookMethod": "HTTP 方法", + "alertingWebhookSecret": "签名密钥(可选)", + "alertingWebhookSecretPlaceholder": "HMAC 密钥", + "alertingWebhookHeaders": "标头", + "alertingAddHeader": "添加标头", + "alertingSelectSites": "选择站点…", + "alertingSitesSelected": "{count} 个站点已选择", + "alertingSelectHealthChecks": "选择健康检查…", + "alertingHealthChecksSelected": "{count} 个健康检查已选择", + "alertingNoHealthChecks": "没有启用健康检查的目标", + "alertingHealthCheckStub": "健康检查来源选择尚未配置 - 你仍然可以配置触发器和操作。", + "alertingSelectUsers": "选择用户…", + "alertingUsersSelected": "{count} 个用户已选择", + "alertingSelectRoles": "选择角色…", + "alertingRolesSelected": "{count} 个角色已选择", + "alertingSummarySites": "站点 ({count})", + "alertingSummaryAllSites": "所有站点", + "alertingSummaryHealthChecks": "健康检查 ({count})", + "alertingSummaryAllHealthChecks": "所有健康检查", + "alertingSummaryResources": "资源 ({count})", + "alertingSummaryAllResources": "所有资源", + "alertingErrorNameRequired": "输入名称", + "alertingErrorActionsMin": "添加至少一个操作", + "alertingErrorPickSites": "至少选择一个站点", + "alertingErrorPickHealthChecks": "至少选择一个健康检查", + "alertingErrorPickResources": "至少选择一个资源", + "alertingErrorTriggerSite": "选择站点触发器", + "alertingErrorTriggerHealth": "选择健康检查触发器", + "alertingErrorTriggerResource": "选择资源触发器", + "alertingErrorNotifyRecipients": "选择用户、角色或至少一个电子邮件", + "alertingConfigureSource": "配置来源", + "alertingConfigureTrigger": "配置触发器", + "alertingConfigureActions": "配置操作", + "alertingBackToRules": "返回规则", + "alertingRuleCooldown": "冷却时间(秒)", + "alertingRuleCooldownDescription": "相同规则间隔重复告警的最小时间。设置为 0 固定触发。", + "alertingDraftBadge": "草稿 - 保存以存储此规则", + "alertingSidebarHint": "点击画布上的步骤在此处编辑。", + "alertingGraphCanvasTitle": "规则流程", + "alertingGraphCanvasDescription": "源、触发器和操作的视觉概况。选择一个节点,在面板上进行编辑。", + "alertingNodeNotConfigured": "尚未配置", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "来源", + "alertingNodeRoleTrigger": "触发", + "alertingNodeRoleAction": "行为", + "alertingTabRules": "告警规则", + "alertingTabHealthChecks": "健康检查", + "alertingRulesBannerTitle": "获取通知", + "alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。", + "alertingHealthChecksBannerTitle": "监视健康和资源", + "alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。", + "standaloneHcTableTitle": "健康检查", + "standaloneHcSearchPlaceholder": "搜索健康检查…", + "standaloneHcAddButton": "创建健康检查", + "standaloneHcCreateTitle": "创建健康检查", + "standaloneHcEditTitle": "编辑健康检查", + "standaloneHcDescription": "配置 HTTP 或 TCP 健康检查以用于告警规则。", + "standaloneHcNameLabel": "名称", + "standaloneHcNamePlaceholder": "我的 HTTP 监视器", + "standaloneHcDeleteTitle": "删除健康检查", + "standaloneHcDeleteQuestion": "请确认您要删除此健康检查。", + "standaloneHcDeleted": "健康检查已删除", + "standaloneHcSaved": "健康检查已保存", + "standaloneHcColumnHealth": "健康", + "standaloneHcColumnMode": "模式", + "standaloneHcColumnTarget": "目标", + "standaloneHcHealthStateHealthy": "健康", + "standaloneHcHealthStateUnhealthy": "不健康", + "standaloneHcHealthStateUnknown": "未知", + "standaloneHcFilterAnySite": "所有站点", + "standaloneHcFilterAnyResource": "所有资源", + "standaloneHcFilterMode": "模式", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "健康", + "standaloneHcFilterEnabled": "已启用", + "standaloneHcFilterEnabledOn": "已启用", + "standaloneHcFilterEnabledOff": "已禁用", + "standaloneHcFilterSiteIdFallback": "站点 {id}", + "standaloneHcFilterResourceIdFallback": "资源 {id}", "blueprints": "蓝图", "blueprintsDescription": "应用声明配置并查看先前运行的", "blueprintAdd": "添加蓝图", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "检查间隔必须至少为 5 秒", "healthCheckTimeoutMin": "超时必须至少为 1 秒", "healthCheckRetryMin": "重试次数必须至少为 1 次", + "healthCheckMode": "检查模式", + "healthCheckStrategy": "策略", + "healthCheckModeDescription": "TCP 模式仅验证连接性。HTTP 模式验证 HTTP 响应。", + "healthyThreshold": "正常阈值", + "healthyThresholdDescription": "标记为正常之前所需的连续成功次数。", + "unhealthyThreshold": "不正常阈值", + "unhealthyThresholdDescription": "标记为不正常之前所需的连续失败次数。", + "healthCheckHealthyThresholdMin": "健康阈值至少为 1", + "healthCheckUnhealthyThresholdMin": "不健康阈值至少为 1", "httpMethod": "HTTP 方法", "selectHttpMethod": "选择 HTTP 方法", "domainPickerSubdomainLabel": "子域名", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "端口", "editInternalResourceDialogModeHost": "主机", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "方案", + "editInternalResourceDialogEnableSsl": "启用 SSL", + "editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", "editInternalResourceDialogDestination": "目标", "editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "名称", "createInternalResourceDialogSite": "站点", "selectSite": "选择站点...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "端口", "createInternalResourceDialogModeHost": "主机", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "方案", + "createInternalResourceDialogScheme": "方案", + "createInternalResourceDialogEnableSsl": "启用 SSL", + "createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", "createInternalResourceDialogDestination": "目标", "createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", + "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", + "internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "siteConfiguration": "配置", "siteAcceptClientConnections": "接受客户端连接", "siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。", @@ -2429,6 +2624,7 @@ "validPassword": "有效密码", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "已连接客户端", "resourceBlocked": "资源被阻止", "droppedByRule": "被规则删除", "noSessions": "无会话", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "目标", "editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。", "editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。", + "createInternalResourceDialogHttpConfiguration": "HTTP 配置", + "createInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。", + "editInternalResourceDialogHttpConfiguration": "HTTP 配置", + "editInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。", "maintenancePageMessageDescription": "详细说明维护的消息", "maintenancePageTimeTitle": "预计完成时间(可选)", + "privateMaintenanceScreenTitle": "私有占位符界面", + "privateMaintenanceScreenMessage": "此域名正在私有资源上使用。请连接 Pangolin 客户端以访问此资源。", "maintenanceTime": "例如,2小时,11月1日下午5:00", "maintenanceEstimatedTimeDescription": "您期望维护完成的时间", "editDomain": "编辑域名", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "添加 HTTP 目标", "httpDestEditDescription": "更新此 HTTP 事件流媒体目的地的配置。", "httpDestAddDescription": "配置新的 HTTP 端点来接收您的组织事件。", + "S3DestEditTitle": "编辑目的地", + "S3DestAddTitle": "添加 S3 目的地", + "S3DestEditDescription": "更新此 S3 事件流目的地的配置。", + "S3DestAddDescription": "配置新的 S3 终端以接收您的组织事件。", + "datadogDestEditTitle": "编辑目的地", + "datadogDestAddTitle": "添加 Datadog 目的地", + "datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。", + "datadogDestAddDescription": "配置新的 Datadog 终端以接收您的组织事件。", "httpDestTabSettings": "设置", "httpDestTabHeaders": "信头", "httpDestTabBody": "正文内容", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "目标创建成功", "httpDestUpdateFailed": "更新目标失败", "httpDestCreateFailed": "创建目标失败", + "followRedirects": "遵循重定向", + "followRedirectsDescription": "自动跟踪请求的 HTTP 重定向。", + "alertingErrorWebhookUrl": "请输入有效的 Webhook URL。", + "healthCheckStrategyHttp": "验证连接并检查 HTTP 响应状态。", + "healthCheckStrategyTcp": "只验证 TCP 连接性,不检查响应。", + "healthCheckStrategySnmp": "进行 SNMP get 请求以检查网络设备和基础架构的健康状况。", + "healthCheckStrategyIcmp": "使用 ICMP 回显请求(ping)检查资源是否可达并响应。", + "healthCheckTabStrategy": "策略", + "healthCheckTabConnection": "连接", + "healthCheckTabAdvanced": "高级", + "healthCheckStrategyNotAvailable": "此策略不可用。请联系销售以启用此功能。", + "uptime30d": "正常运行时间(30天)", "idpAddActionCreateNew": "创建新的身份提供者", "idpAddActionImportFromOrg": "从另一个组织导入", "idpImportDialogTitle": "导入身份提供者", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "此操作无法对该组织撤销。", "idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联", "idpUnassociateMenu": "取消关联", - "idpDeleteAllOrgsMenu": "删除" + "idpDeleteAllOrgsMenu": "删除", + "publicIpEndpoint": "终端", + "lastTriggeredAt": "最后触发", + "reject": "拒绝" } From 7a0b7dc17b4cadea8065486f450abf4fa80343c5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:24 -0700 Subject: [PATCH 175/176] New translations en-us.json (Norwegian Bokmal) --- messages/nb-NO.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 913d7ca94..4cf5159d8 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Kontakt salgsavdelingen for å aktivere denne funksjonen.", + "contactSalesBookDemo": "Bestill en demo", + "contactSalesOr": "eller", + "contactSalesContactUs": "kontakt oss", "setupCreate": "Opprett organisasjonen, nettstedet og ressursene", "headerAuthCompatibilityInfo": "Aktiver dette for å tvinge frem en 401 Uautorisert-respons når en autentiseringstoken mangler. Dette kreves for nettlesere eller spesifikke HTTP-biblioteker som ikke sender legitimasjon uten en serverutfordring.", "headerAuthCompatibility": "Utvidet kompatibilitet", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ugyldig eller utgått lisensnøkkel oppdaget. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "dismiss": "Avvis", "subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.", + "trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.", + "trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.", + "trialActive": "Gratis prøveversjon aktiv", + "trialExpired": "Prøveperioden er utløpt", + "trialHasEnded": "Din prøveperiode har avsluttet.", + "trialDaysRemaining": "{count, plural, one {# dag igjen} other {# dager igjen}}", + "trialDaysLeftShort": "{days}d igjen av prøveperioden", + "trialGoToBilling": "Gå til faktureringssiden", "subscriptionViolationViewBilling": "Vis fakturering", "componentsLicenseViolation": "Lisens Brudd: Denne serveren bruker {usedSites} områder som overskrider den lisensierte grenser av {maxSites} områder. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "componentsSupporterMessage": "Takk for at du støtter Pangolin som en {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Organisasjons-ID Mangler", "orgMissingMessage": "Kan ikke regenerere invitasjon uten en organisasjons-ID.", "accessUsersManage": "Administrer brukere", + "accessUserManage": "Administrer brukere", "accessUsersDescription": "Inviter og behandle brukere med tilgang til denne organisasjonen", "accessUsersSearch": "Søk etter brukere...", + "accessUsersRoleFilterCount": "{count, plural, one {# rolle} other {# roller}}", + "accessUsersRoleFilterClear": "Fjern rollesøkefiltre", "accessUserCreate": "Opprett bruker", "accessUserRemove": "Fjern bruker", "username": "Brukernavn", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Vis logger", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", + "search": "Søk…", "searchPlaceholder": "Søk...", "emptySearchOptions": "Ingen valg funnet", "create": "Opprett", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Administrer", "sidebarLogAndAnalytics": "Logg og analyser", "sidebarBluePrints": "Tegninger", + "sidebarAlerting": "Varsling", + "sidebarHealthChecks": "Helsekontroller", "sidebarOrganization": "Organisasjon", "sidebarManagement": "Administrasjon", "sidebarBillingAndLicenses": "Fakturering & lisenser", "sidebarLogsAnalytics": "Analyser", + "alertingTitle": "Varsling", + "alertingDescription": "Definer kilder, triggere og handlinger for varsler", + "alertingRules": "Varslingsregler", + "alertingSearchRules": "Søk i regler…", + "alertingAddRule": "Opprett regel", + "alertingColumnSource": "Kilde", + "alertingColumnTrigger": "Utløser", + "alertingColumnActions": "Handlinger", + "alertingColumnEnabled": "Aktivert", + "alertingDeleteQuestion": "Vennligst bekreft at du vil slette denne varslingsregelen.", + "alertingDeleteRule": "Slett varslingsregel", + "alertingRuleDeleted": "Varslingsregel slettet", + "alertingRuleSaved": "Varslingsregel lagret", + "alertingRuleSavedCreatedDescription": "Din nye varslingsregel ble opprettet. Du kan fortsette å redigere den på denne siden.", + "alertingRuleSavedUpdatedDescription": "Endringene dine i denne varslingsregelen ble lagret.", + "alertingEditRule": "Rediger varslingsregel", + "alertingCreateRule": "Opprett varslingsregel", + "alertingRuleCredenzaDescription": "Velg hva som skal overvåkes, når det skal varsles, og hvordan du vil bli informert", + "alertingRuleNamePlaceholder": "Produksjonsside nede", + "alertingRuleEnabled": "Regel aktivert", + "alertingSectionSource": "Kilde", + "alertingSourceType": "Kildetype", + "alertingSourceSite": "Område", + "alertingSourceHealthCheck": "Helsekontroll", + "alertingPickSites": "Områder", + "alertingPickHealthChecks": "Helsekontroller", + "alertingPickResources": "Ressurser", + "alertingAllSites": "Alle områder", + "alertingAllSitesDescription": "Varsler for alle områder", + "alertingSpecificSites": "Spesifikke områder", + "alertingSpecificSitesDescription": "Velg spesifikke områder for overvåking", + "alertingAllHealthChecks": "Alle helsekontroller", + "alertingAllHealthChecksDescription": "Varsler for alle helsekontroller", + "alertingSpecificHealthChecks": "Spesifikke helsekontroller", + "alertingSpecificHealthChecksDescription": "Velg spesifikke helsekontroller for overvåking", + "alertingAllResources": "Alle ressurser", + "alertingAllResourcesDescription": "Varsler for alle ressurser", + "alertingSpecificResources": "Spesifikke ressurser", + "alertingSpecificResourcesDescription": "Velg spesifikke ressurser for overvåking", + "alertingSelectResources": "Velg ressurser…", + "alertingResourcesSelected": "{count} ressurser valgt", + "alertingResourcesEmpty": "No resources with targets in the first 10 results.", + "alertingSectionTrigger": "Utløser", + "alertingTrigger": "Når skal det varsles", + "alertingTriggerSiteOnline": "Nettsted er online", + "alertingTriggerSiteOffline": "Nettsted er offline", + "alertingTriggerSiteToggle": "Endringer i nettstedstatus", + "alertingTriggerHcHealthy": "Helsekontroll sunn", + "alertingTriggerHcUnhealthy": "Helsekontroll usunn", + "alertingTriggerHcToggle": "Endringer i helsekontrollstatus", + "alertingTriggerResourceHealthy": "Ressurs sunn", + "alertingTriggerResourceUnhealthy": "Ressurs usunn", + "alertingSearchHealthChecks": "Søk i helsekontroller…", + "alertingHealthChecksEmpty": "Ingen tilgjengelige helsekontroller.", + "alertingTriggerResourceToggle": "Endringer i ressursstatus", + "alertingSourceResource": "Ressurs", + "alertingSectionActions": "Handlinger", + "alertingAddAction": "Legg til handling", + "alertingActionNotify": "E-post", + "alertingActionNotifyDescription": "Send e-postvarsler til brukere eller roller", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send en HTTP-forespørsel til et tilpasset endepunkt", + "alertingExternalIntegration": "Ekstern integrasjon", + "alertingExternalPagerDutyDescription": "Send varsler til PagerDuty for hendelseshåndtering", + "alertingExternalOpsgenieDescription": "Rute varsler til Opsgenie for vakt håndtering", + "alertingExternalServiceNowDescription": "Opprett ServiceNow hendelser fra varslingseventer", + "alertingExternalIncidentIoDescription": "Utløs Incident.io arbeidsflyter fra varsels begivenheter", + "alertingActionType": "Handlings type", + "alertingNotifyUsers": "Brukere", + "alertingNotifyRoles": "Roller", + "alertingNotifyEmails": "E-postadresser", + "alertingEmailPlaceholder": "Legg til e-post og trykk Enter", + "alertingWebhookMethod": "HTTP-metode", + "alertingWebhookSecret": "Signeringshemmelig (valgfritt)", + "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", + "alertingWebhookHeaders": "Overskrifter", + "alertingAddHeader": "Legg til header", + "alertingSelectSites": "Velg områder…", + "alertingSitesSelected": "{count} områder valgt", + "alertingSelectHealthChecks": "Velg helsekontroller…", + "alertingHealthChecksSelected": "{count} helsekontroller valgt", + "alertingNoHealthChecks": "Ingen mål med helsekontroller aktivert", + "alertingHealthCheckStub": "Valg av helsekontrollkilde er ikke sluttført ennå - du kan fortsatt konfigurere triggere og handlinger.", + "alertingSelectUsers": "Velg brukere…", + "alertingUsersSelected": "{count} brukere valgt", + "alertingSelectRoles": "Velg roller…", + "alertingRolesSelected": "{count} roller valgt", + "alertingSummarySites": "Områder ({count})", + "alertingSummaryAllSites": "Alle områder", + "alertingSummaryHealthChecks": "Helsekontroller ({count})", + "alertingSummaryAllHealthChecks": "Alle helsekoner", + "alertingSummaryResources": "Ressurser ({count})", + "alertingSummaryAllResources": "Alle ressurser", + "alertingErrorNameRequired": "Skriv inn et navn", + "alertingErrorActionsMin": "Legg til minst én handling", + "alertingErrorPickSites": "Velg minst ett område", + "alertingErrorPickHealthChecks": "Velg minst én helsekontroll", + "alertingErrorPickResources": "Velg minst én ressurs", + "alertingErrorTriggerSite": "Velg en triggetjeneste for nettsted", + "alertingErrorTriggerHealth": "Velg en triggetjeneste for helsekontroll", + "alertingErrorTriggerResource": "Velg en triggetjeneste for ressurs", + "alertingErrorNotifyRecipients": "Velg brukere, roller, eller minst én e-post", + "alertingConfigureSource": "Konfigurer kilde", + "alertingConfigureTrigger": "Konfigurer trigger", + "alertingConfigureActions": "Konfigurer handlinger", + "alertingBackToRules": "Tilbake til regler", + "alertingRuleCooldown": "Nedkjøling (sekunder)", + "alertingRuleCooldownDescription": "Minimum tid mellom gjentatte varsler for samme regel. Sett til 0 for å skyte hver gang.", + "alertingDraftBadge": "Utkast - lagre for å lagre denne regelen", + "alertingSidebarHint": "Klikk på et steg på lerretet for å redigere det her.", + "alertingGraphCanvasTitle": "Regel Flyt", + "alertingGraphCanvasDescription": "Visuell oversikt over kilde, trigger og handlinger. Velg en node for å redigere den i panelet.", + "alertingNodeNotConfigured": "Ikke konfigurert ennå", + "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", + "alertingNodeRoleSource": "Kilde", + "alertingNodeRoleTrigger": "Utløser", + "alertingNodeRoleAction": "Handling", + "alertingTabRules": "Varslingsregler", + "alertingTabHealthChecks": "Helsekontroller", + "alertingRulesBannerTitle": "Bli varslet", + "alertingRulesBannerDescription": "Hver regel binder sammen hva som skal overvåkes (et område, helsekontroll eller ressurs), når det skal varsles (for eksempel offline eller usunn), og hvordan varsle teamet ditt via e-post, webhooks eller integrasjoner. Bruk denne listen for å opprette, aktivere og administrere disse reglene.", + "alertingHealthChecksBannerTitle": "Overvåk helse & ressurser", + "alertingHealthChecksBannerDescription": "Helsekontroller er HTTP- eller TCP-monitorer du definerer én gang. Du kan deretter bruke dem som kilder i varslingsregler slik at du blir varslet når et mål blir sunt eller usunt. Helsekontroller på ressurser vises også her.", + "standaloneHcTableTitle": "Helsekontroller", + "standaloneHcSearchPlaceholder": "Søk i helsekontroller…", + "standaloneHcAddButton": "Opprett helsekontroll", + "standaloneHcCreateTitle": "Opprett helsekontroll", + "standaloneHcEditTitle": "Rediger helsekontroll", + "standaloneHcDescription": "Konfigurer en HTTP- eller TCP-helsekontroll for bruk i varslingsregler.", + "standaloneHcNameLabel": "Navn", + "standaloneHcNamePlaceholder": "Min HTTP-monitor", + "standaloneHcDeleteTitle": "Slett helsekontroll", + "standaloneHcDeleteQuestion": "Vennligst bekreft at du vil slette denne helsekontrollen.", + "standaloneHcDeleted": "Helsekontroll slettet", + "standaloneHcSaved": "Helsekontroll lagret", + "standaloneHcColumnHealth": "Helse", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Mål", + "standaloneHcHealthStateHealthy": "Sunn", + "standaloneHcHealthStateUnhealthy": "Usunn", + "standaloneHcHealthStateUnknown": "Ukjent", + "standaloneHcFilterAnySite": "Alle områder", + "standaloneHcFilterAnyResource": "Alle ressurser", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Helse", + "standaloneHcFilterEnabled": "Aktivert", + "standaloneHcFilterEnabledOn": "Aktivert", + "standaloneHcFilterEnabledOff": "Deaktivert", + "standaloneHcFilterSiteIdFallback": "Område {id}", + "standaloneHcFilterResourceIdFallback": "Ressurs {id}", "blueprints": "Tegninger", "blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer", "blueprintAdd": "Legg til blåkopi", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "Sjekkeintervallet må være minst 5 sekunder", "healthCheckTimeoutMin": "Timeout må være minst 1 sekund", "healthCheckRetryMin": "Forsøk på nytt må være minst 1", + "healthCheckMode": "Sjekk modus", + "healthCheckStrategy": "Strategi", + "healthCheckModeDescription": "TCP-modus verifiserer kun tilkobling. HTTP-modus validerer HTTP-responsen.", + "healthyThreshold": "Sunnhets terskel", + "healthyThresholdDescription": "Suksesser på rad som kreves før man markerer som sunn.", + "unhealthyThreshold": "Usunn terskel", + "unhealthyThresholdDescription": "Feil på rad som kreves før man markerer som usunn.", + "healthCheckHealthyThresholdMin": "Sunnhet terskel må være minst 1", + "healthCheckUnhealthyThresholdMin": "Usunn terskel må være minst 1", "httpMethod": "HTTP-metode", "selectHttpMethod": "Velg HTTP-metode", "domainPickerSubdomainLabel": "Underdomene", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Vert", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Skjema", + "editInternalResourceDialogEnableSsl": "Aktiver SSL", + "editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", "editInternalResourceDialogDestination": "Destinasjon", "editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "editInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Navn", "createInternalResourceDialogSite": "Område", "selectSite": "Velg område...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Vert", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Skjema", + "createInternalResourceDialogScheme": "Skjema", + "createInternalResourceDialogEnableSsl": "Aktiver SSL", + "createInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", "createInternalResourceDialogDestination": "Destinasjon", "createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", + "internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser", + "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "siteConfiguration": "Konfigurasjon", "siteAcceptClientConnections": "Godta klientforbindelser", "siteAcceptClientConnectionsDescription": "Tillat brukere og klienter å få tilgang til ressurser på denne siden. Dette kan endres senere.", @@ -2429,6 +2624,7 @@ "validPassword": "Gyldig passord", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Tilkoblet klient", "resourceBlocked": "Ressurs blokkert", "droppedByRule": "Legg i regelen", "noSessions": "Ingen økter", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Destinasjon", "editInternalResourceDialogDestinationDescription": "Spesifiser destinasjonsadressen for den interne ressursen. Dette kan være et vertsnavn, IP-adresse eller CIDR-sjikt avhengig av valgt modus. Valgfrie oppsett av intern DNS-alias for enklere identifikasjon.", "editInternalResourceDialogPortRestrictionsDescription": "Begrens tilgang til spesifikke TCP/UDP-porter eller tillate/blokkere alle porter.", + "createInternalResourceDialogHttpConfiguration": "HTTP-konfigurasjon", + "createInternalResourceDialogHttpConfigurationDescription": "Velg domenet klienter vil bruke for å nå denne ressursen via HTTP eller HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP-konfigurasjon", + "editInternalResourceDialogHttpConfigurationDescription": "Velg domenet klienter vil bruke for å nå denne ressursen via HTTP eller HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "Vi kommer snart tilbake! Vårt nettsted gjennomgår for øyeblikket planlagt vedlikehold.", "maintenancePageMessageDescription": "Detaljert beskjed som forklarer vedlikeholdet", "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfritt)", + "privateMaintenanceScreenTitle": "Privat plassholder skjerm", + "privateMaintenanceScreenMessage": "Dette domenet brukes på en privatressurs. Koble til ved å bruke Pangolin-klienten for å få tilgang til denne ressursen.", "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", "maintenanceEstimatedTimeDescription": "Når du forventer at vedlikeholdet er ferdigstilt", "editDomain": "Rediger domene", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Legg til HTTP-destinasjon", "httpDestEditDescription": "Oppdater konfigurasjonen for denne HTTP-hendelsesstrømmedestinasjonen.", "httpDestAddDescription": "Konfigurer et nytt HTTP endepunkt for å motta organisasjonens hendelser.", + "S3DestEditTitle": "Rediger destinasjon", + "S3DestAddTitle": "Legg til S3 destinasjon", + "S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.", + "S3DestAddDescription": "Konfigurer et nytt S3-endepunkt for å motta organisasjonens hendelser.", + "datadogDestEditTitle": "Rediger destinasjon", + "datadogDestAddTitle": "Legg til Datadog destinasjon", + "datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.", + "datadogDestAddDescription": "Konfigurer et nytt Datadog-endepunkt for å motta organisasjonens hendelser.", "httpDestTabSettings": "Innstillinger", "httpDestTabHeaders": "Overskrifter", "httpDestTabBody": "Innhold", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Målet er opprettet", "httpDestUpdateFailed": "Kunne ikke oppdatere destinasjon", "httpDestCreateFailed": "Kan ikke opprette mål", + "followRedirects": "Følg videresendinger", + "followRedirectsDescription": "Følg automatisk HTTP-videresendinger for forespørsler.", + "alertingErrorWebhookUrl": "Vennligst skriv inn en gyldig URL for webhooken.", + "healthCheckStrategyHttp": "Validerer tilkobling og sjekker HTTP-responsstatus.", + "healthCheckStrategyTcp": "Bekrefter kun TCP-tilkobling, uten å inspisere responsen.", + "healthCheckStrategySnmp": "Utfører en SNMP get-forespørsel for å sjekke helsen til nettverksenheter og infrastruktur.", + "healthCheckStrategyIcmp": "Bruker ICMP ekko forespørsler (ping) for å sjekke om en ressurs er tilgjengelig og responsiv.", + "healthCheckTabStrategy": "Strategi", + "healthCheckTabConnection": "Tilkobling", + "healthCheckTabAdvanced": "Avansert", + "healthCheckStrategyNotAvailable": "Denne strategien er ikke tilgjengelig. Vennligst kontakt salgsavdelingen for å aktivere denne funksjonen.", + "uptime30d": "Oppetid (30d)", "idpAddActionCreateNew": "Opprett ny identitetsleverandør", "idpAddActionImportFromOrg": "Importer fra en annen organisasjon", "idpImportDialogTitle": "Importer identitetsleverandør", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Dette kan ikke angres for denne organisasjonen.", "idpUnassociatedDescription": "Identitetsleverandør er vellykket frakoblet fra denne organisasjonen", "idpUnassociateMenu": "Frakoble", - "idpDeleteAllOrgsMenu": "Slett" + "idpDeleteAllOrgsMenu": "Slett", + "publicIpEndpoint": "Endepunkt", + "lastTriggeredAt": "Siste utløste", + "reject": "Avvis" } From e178ed12ab855d3cf6f88f1a3ca13c8ddec81249 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 21 Apr 2026 21:35:26 -0700 Subject: [PATCH 176/176] New translations en-us.json (Spanish) --- messages/es-ES.json | 227 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index b370ee7dc..e119adc1b 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contacta ventas para habilitar esta función.", + "contactSalesBookDemo": "Reservar una demostración", + "contactSalesOr": "o", + "contactSalesContactUs": "contáctenos", "setupCreate": "Crear la organización, el sitio y los recursos", "headerAuthCompatibilityInfo": "Habilite esto para forzar una respuesta 401 no autorizada cuando falte un token de autenticación. Esto es necesario para navegadores o bibliotecas HTTP específicas que no envían credenciales sin un desafío del servidor.", "headerAuthCompatibility": "Compatibilidad extendida", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.", "dismiss": "Descartar", "subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.", + "trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.", + "trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.", + "trialActive": "Prueba gratuita activa", + "trialExpired": "Prueba expirada", + "trialHasEnded": "Su prueba ha terminado.", + "trialDaysRemaining": "{count, plural, one {# día restante} other {# días restantes}}", + "trialDaysLeftShort": "Quedan {days}d en la prueba", + "trialGoToBilling": "Ir a la página de facturación", "subscriptionViolationViewBilling": "Ver facturación", "componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.", "componentsSupporterMessage": "¡Gracias por apoyar a Pangolin como {tier}!", @@ -267,8 +279,11 @@ "orgMissing": "Falta el ID de la organización", "orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.", "accessUsersManage": "Administrar usuarios", + "accessUserManage": "Administrar usuario", "accessUsersDescription": "Invitar y administrar usuarios con acceso a esta organización", "accessUsersSearch": "Buscar usuarios...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# roles}}", + "accessUsersRoleFilterClear": "Borrar filtros de rol", "accessUserCreate": "Crear usuario", "accessUserRemove": "Eliminar usuario", "username": "Usuario", @@ -1257,6 +1272,7 @@ "actionViewLogs": "Ver registros", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", + "search": "Buscar…", "searchPlaceholder": "Buscar...", "emptySearchOptions": "No se encontraron opciones", "create": "Crear", @@ -1341,10 +1357,166 @@ "sidebarGeneral": "Gestionar", "sidebarLogAndAnalytics": "Registro y análisis", "sidebarBluePrints": "Planos", + "sidebarAlerting": "Alertas", + "sidebarHealthChecks": "Chequeos de salud", "sidebarOrganization": "Organización", "sidebarManagement": "Gestión", "sidebarBillingAndLicenses": "Facturación y licencias", "sidebarLogsAnalytics": "Analíticas", + "alertingTitle": "Alertas", + "alertingDescription": "Definir fuentes, disparadores y acciones para notificaciones", + "alertingRules": "Reglas de alerta", + "alertingSearchRules": "Buscar reglas…", + "alertingAddRule": "Crear regla", + "alertingColumnSource": "Fuente", + "alertingColumnTrigger": "Disparador", + "alertingColumnActions": "Acciones", + "alertingColumnEnabled": "Activado", + "alertingDeleteQuestion": "Por favor, confirme que desea eliminar esta regla de alerta.", + "alertingDeleteRule": "Eliminar regla de alerta", + "alertingRuleDeleted": "Regla de alerta eliminada", + "alertingRuleSaved": "Regla de alerta guardada", + "alertingRuleSavedCreatedDescription": "Tu nueva regla de alerta fue creada. Puedes seguir editándola en esta página.", + "alertingRuleSavedUpdatedDescription": "Tus cambios a esta regla de alerta fueron guardados.", + "alertingEditRule": "Editar regla de alerta", + "alertingCreateRule": "Crear regla de alerta", + "alertingRuleCredenzaDescription": "Elija qué observar, cuándo disparar y cómo notificar", + "alertingRuleNamePlaceholder": "Sitio de producción caído", + "alertingRuleEnabled": "Regla habilitada", + "alertingSectionSource": "Fuente", + "alertingSourceType": "Tipo de fuente", + "alertingSourceSite": "Sitio", + "alertingSourceHealthCheck": "Chequeo de salud", + "alertingPickSites": "Sitios", + "alertingPickHealthChecks": "Chequeos de salud", + "alertingPickResources": "Recursos", + "alertingAllSites": "Todos los sitios", + "alertingAllSitesDescription": "Las alertas se activan para cualquier sitio", + "alertingSpecificSites": "Sitios específicos", + "alertingSpecificSitesDescription": "Escoja sitios específicos para observar", + "alertingAllHealthChecks": "Todos los chequeos de salud", + "alertingAllHealthChecksDescription": "Las alertas se activan para cualquier chequeo de salud", + "alertingSpecificHealthChecks": "Chequeos de salud específicos", + "alertingSpecificHealthChecksDescription": "Elija chequeos de salud específicos para observar", + "alertingAllResources": "Todos los recursos", + "alertingAllResourcesDescription": "Las alertas se activan para cualquier recurso", + "alertingSpecificResources": "Recursos específicos", + "alertingSpecificResourcesDescription": "Elija recursos específicos para observar", + "alertingSelectResources": "Seleccionar recursos…", + "alertingResourcesSelected": "{count} recursos seleccionados", + "alertingResourcesEmpty": "No hay recursos con objetivos en los primeros 10 resultados.", + "alertingSectionTrigger": "Disparador", + "alertingTrigger": "Cuándo alertar", + "alertingTriggerSiteOnline": "Sitio en línea", + "alertingTriggerSiteOffline": "Sitio fuera de línea", + "alertingTriggerSiteToggle": "El estado del sitio cambia", + "alertingTriggerHcHealthy": "Chequeo de salud saludable", + "alertingTriggerHcUnhealthy": "Chequeo de salud no saludable", + "alertingTriggerHcToggle": "El estado del chequeo de salud cambia", + "alertingTriggerResourceHealthy": "Recurso saludable", + "alertingTriggerResourceUnhealthy": "Recurso no saludable", + "alertingSearchHealthChecks": "Buscar chequeos de salud…", + "alertingHealthChecksEmpty": "No hay chequeos de salud disponibles.", + "alertingTriggerResourceToggle": "El estado del recurso cambia", + "alertingSourceResource": "Recurso", + "alertingSectionActions": "Acciones", + "alertingAddAction": "Añadir acción", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Enviar notificaciones por correo electrónico a usuarios o roles", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Enviar una solicitud HTTP a un punto final personalizado", + "alertingExternalIntegration": "Integración externa", + "alertingExternalPagerDutyDescription": "Enviar alertas a PagerDuty para gestión de incidentes", + "alertingExternalOpsgenieDescription": "Dirigir alertas a Opsgenie para gestión de llamadas", + "alertingExternalServiceNowDescription": "Crear incidentes de ServiceNow a partir de eventos de alerta", + "alertingExternalIncidentIoDescription": "Activar flujos de trabajo de Incident.io a partir de eventos de alerta", + "alertingActionType": "Tipo de acción", + "alertingNotifyUsers": "Usuarios", + "alertingNotifyRoles": "Roles", + "alertingNotifyEmails": "Direcciones de correo electrónico", + "alertingEmailPlaceholder": "Añadir email y presionar Enter", + "alertingWebhookMethod": "Método HTTP", + "alertingWebhookSecret": "Firma secreta (opcional)", + "alertingWebhookSecretPlaceholder": "Secreto HMAC", + "alertingWebhookHeaders": "Encabezados", + "alertingAddHeader": "Añadir encabezado", + "alertingSelectSites": "Seleccionar sitios…", + "alertingSitesSelected": "{count} sitios seleccionados", + "alertingSelectHealthChecks": "Seleccionar chequeos de salud…", + "alertingHealthChecksSelected": "{count} chequeos de salud seleccionados", + "alertingNoHealthChecks": "No hay objetivos con chequeos de salud habilitados", + "alertingHealthCheckStub": "La selección de chequeo de salud no está conectada aún - todavía puede configurar disparadores y acciones.", + "alertingSelectUsers": "Seleccionar usuarios…", + "alertingUsersSelected": "{count} usuarios seleccionados", + "alertingSelectRoles": "Seleccionar roles…", + "alertingRolesSelected": "{count} roles seleccionados", + "alertingSummarySites": "Sitios ({count})", + "alertingSummaryAllSites": "Todos los sitios", + "alertingSummaryHealthChecks": "Chequeos de salud ({count})", + "alertingSummaryAllHealthChecks": "Todos los chequeos de salud", + "alertingSummaryResources": "Recursos ({count})", + "alertingSummaryAllResources": "Todos los recursos", + "alertingErrorNameRequired": "Introduce un nombre", + "alertingErrorActionsMin": "Añada al menos una acción", + "alertingErrorPickSites": "Seleccione al menos un sitio", + "alertingErrorPickHealthChecks": "Seleccione al menos un chequeo de salud", + "alertingErrorPickResources": "Seleccione al menos un recurso", + "alertingErrorTriggerSite": "Elija un disparador de sitio", + "alertingErrorTriggerHealth": "Elija un disparador de chequeo de salud", + "alertingErrorTriggerResource": "Elija un disparador de recurso", + "alertingErrorNotifyRecipients": "Elija usuarios, roles o al menos un correo electrónico", + "alertingConfigureSource": "Configurar fuente", + "alertingConfigureTrigger": "Configurar disparador", + "alertingConfigureActions": "Configurar acciones", + "alertingBackToRules": "Volver a las reglas", + "alertingRuleCooldown": "Tiempo de espera (segundos)", + "alertingRuleCooldownDescription": "Tiempo mínimo entre alertas repetidas para la misma regla. Establezca en 0 para disparar cada vez.", + "alertingDraftBadge": "Borrador - guardarlo para almacenar esta regla", + "alertingSidebarHint": "Haga clic en un paso en el lienzo para editarlo aquí.", + "alertingGraphCanvasTitle": "Flujo de regla", + "alertingGraphCanvasDescription": "Visión general visual de fuente, disparador y acciones. Selecciona un nodo para editarlo en el panel.", + "alertingNodeNotConfigured": "Aún no configurado", + "alertingNodeActionsCount": "{count, plural, one {# acción} other {# acciones}}", + "alertingNodeRoleSource": "Fuente", + "alertingNodeRoleTrigger": "Disparador", + "alertingNodeRoleAction": "Acción", + "alertingTabRules": "Reglas de Alerta", + "alertingTabHealthChecks": "Chequeos de salud", + "alertingRulesBannerTitle": "Obtenga notificaciones", + "alertingRulesBannerDescription": "Cada regla vincula lo que se debe observar (un sitio, chequeo de salud o recurso), cuándo disparar (por ejemplo, fuera de línea o no saludable), y cómo notificar a su equipo vía email, webhooks o integraciones. Use esta lista para crear, habilitar y administrar esas reglas.", + "alertingHealthChecksBannerTitle": "Monitorear Salud y Recursos", + "alertingHealthChecksBannerDescription": "Los chequeos de salud son monitores HTTP o TCP que define una vez. Luego puede usarlos como fuentes en reglas de alerta para que se le notifique cuando un objetivo se vuelva saludable o no saludable. Los chequeos de salud en recursos también aparecen aquí.", + "standaloneHcTableTitle": "Chequeos de salud", + "standaloneHcSearchPlaceholder": "Buscar chequeos de salud…", + "standaloneHcAddButton": "Crear chequeo de salud", + "standaloneHcCreateTitle": "Crear chequeo de salud", + "standaloneHcEditTitle": "Editar chequeo de salud", + "standaloneHcDescription": "Configurar un chequeo de salud HTTP o TCP para usar en reglas de alerta.", + "standaloneHcNameLabel": "Nombre", + "standaloneHcNamePlaceholder": "Mi monitor HTTP", + "standaloneHcDeleteTitle": "Eliminar chequeo de salud", + "standaloneHcDeleteQuestion": "Por favor, confirme que desea eliminar este chequeo de salud.", + "standaloneHcDeleted": "Chequeo de salud eliminado", + "standaloneHcSaved": "Chequeo de salud guardado", + "standaloneHcColumnHealth": "Salud", + "standaloneHcColumnMode": "Modo", + "standaloneHcColumnTarget": "Destino", + "standaloneHcHealthStateHealthy": "Saludable", + "standaloneHcHealthStateUnhealthy": "No saludable", + "standaloneHcHealthStateUnknown": "Desconocido", + "standaloneHcFilterAnySite": "Todos los sitios", + "standaloneHcFilterAnyResource": "Todos los recursos", + "standaloneHcFilterMode": "Modo", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Salud", + "standaloneHcFilterEnabled": "Activado", + "standaloneHcFilterEnabledOn": "Activado", + "standaloneHcFilterEnabledOff": "Deshabilitado", + "standaloneHcFilterSiteIdFallback": "Sitio {id}", + "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Planos", "blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores", "blueprintAdd": "Añadir plano", @@ -1763,6 +1935,15 @@ "healthCheckIntervalMin": "El intervalo de comprobación debe ser de al menos 5 segundos", "healthCheckTimeoutMin": "El tiempo de espera debe ser de al menos 1 segundo", "healthCheckRetryMin": "Los intentos de reintento deben ser de al menos 1", + "healthCheckMode": "Modo de chequeo", + "healthCheckStrategy": "Estrategia", + "healthCheckModeDescription": "El modo TCP verifica solo la conectividad. El modo HTTP valida la respuesta HTTP.", + "healthyThreshold": "Umbral Saludable", + "healthyThresholdDescription": "Éxitos consecutivos requeridos antes de marcar como saludable.", + "unhealthyThreshold": "Umbral No Saludable", + "unhealthyThresholdDescription": "Fallos consecutivos requeridos antes de marcar como no saludable.", + "healthCheckHealthyThresholdMin": "El umbral saludable debe ser al menos 1", + "healthCheckUnhealthyThresholdMin": "El umbral no saludable debe ser al menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Seleccionar método HTTP", "domainPickerSubdomainLabel": "Subdominio", @@ -1822,6 +2003,11 @@ "editInternalResourceDialogModePort": "Puerto", "editInternalResourceDialogModeHost": "Anfitrión", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Esquema", + "editInternalResourceDialogEnableSsl": "Activar SSL", + "editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "editInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", @@ -1837,6 +2023,7 @@ "createInternalResourceDialogName": "Nombre", "createInternalResourceDialogSite": "Sitio", "selectSite": "Seleccionar sitio...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}", "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -1865,11 +2052,19 @@ "createInternalResourceDialogModePort": "Puerto", "createInternalResourceDialogModeHost": "Anfitrión", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Esquema", + "createInternalResourceDialogScheme": "Esquema", + "createInternalResourceDialogEnableSsl": "Activar SSL", + "createInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", + "internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP", + "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "siteConfiguration": "Configuración", "siteAcceptClientConnections": "Aceptar conexiones de clientes", "siteAcceptClientConnectionsDescription": "Permitir a los dispositivos de usuario y clientes acceder a los recursos de este sitio. Esto se puede cambiar más tarde.", @@ -2429,6 +2624,7 @@ "validPassword": "Contraseña válida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente conectado", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Soltado por regla", "noSessions": "No hay sesiones", @@ -2668,6 +2864,10 @@ "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique la dirección de destino para el recurso interno. Puede ser un nombre de host, dirección IP o rango CIDR dependiendo del modo seleccionado. Opcionalmente establezca un alias DNS interno para una identificación más fácil.", "editInternalResourceDialogPortRestrictionsDescription": "Restringir el acceso a puertos TCP/UDP específicos o permitir/bloquear todos los puertos.", + "createInternalResourceDialogHttpConfiguration": "Configuración HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Elija el dominio que los clientes usarán para alcanzar este recurso a través de HTTP o HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuración HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Elija el dominio que los clientes usarán para alcanzar este recurso a través de HTTP o HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2706,6 +2906,8 @@ "maintenancePageMessagePlaceholder": "¡Volveremos pronto! Nuestro sitio está actualmente en mantenimiento programado.", "maintenancePageMessageDescription": "Mensaje detallado explicando el mantenimiento", "maintenancePageTimeTitle": "Tiempo estimado de finalización (Opcional)", + "privateMaintenanceScreenTitle": "Pantalla de marcador de posición privada", + "privateMaintenanceScreenMessage": "Este dominio se está utilizando en un recurso privado. Conéctese usando el cliente Pangolin para acceder a este recurso.", "maintenanceTime": "Ej., 2 horas, 1 de noviembre a las 5:00 PM", "maintenanceEstimatedTimeDescription": "Cuando espera que el mantenimiento esté terminado", "editDomain": "Editar dominio", @@ -2843,6 +3045,14 @@ "httpDestAddTitle": "Añadir destino HTTP", "httpDestEditDescription": "Actualizar la configuración para este destino de transmisión de eventos HTTP.", "httpDestAddDescription": "Configure un nuevo extremo HTTP para recibir los eventos de su organización.", + "S3DestEditTitle": "Editar destino", + "S3DestAddTitle": "Añadir destino S3", + "S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.", + "S3DestAddDescription": "Configure un nuevo punto final S3 para recibir los eventos de su organización.", + "datadogDestEditTitle": "Editar destino", + "datadogDestAddTitle": "Añadir destino Datadog", + "datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.", + "datadogDestAddDescription": "Configure un nuevo punto final de Datadog para recibir los eventos de su organización.", "httpDestTabSettings": "Ajustes", "httpDestTabHeaders": "Encabezados", "httpDestTabBody": "Cuerpo", @@ -2901,6 +3111,18 @@ "httpDestCreatedSuccess": "Destino creado correctamente", "httpDestUpdateFailed": "Error al actualizar destino", "httpDestCreateFailed": "Error al crear el destino", + "followRedirects": "Seguir redirecciones", + "followRedirectsDescription": "Seguir automáticamente las redirecciones HTTP para solicitudes.", + "alertingErrorWebhookUrl": "Por favor, introduzca una URL válida para el webhook.", + "healthCheckStrategyHttp": "Valida la conectividad y verifica el estado de respuesta HTTP.", + "healthCheckStrategyTcp": "Verifica la conectividad TCP solamente, sin inspeccionar la respuesta.", + "healthCheckStrategySnmp": "Realiza una solicitud SNMP get para verificar la salud de dispositivos y la infraestructura de red.", + "healthCheckStrategyIcmp": "Usa solicitudes de eco ICMP (pings) para verificar si un recurso es alcanzable y receptivo.", + "healthCheckTabStrategy": "Estrategia", + "healthCheckTabConnection": "Conexión", + "healthCheckTabAdvanced": "Avanzado", + "healthCheckStrategyNotAvailable": "Esta estrategia no está disponible. Contacte ventas para habilitar esta funcionalidad.", + "uptime30d": "Tiempo de actividad (30d)", "idpAddActionCreateNew": "Crear nuevo proveedor de identidad", "idpAddActionImportFromOrg": "Importar de otra organización", "idpImportDialogTitle": "Importar Proveedor de Identidad", @@ -2917,5 +3139,8 @@ "idpUnassociateWarning": "Esto no se puede deshacer para esta organización.", "idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito", "idpUnassociateMenu": "Desasociar", - "idpDeleteAllOrgsMenu": "Eliminar" + "idpDeleteAllOrgsMenu": "Eliminar", + "publicIpEndpoint": "Punto final", + "lastTriggeredAt": "Último disparo", + "reject": "Rechazar" }