From 102a2354075345c4c3b62ec4b64eb2e018be5e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Mar 2026 20:54:38 -0700 Subject: [PATCH 001/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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/105] 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 02033f611f102bd1dfd1bab99b82b1cacea2a1c3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 23 Mar 2026 11:44:02 -0700 Subject: [PATCH 013/105] 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 2841c5ed4e867a34e8a23ac7700834dfb2a74845 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 29 Mar 2026 14:19:26 -0700 Subject: [PATCH 014/105] 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 015/105] 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 1efd2af44b7122c6fa7846559b557aa49e9d3397 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 15:32:25 -0400 Subject: [PATCH 016/105] 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 017/105] 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 018/105] 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 019/105] 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 020/105] 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 021/105] 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 022/105] 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 023/105] 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 024/105] 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 025/105] 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 026/105] 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 027/105] 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 028/105] 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 029/105] 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 030/105] 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 031/105] 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 032/105] 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 033/105] 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 034/105] 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 035/105] 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 036/105] 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 037/105] 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 039/105] 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 040/105] 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 041/105] 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 042/105] 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 043/105] 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 044/105] 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 045/105] 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 046/105] 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 047/105] 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 048/105] 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 049/105] 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 050/105] 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 051/105] 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 052/105] 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 053/105] 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 054/105] 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 055/105] 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 056/105] 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 059/105] 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 060/105] 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 061/105] 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 062/105] 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 063/105] 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 064/105] 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 065/105] 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 066/105] 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 067/105] 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 068/105] 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 069/105] =?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 070/105] 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 071/105] 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 072/105] 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 073/105] 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 074/105] 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 075/105] 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 076/105] 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 077/105] 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 078/105] 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 079/105] 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 080/105] 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 081/105] 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 2e8d1701147b4f81f01ccb7890c3320ada918f33 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:05:12 -0700 Subject: [PATCH 082/105] 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 083/105] 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 5a090620707646997d90a0a1780ef9de7e639579 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 17:19:44 -0700 Subject: [PATCH 084/105] 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 085/105] 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 086/105] 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 1a36475afabb3fb53eaee06e9ba183137d36bb6f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 18:02:14 -0700 Subject: [PATCH 087/105] Add inline creation --- server/lib/blueprints/proxyResources.ts | 1 + .../routers/alertRule/listAlertRules.ts | 74 ++++- .../resources/proxy/[niceId]/general/page.tsx | 21 +- .../settings/sites/[niceId]/general/page.tsx | 21 +- src/components/UptimeAlertSection.tsx | 300 ++++++++++++++++++ src/lib/queries.ts | 22 ++ 6 files changed, 406 insertions(+), 33 deletions(-) create mode 100644 src/components/UptimeAlertSection.tsx diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 72bcda76f..237238910 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -142,6 +142,7 @@ export async function updateProxyResources( .values({ name: `${targetData.hostname}:${targetData.port}`, targetId: newTarget.targetId, + orgId: orgId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 7a6c11766..9d40817fb 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 { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -39,7 +39,17 @@ const querySchema = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.number().int().nonnegative()) + .pipe(z.number().int().nonnegative()), + siteId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + resourceId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()) }); export type ListAlertRulesResponse = { @@ -102,12 +112,66 @@ export async function listAlertRules( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, siteId, resourceId } = 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 + }); + } + } + + // 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 whereClause = and( + eq(alertRules.orgId, orgId), + siteFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, siteFilterRuleIds) + : undefined, + resourceFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + : undefined + ); const list = await db .select() .from(alertRules) - .where(eq(alertRules.orgId, orgId)) + .where(whereClause) .orderBy(sql`${alertRules.createdAt} DESC`) .limit(limit) .offset(offset); @@ -115,7 +179,7 @@ export async function listAlertRules( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(alertRules) - .where(eq(alertRules.orgId, orgId)); + .where(whereClause); // Batch-fetch site and health-check associations for all returned rules // in two queries rather than N+1 individual lookups. 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 5f47e1938..be94fb10a 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -62,7 +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"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -579,19 +579,12 @@ export default function GeneralForm() { return ( <> - - - Uptime - - Site availability over the last 90 days. - - - - {resource?.resourceId && ( - - )} - - + {resource?.resourceId && resource?.orgId && ( + + )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 3527e41cb..8797455ef 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeBar from "@app/components/UptimeBar"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -113,19 +113,12 @@ export default function GeneralPage() { return ( - - - Uptime - - Site availability over the last 90 days. - - - - {site?.siteId && ( - - )} - - + {site?.siteId && site?.orgId && ( + + )} diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx new file mode 100644 index 000000000..c7f0cc184 --- /dev/null +++ b/src/components/UptimeAlertSection.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { BellPlus, BellRing } from "lucide-react"; +import { + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody +} from "@app/components/Settings"; +import UptimeBar from "@app/components/UptimeBar"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +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"; + +interface UptimeAlertSectionProps { + orgId: string; + siteId?: number; + resourceId?: number; + days?: number; +} + +export default function UptimeAlertSection({ + orgId, + siteId, + resourceId, + days = 90 +}: UptimeAlertSectionProps) { + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + + const [open, setOpen] = useState(false); + const [name, setName] = useState("Uptime Alert"); + const [userTags, setUserTags] = useState([]); + const [roleTags, setRoleTags] = useState([]); + const [emailTags, setEmailTags] = useState([]); + const [activeUserTagIndex, setActiveUserTagIndex] = useState( + null + ); + const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( + null + ); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + const [loading, setLoading] = useState(false); + + const { data: alertRules, isLoading: alertRulesLoading } = useQuery( + orgQueries.alertRulesForSource({ orgId, siteId, resourceId }) + ); + + 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 hasRules = (alertRules?.length ?? 0) > 0; + + async function handleSubmit() { + if ( + userTags.length === 0 && + roleTags.length === 0 && + emailTags.length === 0 + ) { + toast({ + variant: "destructive", + title: "No recipients", + description: + "Please add at least one user, role, or email to notify." + }); + return; + } + + setLoading(true); + try { + await api.put(`/org/${orgId}/alert-rule`, { + name, + eventType: siteId ? "site_toggle" : "resource_toggle", + enabled: true, + cooldownSeconds: 300, + siteIds: siteId ? [siteId] : [], + healthCheckIds: [], + resourceIds: resourceId ? [resourceId] : [], + userIds: userTags.map((tag) => tag.id), + roleIds: roleTags.map((tag) => Number(tag.id)), + emails: emailTags.map((tag) => tag.text), + webhookActions: [] + }); + + toast({ + title: "Alert created", + description: + "You will be notified when this changes status." + }); + + setOpen(false); + setName("Uptime Alert"); + setUserTags([]); + setRoleTags([]); + setEmailTags([]); + + queryClient.invalidateQueries({ + queryKey: orgQueries.alertRulesForSource({ + orgId, + siteId, + resourceId + }).queryKey + }); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to create alert", + description: formatAxiosError(e, "An error occurred.") + }); + } + setLoading(false); + } + + const alertButton = alertRulesLoading ? null : hasRules ? ( + + ) : ( + + ); + + 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 088/105] 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 089/105] 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 090/105] 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 091/105] 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 092/105] 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 ? ( ) : ( - From ed327626bb882157b67f81736b77c2084fc86c4c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 09:47:20 -0700 Subject: [PATCH 096/105] 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 206b3a7d22a113421d746fcc67d3447d1a68ba0c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Apr 2026 11:52:15 -0700 Subject: [PATCH 097/105] 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 zcmbW3`#+T19>>@7%nZilTH0vTR1~>vxl?!)34^IjQDWM1DWnsLt!W;FE*wshOBb`N z!%3nrgfx%X5~l2SB=%(Fx=D@u#9Yq!7xsF6KCkb3y+5zDUf=c0TI(v!$6ZBPR~Z1H z;<4ZLAONs@3IipaytI_)x64Zujq2wnlgXqDv(oul$>gZy?@`I%C$U^TT`N6Z;tyRb z9o*#(?#dhPGFH9Jm8)u*Q}TmTvRqU0qpVO=R=C8@6cuMK$(4sLUXNY87Q29k&Bq7K zpAVRi^q!0Ko>mrCWB!;(NHd_$BmD^Rwdn?bZXvYsAa)N$I+6+Pm>N zpJjS=eEY^fmuvJNQaWds99>i!>Kl%*D&+nClcAlzobAhcF=t8bsk%OR$42eab))_^ znLSUUn@eVCn4j<#e|}?PzH=^C>@vQ+@Yc3P@u+R#&DgJzV#7Z@0n=r(T|J(?CI`H% zn{A<*K=Y09P9kOrhtz#x7ME9%^;5KXPw4H}oheyE zRns9i;TrTHx?Y%nl>5H*DCYFo#qG!{pefU!pqhn4TF0T&ct5lk_LEa0BB%Z9YDixL zQ3K$fN(IagOmjO3WfKLQlub`w5D^`_0}J+-9;}%s=UAM*c57zKQY#gV0X@vOOB^41 z9^8UWphDa#T8lbic!sJujVH zFs;pA=rtfW`b*r5u^Xz|?TM-ijgP%@Kn9>iz#3_yB%zfvb%U{YlF%|_)n3@Ddtp;( zaMX|1)UVfYd^q6KK@!>-jWkJE>FRK(DYTT9ugUYs%gK7(7h7o~Q0IR1gblO+^LtQ* zmG2e`DMOB(go5<}Lj-Kp>1nU&tsoHi4VT|b@-zd>@$}$f-2-jLfVdvEF}sl`&n`J! zis`Z2<^7_#tg<>LXIclrab@nW)c z#OB1gxdI#THb~3pB4OfOGp}uz$PwLVvs;-=hVrAOpB7YMY z!3h)DTpxDVUM%0e`g0P)Z2LgL+f#F=deRcIQZ*VX=~PTHeA`j*YuI5(WITx(m4ja+ z{Go}v0mkS-q-5jp{yUjH?PgHI2~Qkbi`-HQ9s&Y;9fRPnOaq>fGGc@os2d@WA%Tin zv>6f%S95e78|1*dahnNtxOuhlz24w0VFKu>%vv)_ae8nb90yv233pVH?f=6))jkOe zwPrU&4%>DhgDKs_GT=_t5$$@7<|X?i8{+6_2V%;$pusc+Ptbu7F){@*#2J8C6wfeX z`}^)J!hMBro{Xeb4k%bDiZ-J57+oH2>?WJrpTF`4nT^lvP1&t<+8Nuyc06H;@h>k7 z|GBf|z4ky?7&;z6r&sryHkWth){XHFY_;R*2$&1m0r!;(bofUiGW?4YXBpjiTlhi` zxAz)KHThyr|4|&2S=7&bIvzAaaKmbfuoBOWPy-M@ zp>hlV8?yTzCnb6~^$IewVY#D|bHFK&z) z^Hp~1o}dij(TqLrGS|MwN!xNVY17K@IXRz*|Gm&Ax^Gii8#$JCrPU^p&gYD$tc|pI z-0%cvP}^QU>6EmI^2DLKY$ZyE@+>wC5M$tPKYKVe_|4;Yf2_$ts_Ft>-EY{(JZl_B z8?cMOe^3m*?n#+m2^^ZaHnU{0|7vmeK;Qvcy;k6t{H^hGlUj$H!(mU!rN16M3bOcq z(X?XZNUBUP4s2S_Z-)IH-JHHHQAZ*kq5%@zL zUbh{96-a!f7D7bRnj5Y!%Wp(0w7yU;R{@=QXAU1!KnW;$E9C^xmYHm&90zvr&2YRv z3Cyt|KI1)5a#GIQ!w=I1F0$fw2XEjnqjUAUkYY0%Kg3hKFDp5)5*$=nEHt z@VoQ_wzq9rgg-n8oP}z&}Y$F^yS5j>m8KVc%xmVN^l7O~UBOQ4z?^dc&Wc~U! z0Ug>@TGDt3T;_DJ$YIkni%_?tu2kdHqOA)0fF=E-(3+nGQ=)j{kgQ~;hz}-u_pv^F xj!}zIMY}KE_(2j1U;eswyX2qzHRNlm7B$u7IB?BmGJxVG&GsjP&MarTvU_U6nx zoPCa)-|w%#-k(1|pU30#`aIsR&-?XEGB-8kJt1;}g@uLJ*yxr83k%!7B^%3e&VS_~ zWR&)=9CN#Q?{(d(L_z(s_%C{wArz0C# z5+~^o1pj-zv?{55mL*s@Ai&Y!f`LUBnz0JfKmY?{LYT}F1g!+Uw!9})euiG+20*95 zxaRnYd`N-fGKad~*-H)@?}pBYMWxPZPX#vtu5#4T5R&1(FP*@f=U`e5DOKfJ=oquMz{ zhnM_#F)I>w0YzzSp@!=u8xVYEp}H41sOs;zuVW5#kj~A+$X9g;so?vNJu;)hY9o*P ztzZkOH*_K$fp$n{>JWhgu4fY-Ni*R3!E{JmY;eSxtEZ!vaR;3zt(~iD06%af`b?vH zJo2)r<51rAd>br8?DBpD2GRXzT^ZMR?BJ*x5vO8mhSGbQ12zK1XdLDgK+(9PsQ!c) zG86CvR6|i3i0!U^e-6s$TENNyC>-3;Xwx(a-plK?fwhi|8rxS`nK2yEl86`JnAt+* z6GnrGeMXC9=ch&oKBqWb^1$yO0=+_g4~{YHqeC@5I29>PmNrnvOjDaZ55p+>N4d4W zk&@+e^It#EZq;s!zM!9e8g$YwV$w3f08f*t$f*6z=S|yuMO{SXb#}&9T?rqEeI@v2+YLN|zuI!r zU9V@#L}}q?Tkr>!%<44i!T>YV&2{9yCw*LVYz~p--*-sPKv>W^a7P1m7q=I+O0Q&N zfjyjlBU8VHtr2RS@_y>`^g2wq5U!hp>}FUBae(EmwL$Y7`#PukjfqLv3E1yZ_Z;%x z#`KDDUDRH|8{tldr`;t^u?Nfq2;_ch|NOz;d8<~%Sb{*yYg-Nh+3)1NtkY*2$o%KG zb11f8kJ_KWv@hCMUYxg7BoAEwcE*O)?a#)Du#zowxP?cEbA)|k?bw5vvCx!C>!$PD z-uP9F&j8SGXufQR6}1kL5Dcj`%i)-055i+HV1odqbjR|jt?wC~%f!p!?X``j$(9wu z((mgMEywsA=u!>DCY}eAl6TIFdG3CfXz?#?oXx-~e=mL3^78xkN!Zm2Z^yUNMH`{e z3HCf*r*Rp$cCDY7z=i9>uzU=x9>0ZSi${J+T^h~74@6xlFeDT}%e=6Ne`??i8y}6C zf(z+27z;|iXiByNqf(w;8*s$P=GRig>aF@4VSTpmEOuFggl7VGdpmo7yqgH28x{fC z(ytIp_DMMrZx8A7h&QTgp!?Wn0s>=fkS+Htjh&=X-5D%AW2gk=mSR6CabH!DD~YWi=x7D!o;4s~P`}oKChM+znma?^1<|FDv0xrrg_Hthph(^aOR- zrIo&25!Y@;knCtWxDktzPoEHGEq%ZPT%aU)=&1Bd?vKmtQPkW$6&pn=aHlJFt{ zdm5k~GoD`l1ILy?+Ah^LeS3j>o@;)ix{3Ki(XHb@C4E&RKm~pMsI+l#P`Ziwn=ItoHpK>s{!H z_~1FU44qX!RrAK#b6#!K}N;`Gu4X7M5_R zyOaLp)h?H%vvmC4L6N0A#rV7;x@ClV?!h-%-K~3zD#Tx2VY34?m{5&qw8cmHPdk5! z^F+G;rN7DjDoXyjbUnYyFlCzmk9gW}f2i|{-rqZ)pz+5uDo>P%bY0KbU-gXOj?EqB zT)!{Ml}*_JCTc#KJk5RV^t;I)M&mLQ$h%(s$YmYvaADOGHM;i!=-|bfNxb(4HSR3w zynS8O;&vO0d^9n7Twa;;bdPs8qoWG+;C6Zc&C8epjicSV<=1<+v}*R@fYR;n+y>Ko zP#+YrbN(^fU=!W^1vF3^k$1U4d3j+`fp9LLPC(ah_<{~Vp`j(uLoLtjJ)tucCIeEbAkHNgp;BwWu_P2}!8X|BWlhAf|fZ9KI zRdrB((*aT{7smI#3$^S(KSqPGsn@;Sv8(H~!zbiu_DIAe(-WRF_OHxCgVkFC`0ID( zG^%lmHBInf8|*hXLW&DxltBoU!RSNg8}V z!l?06%1nneCm6Fa&C@>;eSnPeQVqtk!LNs=@#Oh#3$mMG-L~*CCs+30zeZF~RI6SI z8F(c}6)}l0EaTa(23I0!NLCWBu>;;pl47}=gPH2#c+J_@FN{t=?Uw@F1mvueUsOTGhNFG^+KS;C-gw#8T_SGfL{zxxLRjR> zhn;H|k?AIr)d6PrR_E=@-t4b~pCA<)0Xfwy39AF9_ihA~u{|zHb9(Hyz3qKI;ABoc zcKraCwf>2dZ<``xVdloEw(06ymS9l5Dqb3qe!e9~!?6RN$6M(Rm8JG!cJ9G)U0HgMq_+9N`HFZv1VkN%=PlncqY~6} z>#Ka&sR^`d5KGcsD1CZDGR%K0*UGKMWg5H+c2qXEBYr>u?8OS50Zv#CuJPAf6o90J z6;A#*z*5rYnEL3WI{lt1EdYTY$)qhFtzw3e%Ddjq9tSwsY4h{{|qPpp)QJQ8O%O1da@JBTNb;NP4J=~|UU~t)EK2&{EkZ~D_c!D6dRuZ81C4^ zS5G|!JYmS9q5w@6pi;p07c75v#%ra)F!CU0|RY8vFO-Q?JLi zh5;P}ExygGoE{(wusshvJPK((=qNGlwrG)MYeYY@2e)AtNQCUc7-@p`lC0yaL-8qm z-q+CG9uhL*;I;&D;JxV5_ztyk*UNEM;TnwIVfAX(!*E@4A-%ZBiqz2Xrx{Vb5Pm7p z+0Axu20dvq4|J*G6=weg1q3rR8!u&(&R%~%DFbTw$ox`cmb7cXIdhsYKH(ZJ>JyRW zzy7w)A;fR1-)t5@Js7&yx3&>h5<`s?I~|09(3c zN=(4gsJyq|FPMLa`PXezbgXI9bcbeLKl{l3D5XV)O8>>+Zv|<&HP0{GFRij4D@th2 z_{zi^Iuani-D);bBoGi71o?C$jT*8m;QWnhwhI#CyMpu33Qi{IIdTK6RBJZ+{ioA0 zS*wM>+%494D*e9x9KPD(k(k7YD;{eYNM_l!&A zjYEh&!d3@()+M~O%ga6BB`0;@o@z=y0*>!6ny;H8EnVF7y}gRgX_g+SU~RGWHFdgQ#OvYM1Yhsr= z1zb{-(b1i&903)PPM<3DHiDOF_VU>Xg$2dIc_BogkvM1{)3p2uat*Li{_xS2LN@zC zPT75gL;^fgTu?aLXwN{hsua3uf4GpiY8g@^!5-w+kkhq?JMD+!b$f6BWDcT~T&2U% zt3bmxV1mP701&d`16x7NLqqp2r>Myty1bp-=lp;Q+`9i6-v3CdI_;M!z7B{$8ss5bQek>_=+${XuQo&U*m;QdykRnEn3yg_d*Ml%6bjX|8{8(Z)L=F%wLI*yU8hr+ci~J zb)YY%|2sdxQ`u!Gk$`?#vs(GW<` z;?jw3nS$M$@dEk@W>Tq4$Eu4j8Nq0EW$A0xt{9)OC;(poNUABoR# z02iCm(l74qBwK7yPD4T6N9k1aJkPki^FocVKM_N(tfU5*tyZ3?ULJE>TAtozb#JOBUM-0wv-9j8hw~>;1AufLRwjXE zR=yp$`4le^5j^`I2q|AkBJL+rbN3BCcj%Dl%TIM2DZ9TpMO*xfKH}yb(qd6Zw@k{D zGpLjV_eiZOvkPqU3H9O8I|mfX#ZS~r$6t=0=hCm*cvE@l-_59yF?kcD2wz`@e(uMM z@|zw(v}x|ii|U%xRBmGG zUkCSKQOZYtj7A^Y6hzNtrI(rwjvjXidwiGJ$H^wL-QpNo6lyhEj#!rPhb+|Gz)hcEO))IXRg7j33 zvQMP$Y%Z^Oz^rLk-2!THTaBh&+(RH|=?;xso3CYk0;Y6EZ;9fQl7rd!$v-$V{&eFV z<3kc>3ktYC;l21FZaKxsTXug<>KA-x8|D7TclApE-V>Pxs_)+644VysjK)c@(7o&_KDa=}HIrLK!Ooz#=3wM?4XJPQAOx~U8HXUE6HSKs> ziE>uJ?L%2db0nJ%xdh>L2d?K6ag9Z%%5G59Yhd28#d<_f@k?!+g6Y-iw`sZ?gp@UD z-!V`GQ?hhZ@Q#4Ux{acz#%-MpfkND>QI$~M;$KCn;~zJ^3|_$7@c3(^a>wviB=V`4G_&qk=iT@J8(a<^5svX4Q#!T=5#iLa-SUtLlqn;u}h80DJaCfbcni1#{G zA+dIQ3)RVT@Au_sZ2?;e)^68N*y#os6wDEz5C=Ya5wJkppDMz_&N(j>x+UhRWt!K* zGf8~C{Tqhw5m&O%mkwCV!izsoHckb=t9=P~oV9-QZ>;cUETf}H5W|JNVsc1!(j=Ph zpd-T?NKT#T0QMf+xqO|UY^xf?!28n@#PK3S1HuDlRn&R!03(BiKK{S&r!D+a7b^Jt zZm(Cy2=JcIh_Sty+~~!gqvm9E<&pyUU`ZY>>DV~hBgOSMu)dR$1}>o^2O3(R_rZUu z6_HPLwgzPrj-8{s6)4V6A9dXrD4Y_E&0k!W{G~-YCWd&aFXbzK{#3Tt`hIrCtn1TX z^O{-KH<}`*?YznxWiC3>kkL{Vh!cAg$PM%LJuLpf*qJE}jQX1>-d<_a0+QtBG`OlM z`g)%;9GF;-9=G7@cE76NAPg=6w%1lFgxJ5${ivwXV(B*jC7hBFIjJqp-4uw_%}faX zUN;xOS)+#UypbT>t+}?@ZDJR31RE@WUODq>T_bmgxd`cO3RCob(7aluMf%xh zUzp5}82=NsD-<4Uiiy!PLx2nR~L#j41R@$vOL zOF1qp^_T}YW0`cRXDI>SM1XA===>CD5fIMM(5bQ&@>SAS zW0+aUw1J)E_f&`|n>et4p&160mau zrjp-(hg2HCD6fYQKZaaM34m>x1J!5ADwe#=XZ*?cCXAKBIlWil6Pcy(~W~s4Qyt0ms30MkC$GqE?Kyl+B4O zGC#`(28gvN3jIm<01a-r`uWhxxa~H_V8AoS8YHw>PeYD$llPJmj8o??6@jJ_v9N+_w4FrE8F$DL9X2c$wkze1N?J3EY6^p%Y?o?k85Hg{Sy7 zjZ1bbFL@+N?KNs*tRP24|LY4Pf+`bA={AH{eRPf-h7xC0LZw*#E;*}O)XcQDV2>H_ zE{>E=nnmXti<}!>7}Aq)SV@Qs3u}e)GCHp?h3lCv6a(8m>7T)Kmn62)Wk|ZkJEkFc zLBa57RJz`dc+;^q>=H_Tw`yd-ACw+qkG* z!Cs~pouWKIQ3HN&hmlRcM~^YrQ18l_OU$4A>TOH&+OseWPLmZRu;j?2ArFLRVQ=c@ zdwh^c_Z*+`~}AqQo{>9~eB ze{9NBS4H$=7SAjdDOFx*2!FJ)-%y1CdlAPDujTvv{z91;;c>e(fAHE<$G+M$bfEIq z7wdg*jzhCq`_#*WS>@bs@~G~!Kq+Da0)5gGVY3H)4tt&GM8qQqyGR z^gE|nE3YHzGO6ER1FUdD6R|{(=IN;pGyce{yN4QS(Jhd+R&9WX8+=$l`;yUu+^zNr zq$_0K(tdV=4Mz$iPM{?*pnjd{^A+ow`@rs4z{u)MG;+bUMkPT+Bew(d%|#DIN{(J{ zm)u{(Y0|3nCG8u3(|u-_v;!1We$dX;nQ`Zm%%~$mJQEWgFZD zy<1*?r*H1ol(rLKeyC$%K@Y)a>W?yr6UU@TBG~BG zQIT2YKe)-~;I-I}z47VM2dcQ_IRplw8(~W1ZDGvgJRVTe_bX%yCI=W0CbZQkD@l=2 zaZmxef}F&vmMLY|E6P*woob~O)E=d+uIzIYD&?<|@_duCDaOPfgJVrNH~{IauRubI zU|h8dN%?G~9wK;Zr})3n&1(qKj`C24*k!~=@P2PX@mje14sGWI!)C^bhR){kh@~u+O$mZ$;&f1*sfS4IXp=OM>%>TU-v0=?8JW3-G*L0 z*2QNSjwus=ax2P)x6Amo&_$)!VW>cOtiU`M`g6&(m+@>qn;kIf(bcFr>t2B$T=|{; zuA3$X=1D#9{k>yo&J1u6f&Dz#Q82Jr=^9=uR7tX*F=`uWn;AG$4gt9{J_$T*y(n&C zISrL7--K44a7{IuVBygbqs*@rpgokwBJ((OW5|6ehmEG#TgBw%Z&Ar}N62RCC!$8} zRKVFDaXViR?8ahs3X~46f$MS89*1EqQg`5o$ZPO{k1B-W#1i1AH3p8L7E;D1uSg8l@*qY4z~ySpmd^6BPbrgiIGx^7Lh zSO=PDC1Rq+=Rfh0l!<^0i&!w~#+WGl7hjeuhvSt0I^=|9_Vz@MiIB%YN2#-H29E2<+o|CYWghN`EYb<7P;`e~Cq zl0%h$am`MRyQZ}nAXX^S9>m_bs*;9Snoba}-1DAY{Zh7o}oS$r2nBVVDIFyNz3OD8WZ z*%nvO_9deYZVeBTy^ce6|t6Wk3;LywAU!41E) z{$PK2gZOkqM$|A`xAUkv!g?O^1zcK$es_U+XJE=4@l6#tI+?GhLuUSr3{x z7OmBSV7D3u>CZxsZqnP6DSvUz#?3-5&#$F4<0Fh2dOB6jo4gO#1|NC;4CN9_{enwE z{6Q2&?jWB1q4gh-m{Rw^oY9BsZmQGx?>R>g{f-PXox&~>5rolpoyzz6@|eW-b1)YH zGa*>B9>;q>hP>~3^-$kD*DrRtFU|TY8Os770iyaN0XQh?^Wco0D?j%mOrosCCD<%T z`pQhdZ1`?@G{$hJh>xwQ`_k0vx5?&_=i7aBU>?)Q?1F~3*sJ*Ia5~qVb;#4Vg+;Xa9f(t|N6%it&S=~f=H8KQAh7F zr@R=-#Oi8V`KE%eaB(()<;IE6ggpbwAHn_Ll5^*Lr#BJ8?wf6l+jVmP&6Zx$EDZhy z54RD;0iY{YFcR+rXuN#Q)BxTJ>>^Gt4CL~xgq)MX&oTX0481fWg{S6>=BZHlJTFH? z;4*QQG8vHBr4U}Hx49QTKyhrA;gpUM{&?ENT>a%qDyr*HRa z-nf=(k?yx4%lYYtvazTO$Qta>;u#NB07F6$^=?jf@1I`^Jg?Cg4s;&)U5TAr2li$n zBOK>}%8qJ(^gKD@v_calP>R&(LlwEC>_6(50TZV`U{=G}^ga!d=3zhP#<^Y-Q7ZZ( zWBoc+mgv7IX8O~xiS#pS%`&nBiK?w-6X~s+eym>JURkPbG68fqw5W3gS4K*RCSX>R zSBof9X{04a%`ihVo0of5E1mh=@(zj-Ik5Z@_jHjn!t1JPatTQN!vG4i(tt{>%>U}eD&|7)}7Qkt)&k$E#uYmk_4qJVauvpOor=QASC+!}z* z_9s-P20n-@9zQ8**htw;e^!y_rXJ4Uk-I}j8Zfi7^gfzupLs5Mm+;j2Mg_#bh<1D6 zQr8`|P9Rzc!NBPb6cU!gH(2jtr@Np!ZMl}U$RYt}OSNkj`g^ku#~TTb@#TmkeSTi< z+E{G2SJ#u47DbmcevC?0$MK9I-z&ZQ*(8lubIjT=p^w8qEgQBH&EK@Bd`@}5#ke|A zJ!mj?^e-auQgLALf!ZNzG8l|sm-5xl>d!-`Sv)vtR;&8G$dcJh$dF&qcFsY&>X6L%nW@M8 z$|=|Jx8B*@;H${rGB5&P%X2M{ax+VImp}~ydOHff9p;*W2Gol>&_cnGayAuo7hQFG(T{#x~GRc)p2sxURY zRd8JX7YqELmAJU<@sC17nfy;msE5ui`|%1#`pr>Osz zmr_fZ0k_BEdWD^dyF^q|>?0)tLDDxhr-8KfA53*9&Rxg#LrERwMD}DUmUalaUHi?) z0t@Od3~}Wuokjn^py|kpoAWrWQV1Bw=LDuTEE|1&6KvA$*0Ji`<97rmDO#-TZQ7_? zS~+Epm87Y3nRsgv+N~CubSGbh3}R zL~pfz{>9*nXFZ#Dc~4H5H3YP{W?eSWd^DAbeT=Z}CO*CM^tB^f*;Xmw&~j&Ne}9?>ga0mOSl&>ef1 z)m}vMybD`!y@%o@LT)%le9;6Q6I_`3qix_Q1Po?TXC3f3`lXH8sJ@W@7OMR@g^eUd=o-2Ci7ji4dwdH4PV2lJ{~JB_wY}9k8k) zW5zCs8wQ*9b&}M9H!W2kV2<=VLa6VN~Rr)$$tUMS==87Pc z>%Ve{;CKYJf}ft6rAb+&zC+W@miC@`AClaDNnp_9qXGRN&=Kt2B^ODVb}L&Bcdv)6 zup~d6Q|*UOoHb%5K2-EOF`fJ?lL$VzhN$;UaY4(d_7ky}siAbPKnC4DcKS)uGCCZG znrPpjXy0LdBp{XAp_W$oJK~1VD521ts{?Cez8Qh&5E8NckeoSsml|91%eBCUKBWcM zW$mjbmwG6NO@huwx8nP3xhnNd*U+k{0&3>tpa>0{A8 zQTIVVO!6=7EuiWM?7O3B_Zds_f9`@aX0gq$Oi$63d=I`oygDK@$LLxp8(d7URu@SW zCf>V_?m-+{*-h+8GsJ)0W1jj-L5-M7f^{rm{B%79w4q^^^u^yQn$Cr*5Ot682XSgMcMuQY$wX0v;+_ezB`M zx_XH0{aN@Fy|yK9nm7}c*SCTR!4GkShTCV@_vL}X_)b?>+T|$;?j8=s11t2>eB^Fi zfQp8nmUA6=3dy(YCU#lwaXVoCjWyf&5dqvnTz!jtHIAP#QLV;{l(^FiZ8ijgm+$)N z=J+w(h}KKC?P%iQ^VYW9ds-3;2?Qu$rR$ttupsvUSjMH_249yqjaWAMj zmfbIF)p151tLtX-?!e?TAcu)!Z~f2Lh_0tdPYKk~v`y=&y6E5J{d1{HnCtM&08dPm zp}FhFZDUrq$Kmz^t8Z7HIk|@gev8A79aCK+M5N0nbyjnEf%i&4?kih&0F*En)A$Y}; z>qJziU!rGf@VkQS^29F?kk>XnL6+PcQ`uD5H^0(sdL8RjQTbI&Vl-hPJh1h%OfUlq zXY$~6uI$gtK20DWM|CZu13MtXDEym%OPVBzJrBol_N4Cg8kYkb5yp9bA?(DaX7Gl= zq5m7+;#)z1N~ja!V16ED5B9dd`yPGIhgt`!bEIvbyS@rYhlE|<7K;lSYw4`kb8LE$ z*-#RB=9=5F^Cj5*oDM5$tYE-6Aug`P?uks%o+M9Co zSKW>Noq}WB@r>#(L2x{RlT4I(g8!^QP^+qtHl{!&gc1{qFF?`}AsukBh4-M@uLDq? z1^Q4v4tf&t@)WRV?(gXR{xDY>{8R%AauA%^An|;!cL`)8Z zKXc{%bJ&mM{E2~`7n#X^#fO+Ql6+%WtwHx0hrc}F(z-iRo}Un}f)cDhKs{c|#OYWo zKXHH@h`EdtcAE*kcXKox=@Zj*yqM;{_T;{Opm>L+e_SWpWo1|4}vdLd~SvgBT{H=-X!BY(9G6($D4oEMbeRJ#%m zx~f}#|8!v85?ls~;$0|XsA&t}&cr*X^sFym{+5{y7&so(aEXG)=O>30uw%0cGY8UP zL^PN1y#=dIugz%R_HI3~UHX`tk=Q9oJCo<9ntd^l&&# zE^||#3t$Y#%M$$$q<8GP9kAs<>_0@Oaw+2%WJ;NJaq z;N#sq4V5J2Uk-L|>`4d(rtr=jP5usx<*e7=tgn7_$k)|5(V~2-<^%(&)E&BVB;evu zaOLTVDl<=IqlhDgkkcME`mU5t~i=zrt4PwC0Cd+$K-1K92;abm; z?3l8%SI&2yzCjvdrWKA_(~zwLZq*O8PZW2avz z1Vj1{?!WLz!#Tf2_B)eIfeu7sHv3?}tC0vj!M!uW`D_RZn$4biA9PiL2{XzGP6SkY zSbHU?qFUAtU3S0sh}C6*XT3>cc_&EBaQ$lDYVcd+YtTszv6@2J3v)ONx_thmeRS6* zz+3Yoey)^?<=1d~s6@Rl*6lF5F*_LmIedSE6kTVDmN)h%$ErC5%{F}h)a@RG?7k5H z@adIjY7lLW9G+81CL&O{L1bvUv}S5r>w5bawaA5Dub~xx)iZra9@h?bzB7*)w!?YH z)rIP#aYtN|%m7tgw!;*)8nQ1As`9zqn{thA3$8oDUPHCJzc(+V{9Ne^TBk`3iydx7 z3+o7STn51+2iN_)M$DiNuRM96d)gF%u&OqHouMEQCc=dbp@J!Fky_RZ(z z)OTs`n4*PTI(I4kXvpW)Y_;*4N6&eaR>TH3fBzOV*O~wD9Aptrs)QKwjkCbIRS{)k zCBZ{NJ=%h)rizBJH?n;uilhSV;N>qhZb2=?Bf3wD$|)x&%EVGq16Z^pu%t6<95-+` zXsM*wjkTEyWlo@}WpTyGbwz<0nO%2VCyn%i$_Fr1AIu%FwGYYpM;c|b7^3ekNr2Z-ps|fI< zHU^5vBaQ`z5uoppcm756fvHEhqg;%M)cBu)bCLRTX=_|=5zOd2m zQK(ns$;}y^FDzhUsN!D>LFJkMPKVPI{ExK@0i(aqt35JkJrb*kr(?XvEOZEx3k0Xvi}UQtycR2oW0i?4y~lOJP%pH z9?T*9Py2wuI`CyW(9BpiX7RWeyq|#8`@4*2P2VA%2#AOc8q>id)#n^Q8*N}OHW5Pv zZ>TSnpvku%TL@pz~{~H^}(z6W9Ip68BvCD#~cI;8-Mt#;Rf+loOgZJNvK4()K z<~TLZm4RvUe7|GWj$%R&hBWz2@=K_*$VgowWvSHWFFp|;dCo=`zV3Pf6JgoH8t9-O zTFW?{fIN>ZiRokcNebDRVM+A5K(Rt55-(Hylj@p;9EH0X=YNQFMBAm15uXb{{#N?F zZwvL6zwt>|)N=@29#VBai>p5g&!M3qH;~>@3cueTtOTzw2=|Hfs-92(kNi#0CrVt| z=BCG_GQdrr~)XC%&@qNwh=*@WKyywd}ViWuL z;%C@z7J40`i}g)>f>DoViu`b7tJ*L7#*5LMY^J3l8ztrDUEK6ScO(H3@ZJ^*B@+zZiK1gaJO=Z} zXBoI@#V-uWvk&JHZ5~%kwmz-%4=T8?DT77lhwESdIoBcL#Os>;D6=_$RGBI|s@1~E z$-!7=$;wtM0Hpbt@~r-ef35SmD8Y89xKAEQ7zy>||G?9X8ZzBqrs% z?`mOhYd;f@G%snHWb8T6!6MshFX`L&wSfM!Wx%~Zg*CSy*l9nB9AN2l?psG)8a4atnTdz012gUgFQ&<&!Q3Q4L3j1K z#q*KZ^R7P7!Lrh{9P5~Q`t>z8?8a~T#NRWGdgA-imNRaRaIyD4Q*-@0DD+`Re?n>= zZ&b6@P+%Ud)Us^n?PRy-{j}Zp&H+16Hp1?y=ZD5fZ6aRBd9?f1E2rn>Q@t2iG_(rJCYQDu@FEA=fl2-{p0xWE6Ecx>s&R*= z{l0M5VxGK~wQOT7h?uBwOZ!X~(lvIE{Z!+)Xja2wh$DNgw7`Chz^Dj#y(<=P=Kzj{ z_3G?b5`v%qMRFS45ISa|Pxu*LbPpGY`_KP2V-B-L-NKmy*N#MYW1h*U{syHoR6bP_ z9GJ*qW!1_9GeACz6Rgw<^1vm8o-|?zGah?s-Z*Po85hxcY~_55OA-SP#H?0E`+$`z zL@*V^SQBB~3U0kZaqNGE;^`qiGIZuCd_{=3^1^Ij8kq#8Fgj{o(v)(3Y=3vK$T>MH zvMIiCQaP_<@3ACZXxQHEdl)G;OLYuq_^^i*v$fS3)g%Y1jJnPfmnEEV;_FN#C)`H~<9}=zY~+-{<4qK7 zl!Gu*Jt7XGio5s0dg?x;w6nt@V&zgiXe^+_br6uwRxcFA0Zlvm@#QvzDRZB7UQ|w7 zy{_6g*Hvd8)qVTqvJcfk{``q1Tr33i2#~V+(H|BVF}|M;{P&;|Q>8!WLEBA<#CV{` z&I_=c(N_~fwC*gInJu13X2kd9fOEwPi#*1PF+JxGE)}_Xu(PYljhlOqvrWHr_!eOL z7oOAgGArzhe$dsPaHYz#)jujAHCkq!Nr5TtXTg=T0pQG~7tL=~VjO5Lyg`%-6%F?= zmA>*Q?6zI@py8g+8f=ipK(Y*50tLIFmsJk5T@vHP;Gyw(sGWZ21&ttz zd%?T5yILBNDH^B4!aqM6ZwSLa(PQpj^qewgqFl&Wcttgeyk*tnL2o z+Cc3^>||)w#dt$bl{-i9mmhg#zIF&ro;>+4vX>g8FLv+K`1rD?%J@nNI19ZMzIj)i zB=;$+Q~2A?$MLrHDO%=V32^n43u6$P9ci9ebDwj0DW=|rgJYsY#R&OLgd`DoEl6K+6O+jA^8Z~Jvd zjuaXq3if9>{LrTB62YXBG^2{lbKJPn-VVQ5L;BG$a+ip9yLBGt#m}5z>&DSZzak>b zAMQ>7Lq9Xu1=IKU#J=tFC>Qf)BGyXTzY(uzv#T@sqn_t#MBv#DZO`(k{5KhnNPuOU z-&v2(VP5oh-;rm$RS^jOR-xe=?{c-qCoz{gueN=ooG5a;HZrNijpxiR)Qk3yg2@I9 zS;UFKZUeo4lB?x;gnmQobC~^7iUks-50jpwYBSAV*ii0=ID8$8bp(nL+eE<}=5QZl zSr=g@ujqgD=K4G!V4U)gZV5|j9gbzyUKBQf)rdDj|!Fvx!dvP$$12*qNs+nKT{@8y!2N2bwpMdX_&=s+*uV#EMwvaW2 z(1WmBLc(S#xmcfoQ=!_acDkf>L=`3~U{mcearr-rl)^3|aS9Y?LHUIf;5&7WT=~nyj?qFlh+|?<^AYe@RPr2`|1@Xk|eQVUnm_rRoe;|}g z;ALHB)_^70-xKgtPo`flKfieV;19pLM4Az?x9)wm{x1Z^kgnmoY>U%8$S70h6*u&(oEk&asd<=QprK+0vaPyT#P(L+ z?te5%=3aLx_shl><#s?RoD-5{kH|bBBztei9vO#lP&oFn9h_sFeFLf9T^AwQPuaek^h^GyQ1y$N!L?>g*UhO^q!gh zir0|=E)#%l?YiXxq1ln+MGn0X{y)OULt9#K)r-@fN~uF*2>U5ceGKcG2G}{!wI9yk zrl9a0uotPfXq&C`?@akfWF{9MnIVP8T*~icW#x3arhMi_D5t}(TYt2wP!u80oVi_a zh{cJR>##`h?*=%XLdKxlJ+=wJ_VCf;Th?aw>`B+7@2r%byLOb^(Gp@OU$c%YtO7Xq z#+baWQ8)t81yeN2@91VGZTc)H8^$lqexpH_vROmkdZ9>-b!=~>;$bR}QT;1rwCxdLVsGT-%Lv7Rj?|L)mz%%hKX0s2ZP(&+v zy7S90=E9sck{h;ZfVNP`v(66-_qI-_FQ7kSMW_!v!9tq+R^E)Xuc5Ha5849=QuKV%h<4_T zeO@^invEb?oFiuv>}vj zvYuGw{g7knevbJWkTQ>I2BiAf3R0&;)>9&8-CrT-?y?kAE2U>^zmNB0c=^ae7xr0l zY1JD-g`zAbgv>O2AD}aKwO5VK4hDHmZ`)n^(C^uvnZvs@P5r0ZggPh*jYMkRLcjD= z2bBXzkg06Ne>I;>IbU8o0#Q_%emf1z=JrNac`R?j51E8V5(nD1qbVoVzu~msyHC|r zm75iDX}M2{(S-NMl!SzpniYBQ3{EF#`jsZOxNe(mJuQp4N|53|9gk0H=#(sQ3#u$6 z_Zz_2m$$l+EZ8B42Oc{}c@(S##~m<#>pEsq$+=3>Rn^Isq46fifvGrPW#a| ziejZJ9@;g{ZG3!neUskof!7PC7o+O?l(o!Qbj}cT+Kg~!awU7yPNeyO5giCh8)~^D z_&Ljntn;TauxxfLlY0(h1uP{|59KVSU;NW*J&E7ILlXPm;7=&|vB{gV%NZ!iscmy7 zx3V;wS(&Z714I{>h|USc%AA5nTUTEI4Xf5KiG#oU12FpZrTh32^N`c&dwcNJk>anT zW|)kIG#1XYdnwP~Q*N_@1_+S)uE_?<6ZXHA&O=b=PBx57RUnvV*I+K>wAa-;5Q4dW zg{snFy`mF+&kMBiMXt{>=rkU!{2>4zMVpIuaMvnqYCCdUbofu7HF(~#Ya1~(irj?9 z8&e>(c#-1=HEoXnfC8h&oza8w>~aKaaqKVwWT$P+xz>ofo0@9X=qeZ}f!k{(pnkW={C zlOpk72eDi(3UP3(8Z)_Rt#11pHQ_K0LGkBIFXI1sC5R7uE2V=v=vtLA95ACNDBoVb zzk;0cvJtL#a=b0;npVe|*{^DTJS=nLL&>LA`TscU;op144pP!bbm7y~%Oqxr^UaWy z_U1RU`a?BwA!NxC>@nkiwLV@JntX?j+JV6lMN5=q$02#d&}9)bW=50YknU%r*s(ju zKIoU;cYbXmeApI)<6K<*e15!pT%b4%#_WpC_h8W1k&a6z%v=wrnH7KaJD&7S!KXLj z52bB`wjcZqD3wboFFns+k+l_RBqZ{E9%`Ty1x%RJwi&B(w0m7Oq8@~hc>UUM{~{Ok zD|(&wL|4I)ysV?h`wcJLD`r}rQD>e~LbGQ^{w}T{Vq#~~$nbBH2(Moy9mQ3>=M6~9 zFMLA=T{+n&>nT=eHU}p>bA=({MHUz=&8K;6xl($#*8|{=mIuukxayfr_be-Uk^t?la$<`Q9occ61S((0!JC0xtV67WGnh zIs^q2hB`dHOJ+7a_qhejpQKC8D9)D6+ot=(#@Dm^a@1)9vy0f`@*a zJb?jFIhA1#Ew=HP8UsZ{2leB2EhUe0 z0)###JwBU$(zZ=4tG8FD)^^Rrr>?)ZCF8oF1U_Zyg*Ircj}R22mZ zhK9++j)d@G-BQd{kkX_YVF2xlFrsYjM#(-0Puv4B|0gbIBPeq5Sf18Q@q&-Ajn8iS z&u`sBP2aC^Ar-$I0rm*gBw*vCB{^g|P00RjOZII@=z2OuC>BYKH03CF91WF&u4hXy zC#pp*0U2+Ht%MPZifZ}#6|?FZPO^d~3pvkh4gsOA$rFgx01r5|if`|*nDuaUwDWpG!zR=JVo(;cG!J#R=|=vEI%Rs08(t{etidJ2#j)ep*l1@dmV*!n}*+U zYHl~_&<~`z!_*?IUIkw@B=_R<4m2jSa^_m6Ct>?MW%!7*(p})&SX|p54d1+lqXc@5 zK)dhT9qNZBm0*`3L}g81$-M{mD9{UUVnP)>5`Q`ZB9aBE>(fY%hw(K}sFwt4S~PJw zIh_nHC~p7NHD<<9!u-`XLRJQ4F_Uw7poqOaY8JkrstKVkf$zEIO$j6j9hFx4=;shU zV!pXlEi5_v+Kq3H+?tr>{t&Q>)`$*#Y|$;5$J}@8WYt9mk|liVFk(q`r1pG0)kvPI zEV>+?{2H~sk-N=zp(wC(?4n?2=R6GlytkW5_+FFAinN|Jey=cgoEJIN-QY=70zLy( z1yuEjUdGS5GfIq{<1(xLpc$g)Y)v7&s38Fe=!S#qPqT3PmKX zsj?N3bt?3tp^po3W;Z>ww*};1Z;y1-1okUoW{~5)6n~m`aXS9UEjds|MzAzCdQtND zD>~lb=wl~t7R2{s6ePH=zBm<7h!8lqWUY0&NQwZdrU*VL2zpD6H4i6@?@467q6XMw z5-HmVcKFSXrdGS=OxkdLYDtO4%2A#ZJF;@)9jjFli#V|UGtZpE33DJ($Dk^%Jw0A zYUAT&zrD{$$0S2?8pjDv!e&s;N~GiwhxWby?w(6L3t_yS@RS@-(;QB|mP7qbd%EDR zA+=jG79QZ8J@py6(FI#S1Yn5_E1+Sbr-whMi*lCnMoxNdCkVdtf@CweaDITfWqdk0 zEZ}at&fSAl_Sk71m?-xreKG>(Fw%tk{b;@LjKqPaKI`6inRd25sGz-l_uV_Q#cTOa zzNy^F z9L1Z_8-}?3BRe7}`d@~aC(|IwS0s61C_N>nvIhp#H}UY%aW#9Af9pEBQfSqmF<--yT4;`*VhYF)u@}rquv+zJ&(1q+lqIA}xkTva zKpJ$H9Ip>vh)z=^{8}?an{miqw9fur;iB7n z0Y-s0d6E=Gy3%7*D;@g(4nb)z)ej>h`{B8&4c{HkC6Z1i41({*wwif6FrnC%eO@17 z?UmLm+Xk$zOlWdD2A)PPv(HK;rEP}1Wf-TbyNNCXo$PBq1Z@^CSzq_=-1pVl+HY@d4%v zlmzJ{WR&F)X@z#pMZOy`JgHkhttjT^Ucr9G^h_ax?fqZX)i0&GhEUw!D%`I<+0(ot z&%~e9`!bAtw^V(2>U=TQL+CWVZ|P>p)@R%ce>%t#JkymbM@u|Zdr4{LE!TTW+J~xd zctIUTIurKe;~0+VAH{SIBcQWkYZ8?q@9x}72k+$>>Ms=1bK5c z<}m7CEwLNWh$AGjI7El8WOQs2h)Uqb=Y%V=WV=$?#YV?nl*Su1Z3Pv9u+s}PL6@Au zya_OI0sJI6+q-gKW9&os$h^1xZ1!_!{rPzy#q+3hf3#=$FE{;|Oh|vxqrc|Cmhj4* z%|Wg=@N&W|&F1mLFo5s42Xy2}_uck`k2k4*va10}Ng2ramS4J_x{7P_$;CFqjzw!V z$DhPQ0*k$hQxL3qip(y=DJ#m|>4A*#FW#2I|@TC)F_Pb>)io+V(Cn zQjT^@qrY1D@gJC@%=^Ca9|1aF-pbx_b1|%Hi(XaSJ5x%C`EzW9dx9xwqYVQak}ZNm zeyf!SQlbu)E&v0g8!M{*=htZbC`0y19T3Om7+)E`^!~yd>bVJT9_BNg7 zobmmjgKFpH*kI_;o5-0@i!v>w3D}u4KU=~JvRgHC|4i0AvdNK}MneMp8eRaha3;40 z%Y(IYJXi3n{^x5W`{s?L-+=--KFf9-X}f{jj`^n|qmR+m>tDYr1z0)&%YZ) zx4*rrPwodfEd8VD>ysPj4v;^BogBo%Dfb_h(iK=(iXzhfOT5|=Tuqw+AlnHh{&Ikm z~OG`z|+v4u!CJ$I*pMQEhmLyG%p-yUP%6C(+cCZvb5x*jz!DMg-bSJRYQHSs|dTv7V!tbJ{`Ld!~rgfqK#d zXo{!fqMp-zK}ylRV=Sj+IS$!*kOse)Oq@`01`Y`4_4bdgKhE#D?1{RyQT$}JKMFfk ze)*=_&Zun*e=mIh*sL!1^$7s2}XsFOWqnIp7C; zlUmeKEcXaO1)hQAgbSddIq-eZuQ`GeV6DIvy5+O?cSo2bc=TgiCsmKyyLao`#o8%b z8;1)b@5mUM!IioYZJ3Ke;1M;U^_3>M3dg) zz8`Ta3XGUS_Va7;&UAs(g{3r^WvbM&Z-Z%v4>AvxiUU=ibxu+)!-bfE z&_Qwt$k<$}#67p8wG?NeE3UABvvJg7hxdLD|2hjGqEirte%>v2kMB5n4kSI)1)bg~ z`eW6Td>k+_p*$3(#kg)$lrWp~-Su0=Y)*Q^>oDa&5;%Om$Xjs6rxWc=|J;Lm-7n-A z@%JDLnEvY796`4>Gbkf~yu8?b8Cm5mnMM=aX>ym` z<^zawuLGGT+Tdyr$pPrQY&g->J@U1GQ1I2)#y`+Q{f@K$CF`R~D$)xs??u?(^Gy^K z{bam2@lefr8dUchE*=n_p7Hn1vrlUmp*hsImX|F^rz}!9MlsvV#}G+w88zOSL!1}D zt8h0KMg`l>9ECWqA6d$!cctC4;JGk>ZfE+DQftO=F7SD_H%&KXH`eSG9deO(je?j*D>y|bU8A?q?{wX*GE?#U_c-zxp~RrI_s zhV8TL>)VpMP@YvK*s1EL#-uAjBQImg>`oM1NM3d7JCpX-%^mX9 z0L&SnS+!SJEvKCz-_b8&p4K>X`)iLWwKHwi!7O@Jc@;wYZeixX%T$mm9z>T=CKNYZ zvtTe__#Zp3_D~cyf|IIZ0}IW1bZpxJI+$Ei``iLcMue<&giOW*JkLc>+GnCVIwPW9TF?kHi5 z+`G4Hq_(NJJ$+PDDExs(gL1X@hS&MFk&C1kI`56>+U{&noK6H{gkV{w5=1tLp5ayn zzh`>+bc*eTiU0jC0oXv-y+9`G3o)D@o<)nGlX^$_tCOrCW9W$?-EFY(Irj(=HzJyv zb>V%b;vdMfqSrGIvyjJFGNYA{%~J4HY7sS1GL2BY^64`2{GBsZm%4%iOe#_q9=2Hg@E5vW@DLuU)#SQJ2;sDQ|KqoI#5zh{OJ2T0#dx>( zR<79)$-Z^1TetL-uJ$`(()sOm5{UnNLyrc0dGUF z%Qddt+aw!dPN^tuUe%FZf2Q(^koe)ZN~Yl}Ejc?H3%K}ptWB!b?Vgy7gI#_3tvZ89 zX}5SzYij%On!~ei4b(RRlR=wn0*#~;1L_gx+ed99aj%?Bq7P~>2cxz9y{AL&AqQgQ z7(%U?;j@U|qjA?Hw}X35O25ks29HXQYfezFAN{5X1nkT8?e_gZ&H1Y|V;oZ`+~ir1 z4X()H#RXFLvnh$x-m1!~q<IPO$)4krl7gV|d2 zsAq-iOdNT3ydxXtysC3k1q?%5c`kcM(WcoJ0-R{}x(=oz){Fw8T8MO|_J}jk=o{?wO z{9C=*jLqjBD&H~K&n2QasANdawmn6J7xX}GPddcbCU3Deb(*>d>3yT%=@XdcPB79M z_oI3_C^|MhI-PUGL&!w`);irFhaV-> zgBiRvtGk$}%_G^+;nj4q)2N;9`bPXHFeHn5nZwu`(Kr@l1=Jx_WK_Uq$4Jhg{~Vi` zZotm;=O}BKZ*p{gn%6x~Bdjhg!Z{;RAL>reqwOi@$=Lmp)L&TrCs}xdSD7_Z>h%${ zbeA1SZt+yNT;EdaZn{zRHi80pJf^M(0SK|R z71z?TXAY*2m*#p+WJ2CzhMsW(5A;SURwD7c>77WqytAN(QcXPq>F{lBN@k(#JJc4} zF5bwUQK??_)y!mC+VlG#E54rjaBG;qi+MX$nLqeFA(3K^(QA1v4z~nv?$B{K5I`FT z6?xLj>gV!*kZAo{s)a?$YEHWU$Aek8?Iy6$4n_bDMAZ zmY43Y;?~yp?ByfNc#doG!=sWRJ128xVoX2I9E}r)8hk-$Dm#N%*XjCW5l3T)t>TV^ z0m$Olxo>I8#|AcCJ@W2>di)24&j~C&HPb!Hqhx)>5G{p0pG)6eD+?E5Lf;8*jEn?^9p=qP(4uROY#@98rq~xoQ3okpBD4Nsa&Gn|qv! z-7(YH<~`Rk0u+X7!L&v43ppGU$vOMY(@@N`FZkrmb|7qjfds|e-USe%MaZ2JWKsDF z1NFEma<}&*O>`6!lSwcvl8zMBy*t^}*$4 zfx@bqL2AVhiS+ZYh;wuH{Xyd7n>1cnOcNjPvvaAntciZLVw&swr~6!|&Z?bU!>7f? zARxZiTN(0k6#qjKevcbv{hmIWD=44_UaIw1+IPjgMqwpflju#&vZ7IsjjkZECn~JI zRlK=Z)7dw8FZ`fJ#OwXj#~qT=utzlME?v<)c4wAs?5h75IWq4^>cJcL8}}{SEE42e z-SQ2g%l0)7S>%>UbW9NTM-B-PTlPMiPHuT^g(7u(Zx=pWf@nfc4w=m!2~j-BY_g2O8x^@8@|7z6s|IhVCJ(_5LwLI`_SNk^xgM7K{wbx?qc_(X-R5dwu zf|mQ7DcnYA#S`qu$#QoR6m5FrM6FVe?TIvlAExgU=x z>WL0k7J~B=9v>bk>w5fd{0aQ|r;=vmw9i4Z^{mM@0JRN2ZlpB+P6^}!RBn@25B`nVEjR8Pix%$Xb;-h#gwjx_ zQSq-ANi=PL1NuM51H+azG1KY?Z0|3pZ0KlX<7wlxPs);p&+dzdLvbl=6RJ;Tk>x*5 z^!XKoIRwVoSES;Qd=9eqvbO8x-N3NFxJgM{P3@cF4*b@Sq|6D?@OI*3+tiQ6K;a5r zCM62)12Z1+GzxoTtH3I9=*sGCLz zEiAwD&JWdUl-WVKa&LN_Zob{QFDZRZrRcuhL@6EG>HGK7tjvcij?ff9h4BwV{cr(I zLK1Y8_q<263v#~fOkInjvEHq$o`ejz0JGHwkv*GP9A$trzT;&ZR^r{33it!-J&KM(wpD- z158lG(a0J`eRjSDPX!KRDMmE*U!_;Q)KUYlATLnx5LxJ@ov3z(b>9x3w{QAuZq6** zCZIfb%`IgO!$#FFf>`#U4b7b17NgtN)OW-Cxj2@P7^1U)RG zOonf4w(n3{{AGWVbTR$6c zAN5fyzmNfI7k>OF>A7q<8Umw=ago+`nB6ZY;ZPg-{s&U&R_?Cb*l>B@tO8w!oTlkJ z%a&D#8h1`dcD3n1#F%iY70uV!q45(_?0WY0-V0*nZQ@BxoXnonN~QASdR~=MeNNPk zvGf$*@Ie8t{@ItIgt*sx?k7< z_RZm&h#zB8vw@7Reebi{`#^gm+AzVzmUqVw`pl7ggN_>gU5L>sq`vOpl?<89{0VI> zfxDwu!QFk&pA$7T9IukO<))U|zo)0?%BcHfnv?&jtA9aYAxt0ZUpg{#QcFRfCx&3* zLfK`2_1Q}X>9HNX_5+#Ta5ZAr_CEJVhn4%nk=Ji&0j7ona1y*>5y0uQPbZn?O|DGC zOxI9h0=HAta}%0i1YjMSkr|?I1Kcb^tUj-KQw7GY3$~}2wqMGvu3x^RTXnGN zdR#QD0!1&;s-R^0k5}~mk?58lKLWkG4q1xT z##hXh0V`cA;an~rt@DT?auJhPey`Uq=Enq__$seo??&%EnfTu%_ zOiDjLdu6jee1Be4zJ3YAAr7fs%@R@YE!J#l{vy$D;tzdM2yRqt2JX^Bpvlz-dv32x zKy}Zorog*<;48$SCG!*Nq)96YIT5eYR!+vwO_*Lm90wFaitknYi?4jemx$JMG$CJ- zE+!?1oV}*bVf25Y-UepQ_urTvVQDk^FZp?O+}8bnHre#nVFzahf$irRZE3wSP3yDQ)S-$i0Np#L zWLo^wuTDN6fqRQZ?IA6SDZ+%bq}5xkCBOhWq3iySPTn}W$dMZduZf}|&#$6FPX8d^ zZV&%+B9HJ5aUll%bB*m1hFJ}4L!d(=b#(|?G zfOBW(B1%ZBUwS$--|y3tmgi`5NEiX*>w&O5pUL!)*BjS8I*tkFEVBmI5lI%7d7@_8 zqkuHwF242Iuab>tPi{V}WlvGgKQO`A>bkyMZ-NG%^jqz}!Vp-~G_SR98{6#~3;00w z3aMo(zQ;L+aU=-q@zM?`>oK~b_uZ}79$pV-j-mW#4}}7vW5$k=dHPTW1hG4Ck5zzt z*^T%gEoxAz;F=LsQy6*QzdT2wvii zlk3M_m5UUkOE3WA-wJB#^z3|tdj9^F$T!n!Y2@dch!*s5b=RB|FbvEUZ!06Nh<2r^ zYxV!9tv0PH+@9b5(jq@YmRJn>Yh~JL0$f^Dmf#_U`Iib>AnRzagiZ~Ow!41nPLexZDCpUIEH&@k!dBc3-taKEJMGMO}9M7yE`WXUiFOvG9)DOQvehbKveN9it zc _vRh|iX%j(LL@VUZ>M4U>0es(LCSp39&H6dXr~I-0E2C@)F}N5jv#b)c{0t)m z7or@e7N322X(N9KyRa|+mvq?#39a^CPH~m#u z=uN4&q$X$8k5~$nl6=H>6t(b(j%DQu_uyKmL7m*8W~#Ux*`WRTlfPtuOjaM)Ty=+9fGmX}IzoDHa>%Y8L7qP7Rm^nGxgg$nPAltrTX z#R5$Jb^9*DkL&%fIL_-Z%=d(ypb^>q91q*^r^p#v&=IDMrOu$vDW z0RYt9iKSrokGc&U=W(oH603D+@kj^;Za3SXoDZ?|@HyeC;VSA@Bprx7v9JL$xT?)9)ueD+-H*g}PrV2YlaH)G&aV*k(~NVNB# zZyrv8p({&S!`5z+hh%Pn`w&qEeXA&99`M;&88GwvnVHk2F+FOvZZP^IcLR*glh|=t z=bN((125RyjPPUb#Hdtk%fwZ16XZ&ITrq?ovflUvjB|SGt|szcXnoL!l~iTRXQXj~ zl#$ViL+OhLBYI`Gk}!HCl!j-q8DZ($p3oezOn2M_lO7nO6!uk%c5AKRdNpS&+W2~# zx-qM_q+LQP64}k6*ZXsB5m!<2|Jf;+6PuIkIRW;MtyTjEesV* z*T=8-GH-PRT1WWRAJ3YSZgrMkbW|DDJ#zE-J_J$rCuS=zKVY-j9&rJcAUTRp{{Y&d zVU@SBVr1PMLQ9uUSSLNpbn`a7C1GT||3yc6`Rur>5JF77Q7h#iUZcg2Ix-Qm31(uw^!cm6#B>mmm%lb0i{81`c3 zhdrD}QRM!X(G;zJ3DIr1FBN3?Sn~a+KiNm7TmF!Ay6R^$x7B(RSV=y3Fsp3I;!yG! zHyE%1lS_du1S9G?`hdaJq)6%*(mSx?LN#~iw42u)fEYFTp{QSF5t;7g(Bq?x+Hk&G zM9zE?pCh5DbD&E^7~e^Xk+WBd)b=|bGO<4Ka?#%Xz$u$K!bk2Wi((wNd@_=l=W>d0 zvzH}=u?@%0PQuT+r*2DNYr_NS>EcFUg|LC3RM8wP&gfQBTt=jGM>xea&^Cr`y;^V1 zNFvkVpk{MIyV@>wn|*RR-OPPN+319|f?V3V$FI2Z2g&1@#g}jS>Ae?ImNKB%6m?x5 zt{|zF3=*+GvdTmLgtR`_0jlLS7Zs%uzl|@ep1e)2!Y!n1zTpOjg(!rXI&|N0yEz6U zoMU4=ZK81{)t)`WiM6BGFHV5Xj&Jo}|-n`ezaFu@Kt~(AM&-S3^O%6jN`f(A ztZQw}N`}!Ne_FnN{6?Yf>yq_A-G?%8Tt58pW#k*vYpC*n8UK<4f^vV!rh-u?VIhXsP{#oSjw1F~Sr` za1cH-qKW<%)bpcV`d~XuzNV{vHSl)nK~%>>Vf&E?g>LPlf}FEj6>ZPi z9masE2P;gsf$!rK;vTE@2z|q zF0jdAhx3Yur7X+!eJ%=c zWmN$vL5g^DG=RaG!RPN>cxes%?ql88c^_ZJmG3OnUu)UNc=E+GBtj2zCoZJy4m?=% z48=87ZRg+g0k-d=7o%WOU!s_b^xY0M!UmZZ;}}21tnx$dX*{KJRR&2~FpPc|G&FWt z+Lc1RgE5Htyl=W?kjL|p=XF$vD_I7ltZ%(p-7(WGcG|J43R4*)Bx_n4JBIXSN&xaA z&+}m?Cj1n1cdRh6ABpdlxYIVl{YE)n}dwoY`2_fw;bkosDttx_;|zvNhZVT@~%&S%OFs+EMuh?DU@X0Ufe6=iF@ zd-ts*dgYx?6N7aAqtOchLHOxXPGu16_n~^pY)HUy$Y27}z~q#?B&Yk80Myb08cmIVQw$D+(L7yBx}3TTRnBP~>uyn7k>H~_a#C2>d-~gY zC0>e2yYJAxn1J5b`U%sO98|9!H{n%uy&4uwa6K=*eVxUd&lOSAiZ>$e0XfnBr^HY4 z!AbBfyt1p)U%Gcn-DTZoN@msbq}eVV~ifYARQt%REX`30zFow_^r3=PT2N0+j> z%N`BrC;v(z4?|sZ_Q3@3y=x$E9`H$G@FTbE2^l&o5xl#m>vFa(jY9XOq39PMc%h(I zC&S5^U_;=darl9bR14k!`Cn7J7_%Fx%u*sA)AWR(@#7OH9Llm6-2JU4gd6V4*2iNf!mX9?&Pjl zRQxJl0v;;noO-RIC)d5}Z-yFPQ{A>gwrKRt-QdHLT+Lahiq}wVEqv+F>fw4W+jFDs zGUMp-T>*)8lf6Wq5W$l6>{aCh+h?^3dQ}AtJ>|w!Pq3{{=cH143FX|<3#zm+<#ONJ zbd(#_g=kG~<`Pf(&RkdXqJPeiJ1N0y=aCgE6Z|n#H3~U-p@Q^o^LiESU3xcvv$QvZ zT#2b_KRB4l4QMnKVi8P@G_P3gxP5*aCdFjru;V5^x`-D%-%zDt|HZt|&sf;SF}%IF z;t2Spu5Qh2PyVtO&JGR;(|E(S(5$*bTojHibsnp6DWI-ejN`K@gZX8f(L7lKAja_i z>*F~~lBz+QsuGQw|CYg`0>8=`o+_c+`T(fUWG#J{v)vmn>1r=mcPi`ckx>yY9^jT%< ztye!?6Vc$`cs72UO52k$;CWP(B4&i}AAm|9IqtUT7JAVtJzZ2UOlh zl~E!NY7)YeO6E#5oqMt}BP0&LiJO-v%fMXyuAk1*O6>`B%1MhSO1qw*v_jK$Szf2H zez@M5fBex9HePYd(GvuF9B)mv*UVgKz7q6qS{V=ITcIeVA*xF09 z@_)RZy+U@>xZKsky!dY?6~xH+-w%z*iy46YgV7>tZ_p$&@}%&1R8ac}ZLl^|4iS3Q z4L1cwEZ9xqF#WqquJy3~F!O-KTNHzflA`!RWuB9rkw1vaWyevq2t{P-HAr(AhNt=P zk@jod^ww!_jXPKeOm4;Y6jE^GT`949SEHTEsf)DTEHMEt-lhR%MZvfF+dN5GM)1vJ zb;R-V?)3Q=UI+sJm#txXfzq{W71VYdm+6k4*$*N_{QHODNkYIcR7Id8wtXZSkDB)Y zk&iM02Px@Q@MTAj1}dQ+c5oaHB5JoSz)4leGi{k4Kamsq?oCnVRJS3(B+mPLez(j~yM+IP%_wmxK5jn!0+F+yAd{#HYqMc_;l_cquq`RQ z7k!3Rj&D7E)>DjOG(9nm+P_&B&YmR2ZhG}qS?MN5mnqZqOLxJX51n2lPd8x?b!a{l z4$kV7z>D3R&O1DszWf)^7V2BnLF^T83pf-uWD32ha<(hoAt&|hrOHiS zIzGr1*$&AWzmT?<&sBzk#8qMlMJ=sWk3U={=IVKaZndf6)N`8Jd?DQlfb_rQw`qW9 zp7}_T5vN1GZi3W;*|8#YpQ1~3kbg={lfnH#z1+k_3>Cvl0X(la?s6(4&AtE%&?xOZ zxivdTbEocE?OR~IR~CYvtJY5>VUby_yrAi#ji$Bv_YIPc9I?J{)~C(uSBvt9PT3}- zA?nRwmu@}^@C55YW;5F4AM|DTV)tRld*oJ|p3uW<7|o)Zdm_KMXKLmvDu8k76VVit zmsvxSCK|=v{=Ye?8&axw@*ME+1;^F^5&=;F#3P8yyETMvijVef5FT`#og5RVs&E=X zQ(*+#jSYouIbz_XPT+(b6Ks_iLLG`9M9Z;Lu)rY9E;H;D8c#q3=UskXtc%hxspDp7 z2G}*gyL4<8VhIIArP5@vJT4XLrOwJ%qAGQ=NlbD{* z&%;>_pX9bS0*l(a9|QgcdEGar6a+V3-L#U7t^#4Itx?C7f(k)JZ7-J!;9nD#YS1zA z0UGogz0ZD0(h+m=JbpE~Cj=hG$zXv@bAz3Hvcp3n4OJ5!e@@TCk*(QZeICfx^Xy$C zF8O0NBvsBGsg2F1GdP6-d%5QJ;Tqfz-IL!Pl0gTbfW)UCKwa8T95K(+Z+^}hSK}StInu|Sz zfm@F93pDm_r@gmKZa=vU*NA+3ZL8R@^?W*=p$6!sGE(QXBl3jmF`{Jw|2dLGj;Kfp;u z5l6M=#swK@EhY8DqKwRIZaGsa+H!v^15QB1$Z4k&E&n8_H*hj1mr!wbRc@|*b-FEt zw2TVYuSkO+4y(4Q%k!mx=(B$K7PMiXOabG!`p)j*MH2s|5*+H!kjaceP7J>ke{* zlo9o;fIkbnqfrgi(q>ari5GRgz#BIqUxX5KArH2P|Gt>L__txt%N@6CMXj366+f<3 z1*bmfJ*P-bDs+7Hm-0TiL9L}P(xaKCLdFfW706)=O4=r;D9nZ1PSu>Ge}E1g+Cyz~&#eI4H*` zj7Wo|mSa!E?ofh^Gn>>$m@VC*RCMg+CyWnzWL!FqIT)AN~C1rTl zvQMPCWQce;q~&*Y@A+Gipq|1A?k25$f?==uAmq&u1m*z}ZO+L@kYnIP1>pmxD=R7r z{_>38<{01Vtsg~v_`0%(j?8CYV_uhO$c{kw(wdDzT3s~U0gbjwZ*JAK++BKv!la$J zm82!TQmfqW-&)P>`{t7V_L08AolNWVBXuiKF_y&p1FPp^OyS>qz=8^a&m1Xlt^s63 z6@j!5sglq$<-P~^Qx2WI*QoOMiepTY_ALs8A`L(2ViciHz6UG^W)CD}b|)OS>|I^Lxl%F-qG+)VXqQ+@ZX6DP**bw{bWC62jJH2}*RFF@IagCFjo2=; zUtbcQ2LWO@N)O?}7HUojW?U-~Xw0CPK+LV{Q(5!jIowa7@->$}eQSO0jpXH#OsJcV zgGk2gL|+dAZM*aRS*>!Up79u4wl0p-p=^I}oYp#{|Lc5n__KE3Bs{YzB}DRNx-KW% zU|H$vg2?pY-YY4oqJ{Cy0d6)a_4x2lT9gFG zqo_4wbjYS}oO=}9tuXy{agwe3OytbTY2%I@*`%wY2O-!mKw)vWu2GI0|1oUY@wpPQztk|X9vm@yxw>RToP*mXU<1PYx$q5< zX48ea2ue72orS!PlVOw);VdiEB8GYNZM%EquBkVgG~Bz6*yv&)OgDj(nhUC>G^@x6 zQ~W3&OAohs$rMC1&I1H$sC4E>fDM9XY~)>T5qUDn?ut;Np!;$eM%Y|0{3FLtArK&W z9NDJEhP;e$u0a{q!Lk;?p&QV z*_85n+E5-!f0^#$bI9UG8pDWGt1E|Qd#pv~=_h5xy4)|nzJl7hRXcU+ ziHMqk`{V&z$udIgAaWe1#DLEJ!nWrAG!NVJ&VXU6maFSr+iypOQ1z|PpIAM!vVHjVV~LRDU0 zq5wac@f7~|@TXzAj=|T50lw$`?9T2>V#`to!S-%{X4r+kVcv6N+~MFOyty_E%C5jr zVtLyA1V^pz6fKLX9hv=Q^gsFD*lEURrKmSDeAWFIDDJt7B*NXR+p`y`oSApxUzIY{3F5zgL5v_)yzxJqd!me{|0Rt)nr`0>NjLD{C_ z3$|N8H*7h@bI}?X=d@}a1dLj$APxFNui}pBTtc4M+_OTBop_nlT`CNlu{Eop_MVsN zF+uHOm}Bq=dJMb#vD3`r#HZXHgoh>flUh?Y_tN33NeN(V_={JU<4i^5 zVsxe1PA;2oET*%eKJ)##6y>Upt!CJ!`FzF6;l7};B8NdeS#bF=JnbnrIb`FJU#5j> z)$oL`RR{C9Z!y1@f%IMe2OJ-CG3;v1D=m*;wO6T`x+#@B>daN2hruKfr`kb(*UU82 z=e5_8Sa-Nz!slW}1D}EWlcJb^LtvI{;Xu2oOdwO4(zTxX!ThrSQAH8d@?ZSc{I)3# zJKIeRVzm*Ozc>z@$6&@-3Mm22SlK0VMp1hk6F4^M;55V0O~o)1AZ{(nCEa~z-~Ze- z33RM8qz_NBmk_N?-CfQ^_@h4g_fE<{RfSoO|4mcJ^qzHvf2&$~CrcfH{VX75S*XQy zEF}UbMzb|dK!Y1}N_Sni&+p9jaL!?F*x})O-X*`$zk(e%wmxWkk4LQN&xSLurH)81 z#d5yL$B_eoPcDn@u5l}T8m$-~Yx-g%Q=ER@(UV55KIEPkKt8Na>2v>v?Q{1UyBD!2 zPS)iuzi)i0(+M4T+*oUc#P0^5T{Y2-f_K96)ds41{}WnJcT0^XSSg#p->L-Y-yd+CaBL{GY&mXgJq*mTl8<+)ZJhpez<9I)w;PZQUe0%z ztKY1ZmRMwi^72+VY>IP=X-MR^yP=OgMjXy5Q{PW%yCpP~h=V~G1oz8q%ab!TWa2~9 z%*LNDzbRT_yM@OdE+ju>7I(u<)n^T0n_(v_r*9v3w$1a>&8N3Q{+n*zMMKAq&z(}c zcEZx|72+LIMW*MzRuM6Gvjy6|L*dn0}_u^gb=JyGspg#-E{T9<^Ffw50L=J=9FF`-~6&x>!zeN%mIlZT9Tbnh5x9%PU!}Re7Hb-MQ2(ET8}12nXf) zG93};)}!iL7thefQAmbk#J0f8cI(HH?d;1C;%b zX3_87@3EJck>#J@+}|Fi|K$sJx?qVe40iM0GQh&*3A#DNCBNYI`d6CaqpatbeZ>a) z3LjAhzy)17|7K$}7(Fdaz9gBsUekf8Qnx)zd)r9mJ*TnHFAW>}K2B)`8|7t%EdL4W zHvsFC&qOYQ!q5~i#kmLGoM+X0|Z#{!p@;B~q3bZDo z8x~IA5k$355SdN5owX>>O_5iq6>#|O`T>ac;KYjF^>+_A$=un?yc%5O0u>9b`xo2k zHC`3Y6-R__ENRXnpO>&KZv_EW-vBmub)bTYbS5-)3DO9q|~7hpkKmChjCeu4j)#gY~9- zS8Z3ClE%0FgOL@|Q*u8XheSQ`@unh%T44J5afQ)Z+kO+E5pA1$W26LW%^q=v=9j#z ze!i54?H7M#j;60A!CUtq)9aZBJt zWEHCs=vn?)?QGM&$;++ts&B45c$b^EK-Fz;IOG^?`wLxeEWw9d`#=5G~$WWe_dQKp#ho8SJYbTEHo^WxWq|K_b&Ca%o?gGPcuK>{1&k!H!q@_o$krlYh zbR^~w{;D-M*?$F0`lxs!I5bS*!q`a-yY2agDz0Apv*+&de@&r=rgo=ST(Da|M}?;)A;0eqb&? z(|*8CT`xi|x(a@T88)=xqblWF5Y{#414>S0M6|Z6lWrU;W)m{$k*88Pxr2AYM6;>( zIY^luj-1DLM-<{{QD|KeXPCQ`votMr#TJ4p0G8Jj4Q9tydyHZ3klpzC3k; z7l{4Xo`H^lmr>nd#_GBtXSp3iZw61$xZSj1&QBX3@McK^ZRZM<+O__S%`1YQ&amMR zT%isA-FAT0s&W|_)s$8& zCA=?v&K*59-yIov#M(sBhX?)rd}C#{h@TnHp6=r{Ce| zzkN@G6#VXSW4()?n>)x_dZ29B)+IH&n;jKMPsbCE_}|8;@FwDH-Ey+aMFr;st<8SQ zVZ}rb0Rl&hkNjM8&I}cOSyIZ8#E~hvAm_q940a|s>5X;JZ8ZWs`kj9MqRqh|#@9p* zZm~C7zl%HJyNfP;4t8_iGU^u}=geXVC6$g}0;<6=UvsI7K&H}qsnVthif&fTo$&-O z?bI@f8(19>B50f>!pR!Pw`|+ca$PE^*wzcBws|(ar4Ve9vUH3C~o?aiiA1Sdb!gg5b2zwoojJ;o| z@?U#BMVuW-59`&Lmsm8N16@psS7}UZdXTgYZNF=oIi~bIg{tn&$h2i;bo(B;$H?aV zJlQvH|JrD#g12+S=@DFf<)8g{rS4c`rL^=`6CPl`G(h4 z3;eKf>cU$*d9Jc61}xgCfw3O)FA+PcZx?l*Pi;#ej&6&Zz3b#l`kDBOHp))oNcnCi zTl#=c-}PdL-2|V?tQF4I#~S0{TelIKbttNYyUop?LXq@0fX%~{i`o6Nrx1;{)1g|xA z-Ep|u+r#3EAq(NV0q~)ppkUv0pHjJWwx-QN%SlWX_QoL@ ztFa1|{&GmGt9dKd$YedmyJ5@!X|kiw&04&qw0YbRzQ>j!6~!5o`gE92wjsPkuP{U* z?3exq)ztjchInN1)0MnLr$r~`vmxYnZc7g*M>qIAs8gT*`_RPr2T(=bsQ)8(oiJ{6 z5n!l5Uo|v~GrG&3H=>UH?iM@!@I{-;Kn#{qMQb4tWVc>yuc3X}Uz1KTT7nPSf8ulq z8%o_P(OWEPkJ%O5ALGtqs!RHDM;V>yFP4DPC5O5rfQenXBnp2Ye0wh7LeP1#(Y5;} zo-B@~!dNZtUtI8KfZvq~**VY9+4e`%X2mBDtVW&2>Kxr7uI2U3JoPn#Y%sH!U2^nD z(`qcpHIFmJX85-OOmmiz+JDs?PMkYJcB(1>t@x z7@w5t7mVECJG-fXzXanR|8-?r8V_J%BzhXUCKLWvdVRN&!xbc@0*)!fx;Mw$RH5F! zJ^k@xIzV-;KejI`8q68*QMdhF6G;rTjxdRoWeeLTXKYLC?;L2F)*DOoto!v$UOh^_ z@#OUE`98v$Jh1{GG6|BL7}7<`n21cKPo#Q&fzV$ zz%Q*(jE)~2>pPD@ti=f~!xXerbiigvgr3BgFc6tzIEH1WBZof^Paxq>Y_|FV*C60^YF`HV`uTp%uXiB8eq z4u=Y$KdrnoxwhbJ-Vtc;xdH&$%G^3SnJj$%SPrl{P{BZtdD}tyzQgGOuKIJ2)k@^t z>B*GO=Lk&YNz6OTt5tf^T9~{u`X9vX8Dsy0z@=Sp@xfBm zk3r_*eif0kGqD@Ns`o+f-JwCdt}a|5HzNe~Dky`fV~i@|Pwu&UMVdRTUzU3aUbhQT z^hy2=?q6NEW$cYbhh29oR+i(YX`#-4x`Cg(umyOz-VnvhSI>#XPc?WP#(VBH*%Wx~ z5nh^h=W?@xi`-$~pEtRDtBB2v%|KR{`mOiBFhv(CNI~ev1u3a)y=^&^FQhp6-R^hc z&%S>~ez z^Lg7|ogdomr?!-toeqr6y_jAM=M0p2W+Wfk0-Vw;M18C`T_|DlD2s!YLcV^l#RvdO zBKrE~$GO=DDU~m;u)EFggtNW{>SoUs&tRbt@XL|VArz|yj5EgdY~b0`qe1q>w|3k9Q!EA+a>$lWLOAcy_-%t7!ktl2hW6a z@^+_@cpQyZjb@}yr}xN%bY~iVP>U|l{~nx3+b!9^#6Nj`vYo$V-D7eRyE0}>)R_yT zYmxuhD6}Uvglc_)y*N3_p!Gh0k{6D?&hI?PZU^PM%Mc^J>rSe?cd9gu1Fb^?kGn&D z>9#bsgs%xAt#q;j|9~EsAGvLGVQzQZH_`DPSvA$bSK^&%M&~Pd+bmiC>YoGpX1;&;ASfVY3gDFBpMb9&RTw6=n60%4`tBL=)Yy|7hNj-?h>Pz{ z@i^xjkR{#!WOx1xgT5Zu7uGr)UoU(rkd zs+gqPcb12s`|kQ7fN9yai;hc@fveK1<0#2Hf-DBcFYU>KQg1=-S<@C(&xmB%BhZB3yUDE!>5^!obiOD4C;Z`13N+71#&0Xxib{e&FHVwZ97Jd!{PJ3 zKnp(ppf*4~n(qx}V}8NQ8D(4U;{flq+T1!J*I~l_sN3n(-S>>Bb$q@8DSRk&N+TmB z19^20q1ME|5`Ef?8`rh+;4NX3c!9_y+z)lps*_9Wh0|pnoc=qRK=) z@IBawhr_Q60=jzzPag!-Z)9a(&9Z}vdJZ*fZ0;=0e$_I?eJ|z}D?9KbGS`Qj z!#d2=Le0QmUtB%aR9$`XRa@Dd&TjFutMi-67hZeXKBQjU0X8;* zm5Z7$!j&xxmZDx=?}`3?-;EA4G_nH7*Y#g(7) znI+^J=_Qwkcz3uPnl#p{jI|9G%7~}qkH{EVZ&;;JLkTUSjcmalsVrKnM@{g+j>^qI zadP9wifdWR%A!gc+kXR=2?L;gi`38+!uYmYA%BR!jF|SBzDiVQN5!7V+lkj{LOX%M z$Q*^TMpLd)#*olc`}E4=aa;n8U89Ke<#qJZ{8Vw z5-HwCZ%iV#-FSuWHJ^lx)qcy$jjR2@=P?*U$`NRt@4hw}h5oAh^z9d9(G1)>^>@0uO=K z87H0>{b<;**pFk6@-|b!6J~JUdZ)S}k z$6wL>aFC)n0w7ANO`ZbMGq?Y)zXP#yC!j+B>On6fgpAuQ>17_#(wo3B)+s}Fx_UPrD$+EtDO(f26Am$-sk4+R=L14m#P6FbGi9PhRWEI=$j56R++rv( zGGPR1nW6>>-@eVdqK8G(*iPaVz#ZzTNl{43 z+EmUoYr2-t6JHGwQ{&U0jZ*j1zgbR|^Ne@y8L>BXW{imT5tsi^8a;cT#e(fUFJETl zg2_90RmQ5gJZ5gb72ba41y{=Iy(qcjQTS$J)Um6+wA65q81y&wEqYYbsit`?TcNuZ zZm&Ozr(^rQDz2K5F1qZUeKH@ba&g8EJ{G6m@%9=J(`I^3i|RUK7tULaYfsr4aam~T zC_~Bst4<1YjB(V(iQWAsf)9YJ0b_5v6Tlza+fh9t)s(%F;N>2=wEmvYugApL+E?cG z$}Q|(pF)Gnrj(CT3e&p=V>5@=EVRGKqCoR}@2Ggl=Dkb7NUy{#a zTBcwKDQTqa#0Rb=SPyfo%MgaUa{hi}Rk`xx>zj*bVr1t3p>r{bS6l52$8l@NOG_vy zT_GLjtRUVK(c|_!loj(!-}3uGZFZfK!l?w22?Clf^W>*LxjixJkL=F=IB}HW`?&qd z``OW1a83^DVv9FRrp-rk0Sx? z%~ks&KaQwqd+tw17f3}uG()Fu3xGTV06=pr6Q}y#h3S!#|&_Mh1L_YN4r8kY_ zUEKT%nAxpAbh5sCU!p$!)DY~0H)J^rq6-6&eV>vjtKb=-i+ARCQPkhn z;JpM&9fn=R4TN1?_%YLI$L&PfbGw&-$X1Fj^zu~FGouhe_zeK(tA(+&@Vl{( zyCQa;;ljC;;D0mCM_p_yt|AQ|&RDdh2*x^T5p+Z=5)beFsd^XMx*05~_HXD9x|Ug> zRhyOhHLb{T*^$qb>*|$t1A1Y)K1JdBUkNp~$wOt&$lubhy!f)%;Kuc4?cWiyl^=Mr zLT7{bUvT*^pcc}mS7+_!*ND-_G)zKQ36spexHq}D7p7h*vc+@ty7vNLw}ZwP!Ul+Z zPmX;6$OQCsya_`U9z<$5?4MKh^EJ*lohy%t8cIm9SZ+y@ICJ7m3P6wVf(^9gs|#@T zhK$?9vG`cLT-|1Yr`qac!s%N|Ww|IT2fD~H_Sn1pL?5Q8)7?DNrR^c6n)l8^-oJ}? zuGE(Et?#XIM^W2#v^7t?4uu9$*O_YZE2Da*(w+jR65P2b=HE%OEpekQ#mupKM!Yye z{RffmB8a1hdmrdxBmZnG){SvzhYR0}#CUtWT9}A<(|*-(OOj>Ggzh3qV2(z0iqo3f z*Oj7n(+wX+}<~~2-ZFVVAfB0k1%?J@)sy+SiF*j$A&G(3DgYfX`nXx#2{Bfn+6UnqA zTU)uTOwmThayA=@;mptbr+IOmgX{~WSaeXB+)s|ij4*9f}1cFv9%?6tT za*llc&^>bEl>Zs(L87SF&CP2w2f+f)!VgmHC&Z+&UM+4NBNCmZZmDhQ1)L((104mh z>S0@%KiBR8HUE?%dk(uktH~QrD@tvD(pYwz`@r*1eSBmw$m%j~Dz)2gHn8|}!XWEU z$s6j1zUY)ld70Jk(Vn3;yb@zOPNa9FM4oK(rqcSf`h1T>gT}WXc8$*rQeyt-jit>5 z2(J~~$tY|Zx#0c4FZ5+Bu;N3J(7~uhaGbuJ?4gMrM12H1<)GWw45jxC;B-)*HW~q( zE*lt>n+s(9@^9J#{c~F=58SHT)eZF;4Mml~OZdc^{z@2~Ofe82j#u~4sD0YWuXv|# zJFnf3bj3tyC;5h^x|dlu>&^M*dx}rKuFJ58utU;~Y8Bq`sRFBKD}~;tbX?_&K)O|(6WQ~ue&@O-J z;ZLo*K^_ZS%At2U%=8pJnmnO6GNR*q8<#qXVV{(&z@Q2}& z^@o~reokvl(IC~L55W)ax&IP?jr-~(r5evi$|SW(OyD-%?&g5vK6o3YMYX?G++R%O z9R7&DrXB)5auo3BtvxQzPDvzb70?h_^noLeBg52^#VD@Pk3|bHf+`=GZhh0L?6!8( zfrHUS<~kl5P!btDuts<=Q&}62W7g|tnWpaoUzmZjx6$Xkw(4}74!efeWY|nhm41bv zxb{1utuRVD1xNq-L!ZU{g(G=agE=|}x5=&mmR`e7hq71Ke{(kW@tJ+~9hp@2tlCOz zrt>IYJ+G&&f+-+d;OCb_Zr=)Q}+*~%p~w_EzwzHLycSk@!;(| zJ7&N?{VI$QHP4co`m_|h7<0j-k6ApQU=fe!mF9&RbjL7FjSuF6(_{wCYEL?GJR{V3 zAfks)wa28I>EAb3{dtwP%Jsot0vxr_rP#Z|2T04rZ<4pJXx&sg+$xA=w-oyMdsKLd z7E<{=FF=TUDQG9W_^Guh5qWZNciabym0Ri+cN(0dVu6gg!wMCXzD;<0(J~`ajBNVw zH8rEdeF@EqP0_;aw0O_(GU`D4E)i z{_H2bt=N2pYnJt$XOu5Wm4>zL_d5`_Zy?eux(OI(=3eQNRS#;tZU z0g&6Xl?m4BnB}1;z(pa(>1QFe@F|?c3a-|!M0Mxla$~!gqw`EUMD2IT8~AMc3Bk)G zX&kd;DBN%%y_sp^TR^1WO}-W9kZqrfH;ZU!tyZFnHYnyB+B*}_wDgUTFhAm*JT?4(MPuu9p@+*k>E7y3wZj6vjp8e$L8|7+; zb8Zuto;$!vh`me`IDPav0ph!&{G;J@mc~is)u7=*r0;q*LaoGvt~hCi`)di@>5%;v zYXhQ^`9t7L;MszPYp?8@J)9NEQ{2~SKWmr9U-~=`^bo_QGL3lt`F3_by>Rp6Zj-oB z@}PX5LcJy834MltBqT#?Ccw+>zec0RIZ`x~_AALotm?uzgEuO=&!Tj5N0+!pz9IH@ z=|MC9dh+g!MFgVp%IWc!o9A(K1CaJiJDSsE8L9X0XbQbMC?c=%Yrj$XAFAD-HMbj4 znVcx|cz2HgT!Z|f6o8_;f=SQ!*GmVFx@Jhl2xmr%*dF)qP5&3?3 zZ3jzo*8g7`iMLNNHeg8BdIk7VDttErU$g5Vhu3U-gRg#}$Xfr;`Fkxk{d6!wYP-)M zeAxB#nFUd)zN0DLbJHP3Yzs?@6Ji*A-GV9y{^{~-N)bbF+SG5(M}BT9q0q5w_QgAz zFgwF!0g==G`$I}S^R2a1sb+V!jMm2zev?18o|3`3{x-(~PNWdV-}7GCFHGI`e?hX^ z#G*j$zU2N3c1dwNIOn+S>5r}I(<7JWPRI`fB+vfrxhdkH51F;l|6}Jc@4Ipg{ay2ne%y@~(l}9emHJ$D}Nr1kb%UlTRaFn|rCse}RQC?}161nE z2e6On7g*F4#>(Qfg|DQS>pfx(|670aPxieN)NqYYRff5RuE!?Yx#k`zR5T6`rC#$by=J5j_Yfp+)O};oEd^) z@?^hpBG2G!>i}%YF#kWXC{ksZb1a&z%pC;ZK<%(5oa!UC+E+_3&u@gZxgod9#ah3H zYFu+6&QoZygx$B?1->Vm63=yjFg56{WW(65@FF zRexx)TMh(V$YG+seFP|7`S@L<6zs+cEFUFT@k99`%h~%8Q=abvA2zL`b;|UUa*6WU zfgJ_OuwWKWcvLq1y29y>i-n0B*9ioU0F-^0?%lBBhHB4xn}3ksaPjRRPnDvo(efC` zS@TpWL^cusmoaJ~Vw%5G@x*Yn;q|hBNA2dOX=9e(FhQMXUP%AUxo+$KymJ?%d5Zs5 zk^Ch|$N0cR-{JGso~l_Y9Sb7aFR5C|c)eQNlJ5B#U+3TDwXR1iF*XR~d%f$^2#ap} zrbtv$vdG+$bF#ar#Uor`2l?ir>Y+W~WCK3c*_v@M=xpZ%<7K>Z;f;73+1_WkW~2kv zojWYCU~%yBT)zGb+xIGa4JM=dCe}^r;psLOnM=0RlJoP z)r%NU0K-5Uqw#%3p?wJ&=pcB0`}3*sAndF5&d~AbX;!QK+uQ9KTZg(X{Wqt*6kVzVn!8VGgSm)Akp%F+PVLEj#z-Df?|)Rid1j zC|Z2$k^QtKBV$A3L1E1N(dpNGypZdX5Z8`>%Zj1drc7Pqw`~Cp8_m*&Ij%i+2DRdc z3Uu!DJ?{6B7t6h>^;cPBeHblJE}&_oFJ%}iasNwGd^e*^ZcsDOUWW-B9fLne`DxIb z*xS75jW)T(JqB;d@|{|VArIl}jK)AGr$yL`JZiUrnt&{f>zMpK-=%>wj6c~Tkm-xl zy81s}xxd-_+|l3_t@plPGBfV+*}#+X4lJ35(h*)K=%^?h;??Ai38_rXw6ByrbOA9t zFwk7-ntg!qSzkR{I+8je9;z@xVJx?gq_9l63qBhGIoe5!f{S%EO*X*Fa`7o~nw(MP z^^4wny~aWNGEX}v)9ww4-`GEwgiOC+<~8y-&#LIe1=i;%%=UanL@BapZ?)$6{T^SX z>#4&9!<`QM!TZ?YY3vNFm$LaLrfGk9_-nGF;RbbgZC8ZqrEc&L%%@wRIwRf7-_JZ?KB=6?Wo! zMY0j$5}Xhi_cVQMce&4KnYB~69(HG|f_3&M%~69Ab+U(rqE=p<{T{%EJkG|xGL)M` z?7b_wQ;rz#-UPELef#1D>XF?o%UCQ{&nQFRuMkmk&$aut> zkrjrSQ@fO+2;-%8kg%Vf?q#d@4lrJ*<#x!+S-XkxN{402z zFcrcAm5{Yaf8r2!vt38=9N9~@a1qrH1~IH1YD zy#~e-7|&b8;yO(Lb>YAp%BERI(R#bM)YOESUoE4025Z3nq5F|=`Q*w~kp)|+a;Izm zVAG+}QSD`$g)Fh)IqJQ5L&7=lQdiaK97r77>Pbi=BvAza-{H!)PRBD|jo6)&Dnx#X z8zYZy6e?ADnf_H%B67VR1fT#q9_sIW& zsVr0Ss0idE7Jj5ZyzL88|Bg#f&}i5=cthKBy#FKUI^4Hk`2KD|;cKtU_v{t|0P+^c zmd5i&w5ye76;*`fBtq|ug%AAnuZsPCnjI|Gq@!FLaSM6Mj2p0joqL#|4`4kk-i}y5 zK;#@Kt#~IV7T&3Z#b=w7B9AzQW5PnIJ4Eh>qR?|q1|$@O(D$R;l?0`Ex0NsFs0TE54Amlg1lFUa{0>&!V*ZX5qH zsf+r-cH%el-4tgKwN%+Hj?%I;I^B>zR(r|-+y#yp-F3dFOUgVgBQ5|AD^# zcT{h?p4$)Zb=sEJog6tw^lv6b{HJ>dLN9Tf(6*~g5?CEyJ8W~l!#y4ubG2#p*L!_b z)XC5-wg?ZXA^q_Wz;f)Ojj@F9KVKf@lYoR#2XT*&-R8+{DyI-5qde(SBMHrvuYO~Z zo8L%pUsPT_#+{NL7!agwj#jUq2N*5h^P6AmRy3g%H@SiP#gTuMzf?Y7uz35Wn4b;u6<`A;(Z`d2q~x{4y#O%$kR?t67|pM z8pOJ_x zF2v*i&#gJ+yOKKg-G4S1ZwzL`c;k{?#5Q@o8lujbt1j__i_Z-FBAK2>q_$_B+6h(q zc}r(PH-~WxtauRDR%~?nRrd@KHpHVNxohH==+$~>)+DOQeSI<-aHFsHses_btCT^e zwlc^>CvXN<dGEJu20D$oFA@NOTuOc|CP8s)@JH_?HZ9k&KjESxP9@UCh)PmCk5`C)4 zqp6Rt`OEYqZ9#~sazlfp)TjvVud7R@K$+h(6_ynQ-fd#EHc~XjFan3hyDt)HI)rb6 zN#WS7AS}VkF*Cab`Qt}zD>@9fVFs;bZxHw6(&vji@1f=6EQUKVbe22fH5$wNujS`z z`1!S6+;_fHoMVkTO7xN;6Mpo;W|hqr@Q1C~gn^}OLibc*9;fieC691SojhehzE7cB zeDsN(BFzvvq|8tw{*3-IpE{`TO|HxvJUJnj1lvXf?S-($Ph?z`6GcpOD^Rg^TI z>WiU;;#@{_T`tJhb84H0Kdph@)eYj(RKcN2aRtM_nz6GFf6*c1BEaQc9_{I)i<;($ zlycvOxA&#`ZmW2i(OsnTRUg!fdT)JL@7BzKY`u1LxO59DPBx8pj=Ki)xPx32yPy!Y#3nueWGDfL5ph&4AMl$#-Y^U@3txNE^7aA89%G0kmE z+c`TesaoPM-$c6j`d}Ea>v_= z3>3CLYIYrKN;lo8AQ`on?$C=kU_Ez^E@~r)H3{ROe88N+Ir7h-!9Xr!=O2a^eph3< zd$PCfB^{yI@ap7^Edd-)^W*Z)X{kdGpJX}OjVzz@FiMtwN>#JJ!b<%&rol1=OSDzq zMjV5+?Vy&SDVnLlkjtMTA(HV6^A)qI!b6XC9Ii}MXr7cUbx&B$scD~bX9WePqsO?7 zHh|0hA16qkLbWYCr(&XkWd<(&KXnL(&by>w=q0Y!H|j_E>w`6yl&mA$$|b|MDz5{b zBWBa{Ys2qu?w^4g?A0!hI!ccMPF}pUUhW9w3;_&NlP9l2TqcCAq*ShYozktY zStE)vdjD)g__ykKFWpOCtsK*u*Fm#&eGKm?Ncjd5qP(|%90C<3-%@b5yyflh{6j8$ zWjAc@?u)ERRm~as37?`=AhYvaFFn=`^RktTTf;yqo;?m44krG#`E>EKs%-oR3BA*= zb3RH0NgO&Gf9|!A!GiDW5C0k2X+A~qrP&^>hn@PvaMPZwZdNd@ak*nFb1sP}123%H z!IBm*sla!~?p>y>=@2qp;`K+y!_f%T)}ocr5v(p)ztkvwO`_?xzKrEQu3{AdPGi>;d}&=J&BL1 zBjWTr=F>eHUFjuu_RlxHbOS?C5BhbQDPGc9ovwfINR(+x`8z5_xvbY3WuWe}CyuH2 z@5N-KoQCfWn4y~f?PSe$w+Llcby&J`H#_{q9{K;^brYs{45BQ*7544?i`J?C*4q+; zVm%DY&~AfEm);yDcyi_ zhdvn}X)_`Nieh_XW+26O^wxVq+$dYl9kZCbMto;Cl1BK)!-GVN_xt7#x1{@!$jAFqHs6ncwsSGM-Tu3icst?=!MjVZYl zuE2`5BfEr(eBuK)tSW7AB(#xq5;kma9lC+4_73kO#7M&&;+}!#-Tz3z+why7C5JNn zsCjL&sN%4|tX_A#7RP}B$GbbQD@gzM4MfG!%%GV4DfBQbbAYcl3&j&A#{MC`X?O!^ zGg^2!^bLtWQUed{KmNLVUi7PBhDq9}9buqA2IB%yWMs_}ogS$UULPxRi6A@+CiieC7-I%qH@GgzpVM8}lzU3U?VC z-5=o}tEB#CwiTZqoM*3lOZv}0x2la6eyA=YLxtxk`P`()M}(7H2Pe;G;}x8nZ8^@> zy&f)C`&UvE8k)AAzW!QM|NUBuUQkCeEsL*`KtSZ0XhJJoK3t1}aJ`OUaQGd^p%gBBR&+EQ9h(bIY zAhsr3*Ek)Xd2W*%mTkaZah&u~h+y>7HK`jO!tOj%}?+fn>#d<{i z;$-4E9UAEHqDrmcwjv^_9z{toniJ7z^PWf{QT_X`i0=nina-U4@w zyIGV{!l=k)?rYKRTY>4p*#5fPE5qI8IgNN*z23B5>2Kza=| zp@$GiA!Tpgd%pYq-E;QWo_)&9JTpPZ=S_jIi#VFPz`P;5a12-galgRQH?tV|5s7C- zMR;`kzxUP$^@{<4iR15H<=X$%e)#5F$90Mx-v-iA>wBthDx)01?L3K@a&`CW_-TXG zDx?qZEGr)L!Yu`hd~WYHY(7$2%HK{~oF7n1_;`So%1;Q?y>>`t$Waw$N3u`C%RN`A z9T;{!_qSE*++7Y6;8xYJ9jMudYWgsBhhfYEWJsWw9HbHjFgT{ba1#_j_W>O_kNF!dF7{U1>Xuyk#7nWC7R9)OYMreQZYGwu~*)>CMvr)qVf*v6nuhzl|JBAb_02CUL&wY}}tt|{;ZVoS-g{i8YaxFVbekrwt8u0E%>r9*T`^*Q0N zf(`kDY}lGn-os;PmV0Z_^ue$Ei(BhW>j7833m8cF(pg(hw4G%TYiz%qJLFX>m2TzQ zt@l+@kDz}X)o>RZC^-0azU30w-;Mh!PWMaMk#${TjxQ|jj!DamY=^g=2DZVHhqKcX zSMflbG1v4!jOAOpO$d^&2ucAL451#%L29rJP?9wu{tmmL86Hb{U#(~1Omup?sU7GN zq;Bq{ICSYFR$0HG78ux&Xzo$~&Tn%ghB$)!-|R!CFp_;)Ll9u&hV6J2aB-_{^mBog z8<>-W_Npl4x1R8qop=@gwk}m8%kup9*N?$wxUIcod1YWS=q|`6{%=rwv2pkfZl`7! zGGudM__E?=l;IyZk-Q+lwbcf_c*uJFZlOTf&Y7_Z#b>5hfW?OO0tf;xFl!58jI}{> zFp&okyO&ZTii)Y)eXE~i!$k_I252DJW*qz@AI4GQ`^dB* zaCuFhiP!-}EG{E~axi6QlWtul8WN!Gme3RWX!GzY&nkE|47!f^$AZ5=;QClVE2z1V z*#>5_QUMn5SWM=Y%p{pBCkv&(x*jz|7@a2d9A;iig0c=893rX=_ zYDm&=+REziwZ$Y+$+klOF3!;~Av*&C>u!QZcxREq>CkD;Q|NSlnMJ=E`=mbmH5ti1 z2Gp%yqSrNFF6>b2Q+Y8P<@Si2;c_*{^y~FRl(UDD-z?&xWCTLKqVb1bn(Ma;qNCIA z60gzsVReMG*O)L$U6{Dq>VT^R?BJQuI|QQzq)|lMDKYvk9y40&?KxN5NQ*up4L_&A2rons{bI@4zjYJ_Ob33QfUr zg)hU>p6NxtAoz}z?s46Rhqr|ORKZ-~P20g@(7BUU-lDOpYa_vW{I8qq>QA~F2wc8e zubEd{Jw-7xZjMj-NjDh>hCd`&e2}bqS+moG``oVijA4D);`Fk?EZCVeh1@!qCgeX# znTL5h|ND}z%q>j6xuVQ`)TS1)w;ftn&__GU4SKL_S_Izc&lhUzUdc#*@6Nw)H`ON0 zx7W>VxLf4mq%ipzt~zT73{vQdZ|so z0%uNZ@MtSe?ns21RK4IE>{43)9v>v?WF9Y(wkN&*z4N!?L_a!TQ3?4>kDFTU<5Sc6 zwYQsXoix8VaKP3`D$Do`rGXhWm~^bdW^;W(sD~ZB@M=@6D`lKfS+i(#^ab5opK&`x z;0dme&E{Qrchp!V2e4jKXM)96A_tyt>yb;1Qsd_fwX{w(IuU z<~O9&Zix>&j65muO90$rJ&MPH)a2bw*!W)*F2|+_+ZGT;u|V za4n?jUmuL25kbdTh{m%Jt^BgBdJfpfgM2=2LfW$DvbVJ53b zUxn%N>6)qP1t(r+^eKOzuZ8J}{}aw*RF=?}=K5+6jB3AH?~)#`wpl$r&@t!IU$MPB zQOCSFIeSfy`uUo+cYL6734|*D<8=urd4Ej)%b6L}`AG7)%RSgl9BeJ+=a=ps9FKXz3jnlyLI z|D$$aLsCg_ujk;c?x%PBFh3UT)Mj?{SBsQF5vKBIC%6}|bw51al~ap<%?2H93ifmdwLj~cN1U9}4#vkzKeJ2Uof-ak6md89O*b}A{Jf&A2>H{cl=Dsd;&VGqo+ za6dqHLQCj8Em%F$Jm*-O9MJp)->tUdB@3AZ0tsf>alZ9OI%8Wh#6{}5(_F8MWF;pV`b1G7i%Rv2pyQ0MF^olQ^?XCw_u~(={GN_~ zrtg6n?IJpRuU|77PE?*e#fg8k2e!c1%35FfWL=`;3vl2q{e4cO33e#87>O}P9t)Bf z0qdU})9zW{!<0XMKn-IAHB)7A_NoMC@ZLOr&ST*n0nT@x6*h`39avHz`Zflx8ZdaLKA^U_CKC@Lvb6 z4sJLp$W3T{!3c;uXmze(2vY(EjT-KQ#Z<6>#uHfQ1o*(=kiXCJW-dyPv~pFSqAiWG z3C*FWOUh)%SiVn@PB$`2n?|K-$a}tOH{q&?n8}GuO;&c2OH(Dk<3YIADU$IRx=Iw%L7mJgvyqDVCmY=S}xmXKyo^-*gxME z^u}+3wLL$I&yZTpj;Oi+`3u1{mh?M`>Ex+Jw5a_86qQ((>iFRct?YJA%4am06j$!0 zLTf_bBh;Vyy5_zIPL{{~@p|6K3g@*p>8e6}P^9eM0~dqimmBu~MYs<0kR`ymd!}jh zmQ|(AzWG3v7ouR0v-9odw)y7}{*(AyxiZ_<>Iuc@ia@ zT6N+udJ@b~Jp63k82)dl?3H2?D@K>Q7{NI=e-10S5voxStT>#prJ=Z6Lf{o3)_uz* zM&8k0pZ)|je&Z|)_}M>3pT8@N<;w(Xe%CZQkx881GSjCIF01gh8@eEq$;y(8^aZDo zK>N6y;KBKr@TF0IGtAfo!os8RdoGCfdMYhAz^%ajr3}xRFM&e%edBxX$L-sAGSip? z*|Id4?Xz>E_C%X|FhdGJg0k$rnz;;kG;qey6%Ah#zxhfEGl>RF1u@zd!@ zqwJ9T;qzKVtT3qg{^I>XPwkAj-3?s9Evlo@cD7523e95})eoBN`gKf`yq#@%l#ffj zmlQZsl2d*2-|At_>WJqrg6yx``P}mP*Ys(|zWsGBLV)setuSB$2!Up<@}P`{3|=S1 zI^zN9SXZQl~`iT(txw{h6 zb+V0v^-&Q6S*g*OTM4YhZQ)Dvgl4srUY~<~;jQw$OZ$4a&+M_-ik#uLL7{0t{+z#t z7)EHS&|1@R4`cgY3lo zjQyJlwPQ2G>B$*%Oe@EP*7x?EQsJg2* z?)Y+dH1@l^KC23(NtmEI8pIeHX{>A~yqC%m%SGkmZMXQNN-cE%NL8ngCxQP;mTTl~ zC!xai0!|#d2n&uh2J_Ba%k7^hEf}_c=SybAE@oVh6Z*4v%2-56mN__((RABb8&+WR zc|BIbD#|kX&=Xc8UGByePAajDQnviyh149y9oFp>SohA4H+gnxq4X#xN0zS0 zbB3}9^8o+ax9RtN;Pt=RL{#fvcYr$s>~iWVSR;%!arK~z1kWLLnN8$ruHR1`(#_s% zdzYB71pM(BB5_2jgk&1!k@48Z1>QWt#@tcV! zxqNqlsWnPyY)xuAJP9n&Md)3#rRR0|5?q{fv*LDi(~=)ap%pl1PVaYgoUd^{T4sTAF? z)z8hX$koy&&7J~%mL#wn5-MZoWOIghh{2yGw5Sd~h60%V-`Cx7VD%U?M9N~jbktoB z7*!5M+7W{L15iL)!SYBQT3aZ%!p{T(#iA-bG`)E!vbBqa0G!70pouCWeu~a16g~_GVhOfd1;5BPS7~B38k=>E1 z3`_xZW5p3CJXhA4<$Y#3(dH?GO=m^&BWC54cV2$@E&bv9XB+K>H6st1wRcado8IRR z6Op`XpN?%4W`anO0GOinf$4nA#e<5ZkW9>uXY;SmfB@R#PgU>zT|bk?nJm^a+!Ciw zyI^xaZcrw#8i-WYpF{<3v0GV|1e{`IG@=EMi za_+PZin10;)8fK!hkHuPXWkeHM{3i3aZ*YAu_xJ_&RnhiN zRM*9>3G;CNV7+O6>;Z|y`5~lx(=7$&%6eFcTKd(~FLP>bPY!13s2WVibcQ%b1c<(^ zM|>ALB&~iDD)?mQV^HH#be0ZxIB@9a+sFG=FE&PBz4hgvLaA%v{+{1hpYHoR(Rj9N zKn;9)0#V^9m?*Px3ivUeW)Kee6?T)s%v0*!NH6e`Hed%NJo_peC&aZU5VcUD^3LNMCO z!8G<@shIrfV&J^#J!)?}Vp&v_v^FGl2(-N3P({A%KuvlDH##jCF*n6AN77gC>_dgi z*7w}?{+h~jgUK-RsdIXpT!&c|gg`RaU z`#8eH4xFx2%)EDaI(YZe;25U>R&CzPeEaO8QBOVJyYW=#2wq&H$K&f!D%Wh^jF^Qj z&kqP?&mbaGv<^(wiVteMUD=>;==H*cC_Gkc<)b?F`+%z$++83}|yZjgIYO z%PAv9uCljqYilhQ#M@g+3^`Eg2ClmStNe(o3!K>}AL;6Fi^38$V90eE)W8`BRn7V> zvRDD^!L@oupEMvU3hh6#dM{}=i7)(*4q4opWrrZNBj&)6R%e4nH!!fb!GUm4LyVIa zLJ+0_r8^fR17VU(kTL#Y*A}t4Y}@6HL0S!)?7JU|AvYkOgFl$)V6Cv#$j~x)7lC!- z9ih3Wu#U0N9Y?o6w-%E_g)TX4GkCh9sD-M2v!hl)t+3nC!J?Bk<%F9TVZyfGj1iTa zPOHr*L#O(KXENpNVMOFX_)L6f@%(I+li2gtA|GU?jFa7Y0!r|{u|I8bnDOd!(2AY< z`qtVwNBMW?q1Vo}Xlw}qehn; z-kN}U=a2|(;2|&eMraqZ*fASIo*>7A`2Z0xrLzwQ(CLej7yeQC1zm)=Blgj{`Kf1# zU(5LT&}5=m=Si1umVX)T^@gg%VK~K(Dklapi&Hp4bi{d2uxt_936I<06#t2;j=%Nh3!P$ixDG#wRmwl zdvsB@uuv@&a9c+N48NHDvRZm>_;?TVjqV@6mcA(NRey`CEuO>0u8=$CDu`nT#9t_7 z`hwrU+KsXNRUHJelfC!x&L4yHs;W;y({Rxq;@hu%>_HQD&rGaZ2Pfr++aNjOSkLz^ zz)26<)6zqqiXQxdxXcSf9Qp4)w1TB<#cMAa$Nk_lSuzd0C>EYkt^Ga~h<$p6otG|u zZtrM&65{*>KPcm>RQL|*N3bP&7PT`E0TKoYi>j@4h=*GoM~&;uA)m{)jQaA~rJU3U z`L_~q=HLbt&T$`BljwG$KD>fgW7pR&BQqe(y0sDdFnu@Z|1ePd>2%#i>S!EenEv0F zDYBj=G9R*?&y22L*(Rezwg{=3L>DLf*?U#YPqXxU{X}1ot)&M~9@QP|Bi>K`Vv77A zy!f*5{BAIGV=D7|`d-pUuPk3-O{t&dUPvn9V8T7d{rO_ePxC&ie&wkMUW(pa3_GUw zS`btz`o8O2fG9)C)BM}b@ysWu)Yd_6O-B{_Ick1icr$^@5;<0|bj480z_8{#zSn=y zVsTPUpnc7?CBxKY^N`^Piu-FU>e-AU>VABgpmRoMu#B)7Q{cVZ=9Jk&icD?aR9U&KxBl)qebQsg50fz0As0viJt9+zq8Z7QLbet;CJtoy7IBb-s)E#4pMpcIJtVxVq3t&hY|)%~mkfVGeu@L=OLyyUVdx?)^x0 zJfwboCkLts6pf1=0){)Kvf0LPx^diNuq@u5O<^EBxT_BpmP1WSkc3S7d#rp~H<-G!-h>bx3Ot6fZSQ^RQAyQ< z?TX{&m{(_2xh}P=bcvq}(a{>6sd@XTnO3@aRkX{A_DZkO=eD%+q`DHA-@O#S#nPIA zZ-PwSVD}TVhm?)foS0LCvkHIO&O&KMBJAPT(DC+pr``<&p%LL;)jb^3^warwkWj^@ zR`a~Y<`+a{RZUORPWiq8ze}c!-_IODg|(7RuZrGzecPv%XCzCK>yjwtjl@;-7%OfC zSwDxk*QrnxID2IJ?Vc}t)3=E^phYr{KzR1=H{}|)z`B;Li}ic7pj}8?UnCW?IRmHC zcXws_|0AKdns5%l`sfs)iQI%kU10!cSv;?BOgksK{v4fCIsN7clUVQt{Wv|hb=EVz zDtf(5G2Aon=RxiDOCRu|zSf9O&g!-nS9({Rn)X>jdBfWgf|^fpp{0$uG!pdt*zb$^ zNr*M>Y$31yJ-6n-tiu}bhi%+Sda7+6Kpf0&^grFa7TEFb@<|v*HxV*g@z}atIdvj8 zFhL_zb$tloSO0Lmd5di)sT)0k;FTdIwWHbHfatjJKN80TSWd-#Bcpu@QpFJaZg8J+ z=D{fa+tHgAe5Zb-;>G0~LP0_1FBF~{PvUE0L~U~G?2_$t6-R?*3TT3c1^cPjv#^FS zw}izC?;(PT_2I9oK%a{bC@d2QNsn&;+rI29@WKl%q3pV`IQoK$@`R19 z)?Gc-PnIf&J>MvlTaA8++ggzQXOu``RatTMi*>EnrIp8}j)sJC1OXT@8@Zej7T%P1 zR-M1Vf8CcOnvd`Mk2anS^Utxb*8$dtneSyjvif{q|=)O}24V9`*3aYwn@3A(O4WkO21} zr*w(QH7&WSu{HP|D1W%Qe`pDiJNSLfu$O5IA;A&F&ghcDWr#$2>erNx6t9PIWu;A7 zf>6IL9`Je-u7%l9w9TJ5VRX=3>DdofTppxCb*o6JB$j0@eWzt?<6B5$dAhC2jdWf4 zuh!3RZRfCu?u7ufxtX9}!ZE%D1FK~P1)8hBmnae8&fbw6F- zsF+`Lb>qk{gie}hS)>baZhE=*6%Ub zkwsEt+R-_D4Q?TVb4^T+N3J6OfAc5VHDay6;W;EVmPk7Qnp0*^G+NA!F_PtUvB^mK zgTg@m~oAx9jY}L{3*~8!MYHjU1k<<93CEfdX(ohyRxhIWEDj6inwWc zDvr!7ERgPK{Wu%U8HOwB%UD=Rc)yuo1y=aPJ0GCq?kAZ+DcqNq_};L+Gx^_82P$s! z%vfK$K8lol1bY8%*v|#Ns%et*o8ErmDZ3^8A4#TiL~kw*e4YIKQbb^s(#<3mL}W*e z?NwMwvnieK1T-ap5;?-A>-#r>BpJBOdEB)#Z4;=-6Fml(x42T}$2Xi>X>tvE0s{t5 zyQAoBRbv}kR!%X=TkZLSl51uN8grYataGO1K1Aipev6;Q9hrn1++)t!Itu4hB%+2N zy2xf|=;nzdySWV6Fe?lfhH*+)M0U8rqHfuG z1Fn3XP)2o|QeXu$feWd?7mA*wCV~4R#4@jM?#X@l+7ij4>B94^L0$wjlaylDh zLJ>uWC+MM%Yqrgt`W6am^IO{q{XcLDTvz=Y^qlUO$}ryvxDW9oG>Zxqe^A-RwXmtn zS8Z>GC@MSi`vS`{EpSArl&ANzC-fM^NW{q-FUn)CJ7~L0XI(M)C^f;^&^{iGy2&!U zrh*St)!v?A-$}vR;OGO7BNjgXb2?BPow{6E*pu+eyTL|Torj0W1&M%-oE%J_sr)Ec*}~P6VqG1_ZMha!pJHK7yK|GfWu}?F8AL4g?xciU>LstvN-wEwy2ncO2|db%IBgP=mkx zi81`3NMn$LeO-A=GpMg&G&k@$p|cq|!QskIN2Jw6JUNYju}qfajpN+sbm~3anytq% zj^?KsGiw4i9l%ahHqeAz1dfkGI4!%fyndUh)s^Y0g(GHb2dS^G8oFOM<0bD;kKPFIh2Ub?MaQ@fto$qI3mAt|q&W&fR zRA5Y-atXrhQEsWyGlkHmag&lp$=Y(ikrKqaJ-1=uYdhOtu>a*rZ6GnDS;5$Ykc5wV zu1I9y*MbB@|6XXXZXdgB9)|OeA{YRcP&weq8r!-(oCDd#2Ewilbx&~1nBZ35hiyNS zr=n*j?bZpW$*&sqPj@C_WqGiM80DikoOVPdj*(O;gKxUOfK;Wwf@qx+{5Rwo37Xan z>+KK3WV@TkUQ0Evg#-E{S=}xlQ@}#|G<^TV^<@BUDf3s_`B1&>i?!?f6Kv9>9ki)o zBQ3wd8>U3Ksy^xVjEnX~D0Om^dDcp{?Os>@k}X!LB|=I2sG%}inx67m>$@#W zL0vv*BVBvo?A)*Jd@Kihb&$I{{0TaT{m4Mfa?DT2$(h-#v68rf;DA{CKRiOfl}n(6HT#H{~8_ zbGfcR^eQmy)vGz&{xx{V9R$rEYx-Az*1Ca#Fqs*ji_QkTdR~&9SYF``Hr+hHj=oe^U=o&N1q+y$y}0@Km^C1%gcv@9Z#e`}YpyC5XC_a*sH2Y8nqw+y9q zY(%_7d4lTwcH40io68P|llwaXI#@hFX73mm9q)0=OPo0@_NR>Fml@rNBo0U?^ye=M;Gjn?BMBP-%$NkbIY9eiA6Ag0tg}j z_#AO6+SsVEImb@T)2}~!`v2|X^q#;bD#dlNI6eQY71kS~4lUcPr$&S$lbI&IKlW#m%7La2vXRO0o?ZnHWo+1I(85{w5`Y+5uHy*#NH7FE#I? zE39`D<^O3P;(scCz8p!>+-y&s7)|jIQaHT$3)tw7aMx=x3K!ix5jT=zIO34Wg5YKl zYX(w!YsS;~Bjw1)diRp0!V5pD;cgb0`BK5mTnWRR*qWd3WfnnOuxX3Rrknq2hksMr z|9CGt40sXX6UJ95hlH-Mp0FXTydx-P6;9paa?(fWR=gbwD~FGd``^uDAG8eY@3*1( z6EI1;MN{;S<2dl#d+o&qj5I0YGvg=_ybc~mm@^HjKq?$B#ZGvvw-}F!#n)N)5=F)J zsW)V(In(J&4JJMh-`j6MU#BV+S^758Z}K?m{Ir^*C88NZ1I*P=H2I3ao+aH__UY%H zVV$#gf-utymfZc=7q)e$3?-$+nnV5Mj*m&JGb4~TcdsA%Y2b%oS^M=1hF!eKfzbPs z(K$FLO`#y7?E}U!R>|MKndXSe;*dSxyQ62z*>{v9Ph8~3X)r}hk5%}PGY^zIZSM}f zd_zhN^KAYX)mUwx@B7CbqYV%g1iz<)N#^Z`?*hfg3U4MvXi%BTN`>l{X5Zh)E;L8&NElLx855 zuc>VRi|@8rhE{FbeH#fM3^WVe-v;1)4Ay&31!hM0UW^$dGrgJvHUp41)WzZowAEw+ z&zWpVwer2Xto3AJA6lSuJR$}0<|4#^w2Fec(&P@Db2I$AZ3!Bszp=MKC$vumYmG#M z;(U*9Qk9Plhi!)>ye_M93gSf!@@X`EpMCC(Fcu+~SFy-LM zW$iNot9rqNNMyLGmzU95ebXfEvccQ_q;6Slma|2C6XMCyA_)7P*LMrrt7AX~)6%Mc zR?NE?RruHK1r%xKR+!eHUD5z_%*z=!GKU`V=snnd@Y@Vq=;scco}L23>0gNO$~C%VluE16XRJfzlPaD;tTR0ZKc z3gXxAv{`ko+9w3QC=F2*K|#NE z7I?VIf$aaIE=M+D)O*c!YFluOy#}UkdZE&kIZC4=myic;MJy(vVkNBg7!f~NZx5l5 z2Cl4jH{>DrMwKs;3DGY>&TWrHFkxi0^@Tr1&&tXBN6h@h@5mVbv&zZQG<b!n7WRSgWT(#+XJ?)Tg zTB&Ai%RHEyv~FI672mXbNp-4iM?{oKMxM+<{z$ElH<;LJAFopJXHT5Wy5Vw7>%mSvkXhXdT|B$4Y$1rQgvLQrWxq5f4n2va2q7fIp&@6ZcZv1plBfjZ4P`|v6*;%eq~wBUys#|qz}olgHJulzTR+?83GInI*5~6 zc%_tt`%t3miYj@?RCY*Ac^o!>rrJ}TgyvkWB^78*8F^V>*7IQfovv>F>xBDRCfU!RZu$j&OwWb_{3mD2Mx` zm7hG5`lyd5J)P|KfycujR{P!3o!SJ(vty+$uOv1sSajbf0$_co+ReL-#$m)g#&_@A z2nGTqH9S${PuSE5e8nh)*B^*_;>IN5`N-1>sI*v7LO(52wlC-CW)-71wu>P%@ zRDy2OjdCZ3I&sEZlYHIQ)i=43xk+8c;t$`2 zV}D!}ODwlH;w#PJwP_iIbnXXLPzv8a;BknrO4qu&L2_nyqU_w1y`a9JdzNZeV!+c8 zm<{##V?C*HpS1XAB~IIi=7SqcXmNYER;ow;DBfPwDbr5>{8E~9#Q6-3h_S(nI}X~v z;)HxDJGTMdkaFIlR@o#>CyM&1s^wyCn9X47M}x$*!Tm*?jwguiU~JL3Dw4M;LE5m^ zX4P(2SyIJj0%`3w`|bBKH$k8H2yKOfF_mF0)(PrQ?|VW@i%B#n>ja9;yHe*4e&MEC z|JJu|!^E~Od4h^$2st{!cc;#P^_b`*uN_$tX{35vLHT^hXlm72lJgs)g9I_T>KhH* zbOFQSqIw=A7wqU*KO>C2t5K7yp@zNKthbQEmxYWqUrDPTqBsW~*5MW}}R=L{3(Zun}8f6nD*+flU*bDKaB z^n=7>B`u*}i);58N8kK%e7ZYU+XU3wz__H7bFrR=4IMonvMMR|PU;veZFthL1&4`# zv_F;MqT&X@R-GlL@1|3Cw#zkaXK0;j;jMd0?UeB>&xFX2v1b)eEbG`h4p~O(gH*%$ z9Dpsv!gr%&mmOAVW56ycr$4DtVyI90EYq0F`k<{=MEV8M*9T<0WYBIum7DS*c-0qh z&k(W-s{5SqTi5Jsnb@!gH;;x_*wz|f8`;Hczo9@u8ZUhv&fAxF>`m*>5Z2kQvT4uW z>a2S!0iFVh?6v1d#tjbD#fIR!!_MMOZ)#jlAgsxBjFM7}lEqJ(q_ehPm&W(>fn{dm z>>Fnxj2OfrMFSh!ISq(!ausl;;(UB1KEx`Xh-8zP7LT@DoZ-7({a>+u^jtb&3=Lua z*_}PC+rvi;6q2d#$U2ov7#;8J)v`9loF!!04mu&40Z7CpP3qo=lLDGD*2FtFEdjxn zBO^r&nZd@?NuWfi3Ey1y63{q6g>P{}HVhOZ2%+%TJ=2gMiaq%CLDEqBP5$4G$}VV~ zROqK=I%~Q9>_hSI>xVQo)F4SZfZe4C54|-Z@So#%*yx1WqPXJ)cZPp{V1auJqt}OAy$BOKFjZ^a)$+rAg?9H1i3`j?NfO?CCXeahI&iyP!1 z^XcDR$nM~({J&L0rG&ZVs)p#+V8Z=|U7;B`T5C7txbp5svY{S}s@GFJq_c9d>u9qw ztGY<$zQR$DOR1}05LTsP_$|cK{@0A3AiXzzs2+zJEu{hb#qlUsaV@O^l5O7-4)EK& zb{?2z=I}c}E*)q`0r{ZZz{j9w(LUy7@Hbiw^5$VVbSY zW&Eiy^S=%zP$9^{3Dy?m0f@>B#)43Qzje4RMe!GLjJ2kL9{XSFhHS^mp?k79Q+o}2 z6h;+ju4D6FC#q7h#P+cLITS9L!EmOZ8 zbGtGn7gEGvX-UL<_vKO+1&b?Q5c=d__R?O2Ks_-6M_k5q{mFT#FDrmta5luz{QpqF z8;cv?Ifl~IPLxTw?hLSIVAjw~hw9+^q`2^{;sTEz1MUKe23Lh&wk|oJO!Fi4uID3! z4jhN8N)=?P#~l2@cHuAf$H)tDtI|o|$M*-mNH#Al1a_T%r0bmw=xdZ{>*wsDpew$w!YP^D zL|V=0uDjk=Il=n?Wi4c@SVDiK=#~%i z$LI#9o&Hl~)xbJ5JASr`4c*-OLXU4{p)HoQxG_Ez=D(q_jDJwKW>rO81ff(_KJ-X; zLtq!9;Q~=Mvr<#-8Ol@m5>f+9;MGG(g%Q@L)XqtNxYIB9@W9=(Wi}< zpILM~IG1ch%a1YG9T*Pu*P9FnJn+IkEocG6`1bCqggap5*qElH-}U6#gOZf zNj}RghO^kj#14q}&X+$HxwY{jwkvC_)Y4#9dyeFDSPI^FJp9)AuAT4$|2oE2la)Uy z^BpXVd7nh)8{*E7d>J(e!cJ^?PMlP|DE_n0W3m`^WC&Y%hT@~xXmIj8WyNq&Rw9Y1 ztIAim4>_fHrpSH`w{w0ILPQ34nqM*GM~3O~2a<4(vr}7Yn~QhTHbB-9V` z{Zq$RWj1tBVQ*0jJ69xobKxW+Mt)yUOdYc5-tdC8?8K@^eBHE23>n!T3D4MGE?;xv zdQ?Z=V(W2dR)rXC?=#?_X-^mjmMK;+=Qzt;_XA})?G zT+@RtFtt#?y209#D>U3{qBv;=8?%kr!>%XUe^k|9^Z9A1FLD9nMJ=hxba2u*QRcrJ zfeY#O?5U`nr?T!{&=;KA>+F6O6F$R=%WZsmVNI9+aHlu{iKn?b_e?w6=QVgK=ai6J^17?A zldnC$1plho^u*a2&lm)|CpfTRu;%!C!Xa~GgHT^b&s(S4 zt*_{}oZjfQgw8g-UjAcMtab)o!sR4zwKd-QLM9jzBGx=wmb5idMc)8Npbe&wuw@I( zECPF0%|Lg<=EpL^B~0l`Ay%j9F~W5aTbRZ1CUBuOVDU+1 z%sg;aJV{7*qFqd~6##6Q07_?jD-_@C;eOByG@RozYil15lw)JfTVL0yI z?AOHr7Yyx{eVd5kG-M3>@z@;t1VKdTkAgai|Sk*Jil}HtzS{BEn}PntGuE8~L&-iNsw` z5H=Y1+@20KEq|yZw06xz+pbd&bt3NiTS=$L|MLQ1Ez+bFl(&j5nL`~0J8Fveet$?# z{r<2Rb=hgjiEH2){WYjb;fA37_e!C|$I(A!@kJpEQcxv|1gaK8W%om0U;_<&5_FNZ zU64|Lhzf+60&w)Xn0az@6k7LTzh7YTYu`p+dt_nNF`{(m2cKzrhsJ*|80X%Z%?(wm z;zF7=H^dL;VbhV%Mw$kL-_EQL#GtUXgkkz5@SZX6+i8vTzcykvRz^;lGdsc_)TwK{ zT=+!bbVc^5eT8$oAwR2czX@6liAxRj{i1cyGhu$Nhi4laMV>q98NxRv)7P@Cpco0$ zYFW^}+sZB-(%3s{?mRfJoTDWz@pbR=Hl6iFuXp8&&kKuBue_+M23w)(lrd$N!V%z! zc^Ik5e24iWD8gaI$p!Kr0)H;6xZhV}%rd;NEJ@n%IZtH^hSBBuVbbfPRZ3zq@0#64 zjd#)7IXN0j{dY{O{m$j*=|jxby8kjLDM^+?Pazhw@h$NGq3OJ$n)u$YFQ9;efFe~O zwl9b%y|;)ch%^PI_ZI2BLxQ5B(iBu$XrdrBROy6{KuG8vLQQ}Gp@oEmB!7PITJO!= z%+0Jdv*w($pS?f(ZiekgzSsmdSm-^kN}c3Vp{+&kKbvDTnk;LQ$ynY;7>z}GaqE4f z)YxY5iLq>YEpZWHPpSVbIs1l^Los&>z?@ge&n6py#x&o!z(5+I|FX6+5dI4!N*k6o zwiQCt_;Y0mx^ZkrM{^xnqu<^BK>U#rj8vDyQ5sf@rPOri=dB}7#T*=v^lN1{wfNIW zdZaK?Ww8_f3Rw3MPr`gIXbYH4!w*3HWw0&BoWB2lEFHuEHy>Ja9(}Xj?=ML5gh+PIRXN+$&p=VI( z$cXM44LhQ;1oqk`Yk~9gUey+S3vL_SwqzL>vf=#i-$8sH=-Q)P1&!~Sabu^4TDF-r zN*)PY9$ z1r<(KV1#LP%qoav~XZFUzAsj&*)m?1ChcQi`Q`2}ADS`}0lK5JxS0ksYIoyf#^sfVYE;b}b| zB`y-#AWrMNKo|SQ?rbLKx7rHdC*VYhI~5|kK$%t|?e`7)EcD@Z9V&>)>R2w|EzwS@ z>FNqSf}B&Y`&cd>ai$su1~s@n2VI-`e@8Ba%2--3CAK_trJmWQw>Z2vlzMy*Q+{U8 znj17M@(pwl0;Lp_JSqe|%{r(VV$99>Tcva9A{T<)4Ye$D0kwZDAs$GDNygI}a`6+y zMpSROoQ;`qou^QC-XE;C{mKGh(oJ_&dn)1+ILF%)$~n>vjrDA*Z9ykDyv8GovRX7I zSbw4c8rMMo?KWQ4zSyngytU-v;$uP$()m-h?i5Iof*4f|BHj8*a$;4^;eq|);;B|O z8OhMAmi|2>H|Fm4ti5`saNXDI=|*vU|p3*!Gb1PRY+-t%SL);2PcHPzDxqi6(eliI`6$)~*rDbv!XiY!@vf@?92WL7Q$6sMcP{v7 zIUN`@$sR!UQ|t-T+^Y5FWU2TM)HZqSnY^|-LGxA7(y%cd7w}wmw-XA`-1#mSe%hcE zIh?!>5MJN59GgUBq;vK*I9!JB0N!j_M@HN|IS{7eP1TlU)2Z$V1Ff^OTP|@Het5P= z2~i0I&6BMU&$3zvZDJH``|*{ciSUo*!KgFCQghP zN7I!N;G&!S2^bNihLT}v_i_^?T1En-DR@Y0+EyTp=t^*ja^6VT`}7wQlt#b3OFZ|Q zt)i4$U#-q7k2;K?y$}90K&n@P={&95pAVG1lfuK5PZ3j~4P{l7qdJy>i^ncw*DDxf zA7_SBQmx@MgsO7o73EgJ>U-xYDhG-357m{1ZRl(LLz=_m!1Mu!ulBD!jKg_K83rt{ zPWQ4z+6>q7Do)1j_gza`=fpr6aU=K72>M=N-w!1HbGdtG^$9$E#S>=m?ci%8T(9v! ztOIidYr@C5%@w;p3Jli#KL}Sw!|E&~kCp9`k5)!JcH-@r6B#_nUq!Bm9V-BP6EriP z3aH6>X71xJbg3QdFyHp_ieF3EPafrOJpyFbb+iM|AJZ1*D_e`CBq0o3iP66~3*-iN zceZi*o;~O*fI-MRF{0P}zVAtR;aC)L63?#LWn;R0X+nlO_2PyQDedI!gmVY%h zLOnGq4*?_g=b)M0!V_)_WoVh)ba}BxG>^%XpPKkz`Tf=awJ2u&9*U@Qx}1dW^Bf$O znZ+Jf3O^WXO!eQ;jbwiwFy>$#deCY9-*fvw0X?Z|^XCIi7X*%Bj&QBqozxe$dc-*I znEwo|PZ_My5e1iPqlT;xo9M4BVx|p>oW9P+LXHYPY|l_eRe)A^40O!LX74 zG;bK>&d~E*@*@>JchJ;m-s0Qtxo>V_MO54BK*!peTs^Dp(+5Q!N}WJ7JgAtHbNL96 zYpNE_xJwNbia*YmUVH=qF}%}WR*x4p1(EP6LalB8kgx2o(RQmM;c~qnh@r45o#e}r zqR@tjP?|L4*p zMrLTF__^1|mQ&BlPIzlf}W7u|7BpG3Vu+u+k53NR^U^ z&A#dl%9xj?GGp&i3rWh@z5@DSZ`?BZ$AvP{ub8rp;IJsHou})0mBd;L8pBgmgLtk2 z?m{cElH{kr>x&EHPA@U@--VQ9DvKIL+M`=?Ik5}A$VnvbN;iF6Zla^@i|{v2t>rFQA5 zXOMF!IlcI;r#$LRO<01=-{z zhM_~+`LxiOVAM4E9_D&dhD;cID1zRKu?-l(9p_{4tZmAqiywYs-W2iSuH=FI3d&Bc z@S+{@AiBO3TDR6A3?QG`OMx?fk@7rajs2>iz*5$|G+qdF!n&gX;;V-yf%Q9XQ*Dp- zLOC5dXCnFO$Lh1)ulcd>oe!PUyO`GG>gfIxHI|~@KlFFae^|nV+R7hiP2Pva3)qp` zL_F1(7~j3vNRXnG(y#5P(5HB~M^mg^SpRK4JYsUhPslZ<9~!=7DA0J0eF;)lQh3bE z5r||d8rGNxg2XGEvQigs*zFGueAo#J`gy5qMvF%`Ntsf!+pQLNl8S$#9QM{&s%J?s zHPonPrefX80)D`0f16T7Ir9K`NI_B8kxX<6P_1eR-F=O^{3+*%u~2J%MCkfWJCKtd zzNY#}P+Ej(QRV25DO(bi8}tEiHN6{smb;*a8v5z&z+;;{y=_hH36GY$nCz%;yo6vd z0`7fd!E^=Ct&IrB(LbSq*gi`?9DlflY>g=W&AcV^F2qV!uBC{=Qs$3ETVEiYl6mQN zaMJaeFILF{3$ z`p`lA`r0{G{MCm}k$UViU|lxt6NMaAm&D7#NV{+GT+~+Safd7T#vGo!Gi-<>{z~~; zZ%pBk0A1mC_z8=qSyUkb7IbVNZPn|ZuX4%|DYbg>s&?Z#WdBL+pTUlsn#{N4qFkkD ziGl?rCD0wWpG`W^2Dgaz@t6q&o#&k4=-taQT^Og^DKdY5*=m33P`GYrfIL;a+8$TO zj&-m(4*#RZkBbr3d?bfR=3(0Gzdm4hk8*kj_a!6!uRSXtH51HDapHYC<<|I-ZT4qt>()?fr(tBgu*1qns;l|(_lLf>h zrR*o<>H5#Rn^p&d5(+1IwE=|RjdiV%Jt4_==SJE{xj(6#2x?qc&ZD7;sP`~(Bz?4k z?sx$pc^xLAOhpVEfKQ~J3S#(}(Uks^n&eAg>`(h;y^Hfov9xn{-dsBI;I8M2BIh}1 zD3jt~RivL)MSG?vtv~~6ccy$xo+7gikajQ`5uI;5PmZqOJuG0z@y7-)WNO8F?yhN; z!q@AqP$a_0$%EDvRWxC)NKW~~ev=L68#K#LBX{0ZGa=)ulCG@)<<%19x4qD^d_CzL z2&yt#M$JfvyzR_9N#Ls>Wqkd8>{WYgM-xu=3G9XwrA8BMa!y*#fkDh+y)AOWQ_6UE z&u~u59bz_9j?(nWUJ+0kUh`s4O)z57ya*dDJpUWMg+UE^qvWAcRXLQ2c00!Xj569l z2Hq;lmPGrpBE<0c23mp@WWXkMP+s_aIM1|PG;he*`-IQ&M9>C)M+su|z14Vi?}Z3$ zUMaBswQ^Ga4CKIOAfM|k#OGmb}lT2Pg#(ylJo2N;dwtNz>4ZSQkkgS?Zvu1pl6Eqa?RHRJ%jsE z@5@{P3@xN|rh8<|cNPy88N*Z7-7UL{t1! z7K~TlL-oy^Mbj4x4#Pcm8&?jWHQMXK7Da;dCq~ zThLgKF@^bKJ)+^q^hn5`%kFE@qjRg&p}GG|Pc&UnM4QFp-RKiwZCEl@^FXq(YhOcJ zU{UTFZD{CHta+#Aox<=N5+LKF{7s=>3*RAOhS4onsTv)@a~szBd`-rQVVk&S4GL=B z{&s|@bPe#Q#&*B{C!~R7E(hjo9lAvc91;2n%=JXuNc+RL=Gb3Dg z9Pe_iaZQCx>MdD@L^%Hq73Yhug#p?j)I8mk?O8D!Fn1oFG5>0jo$NK82$8$M^C>yt zuPW`Lf9^u-K-p(}Yn9@JK#jLEb%XlWrJDDPL_-5$x&hw7J{r}6mWFUKjbmD0JU1ul zSJanLiGH_09gVCfD1XFEsCoF8AN1P(*cE;u&1#0++z4iv5RkCB;}CbM{#Vo|@`^AK zm2w0;H8u`N?uBC3Tf)C?p{2fm%rgA?U6UN%3h8gASln^j`>!9}x}2U?2(OtH(3;Ho z{#3|j>r=Go9iM^|E2kb{A}Dq-No8=q_-!BbkoI2t7gH)JOj@e}=D zyXJO%!fu|XnyJdFhn!sHB$T^ezcIVxAaoDEj$ZqbM%$cDP3b*zWK7uXCG#ci_ebv%V@w*Aa5RZw)M#<)pY0QxTHNt|WXlpSx8qos zue}KYW`m|5{kslTZj~7$c!5Y?n(0*t%a3O2myY3Ai*qPSj6fmwjoTrQ(q1-n!pZ2! zLO+s}`nQi~0R&&y@ZE6I(Ro8{;@O|!+*>|YsAds%d#^L|!qUrFsa|xRXEN%fCzlc$ zNR>D@^e?b_P{qwv3d-d2IuyNU{LKCqePY5Hsqx%bTGv}=kpK%ngJhm)F+{JZ;q>ZW>t zmXyyvkd?I=8b=CSX{I&y2rdFcw1$NR~E`{nOV_e_3PLd)fba>n{F7 zK_>`Sz6em%lU7LZS-P6%e+Xl1`t-?oW#O6UXJu*zhziLGMqcS?i8MSjg!c1@t*PJc z*rqD_mUlq$AzoODkQw^;$ex3B%MHD{8t!3VpC?XJfYrT?qp5T}1Tp`+94|*n{x|E~ z%D~QErZpC;W(_`Y*^=P_F$eGI6$Ix1J?ROPTb%6g4U^d%7U>O6LzX0)yPuX4-#_cC zNEAoA^9pEZXrwmdAvu8N_G=#xGbh^UN1VvY_vX?fRG=LSNcgkFn=z-h6Qc1`0r_nI z$lpwDnTwoz;|nf7;9R2TC7(o$COLqUX^T#Sm!-ZmkLTX*ZPkrS zWW&t7MZWT$7ck0x)2J!oujA8usWyj{q8j)hNav?N_A5cYQNNhYZDtK&j1`M^PrZ+4 z3`74UPRJGnVs`JHDb-lDV+|aqfAM|`g!M2-1keecJ;QvgWY_g(L-6z=hU(+UlIz;N z#+J{(^cu0YZOZ-XzH|Rh7BXi&9~>Rb?}{mSo?^mh@Ia1Jr2bvI+DthOG=yU&cxuT- zS!vOvezs>-o(I_a7aRV;yM(-H3@2Q$n)+q8W1p!JKLV@A#lnevfH+9!Zt~a$!x3dL zczkql@{xI=A}77qIpkrD;@?@q{i>tuWy+;=0`uBagtmBBCP&ulX>{IzsNn0CkdCrN z`!^RRwfhy3^Cx@T!oQ!)!Zh+#I$;3=utdZ8I6=;n(7v#eLh`+L&W3?O8II4`7SD6? zQB&D{eJoToTkdy;#uis&_oWhhvl)6IOOy5Hm&hBA`|>%HG){(k`{3hxUN0N6!KQ6A z(O~c>G~sonXw~zQa%CCMubP=TR?p{wCM!vzLP_sct2Bi%fE;7`Nqp<}#Rrv(`iW21 zM(sP7i(+9-r?e=&(NwcSl+2KPsB$T$oo^7)a9A%^5@QsyAMw_#NeeS%9J?zOhCH2? zd*I_t(SlL??hCx{dFU-f-I?_1oi%7jub~Nss**&oZvS#Nqz{b?r3ph|=#xDjrUe%im0sP>Elf2u~agCEkD==!3eE%-|+i)qe*jBB?2a<1oBd@uEjKPDTzM}h25bD0^*`*M6!mYY5QfwpDFvd+MWxVk!$L3yLZ!G4LI8Kh%d zTW+U(?qtub<(VgEx3kK8@z=-?VyM^I;BEwDPf7tLry}(QF847IeMqBdJi<JJ zMb`Oy&zMn?@vLRk$ul-o3WHnt5+nwoNlc(Op>Y(+T(C- z|LXo;EFj}JT^e8A45NV1lZ(G3RoLzMyV4Nk3~j{z+gp(H52yjZ&&zbiwsYpVCT;@Y}hbnTM^k{A4@(bB8)>hd=He zExQO$QB#MT>i3E2V)h?rrjQRCN{rf|)No-jV6~S;vO>U91O@+oGRb|?A=w5X9D+#5 zC48(jnq*%UPONa@XyYnYwkuH7Z0PQGWpLxknXUqK>C3C8_Q~9@(Ybv&A_3fpobp}i zW#7BnRlnSoD5$ak$E+0Jw1+>~ZK}~l{^vUxoLm-<(MNoJkXN-GH=>$Av$S&>#iJSX zq~$kRM#xKd6679K-2h37U}joK%y@a*5E@O7l?yXI!|pY8cHZcA3(5bc-?QT0tFjbV zqX}BMzO*66=?V_l*eV36^y>0kX(Jz>CM7wg z8)=m~7M&-lsK8&Idij~~+h$0>+nF|o*dod57XJ}8sjl5XqpBZfF8F@dub|qDzf|LB z)*cV4HpjX-oHjBDKPUokm?R@84+-r;B}1o@tC2m;q}J#0o_43s!&yVOvF~cuhdO*C zqtdKmrP7GT7kerq^VZHN*s3Z$D)t?wFzk8`?io|rf`HPoN;?iQxFGUz(hV#R9zG#v z=GHC>)Bh6s=@YO(aMM|3#jsUm(d6+C1++W8HuU`)H8KiW(qmoPWvPY*dAu;=M5mQQ zpm002G&EP`qza@LIE>aAm=V}rDVA_tV9BiK_3aIM4z+{3YHk38mt|&Et}XT7-Tjk# zZvEbWGv&b%xEjR+UvixtAzRLhS4H@uxrhgc|JX zTv|j~**+Sx(K?EY5!H$}sRbFC{lUJyLJjsh7*2bjoYWg1<|>kC*VvuML2+7}9p{q< zSM$Z^=L-VJ#a$-G#(J!6$k^VkOZZ+jkp(7DR`LK-18E}%L@Xw)S@E?LeF!v8(4hR{ zjF1B39g}dG9P$rc&A@+qd{P4;jsS0Wjj?WglP7OOQ<|Sb{&?l<+ZO`=1 zA~K`%3ZQ*Qy;WF6IouOr>kDb9hz)&|HN7{rIpBkJ_#Ck~4NW_U1N;9~?0YT~^jjD% zIu!zauT$?f6ry@fkJ$3gjPs~-Y3N$BZ%G|mp>4}pLmAiAcDSm>5*+h4$35uoLMiFI z)oKoBrRLW7v3hW&H41GstV+cS^`#Arz~B?o7aB`vx$jeQc={7OoGmL>Qb#KsV!k%L zI8tm~x08FvSO<4aUiFUJ`Hu>+-->NIt|5))KY~*!hlw|4Y^EkwkiELJCf#p&zjg76 z^PG*4hA$tvowuR$bl7h8B-cHPN?5__pxy1n;A8LUzWT=XrQ0AKE3>vs6haGm36^|c zDMKY}dy6yI6iH^;=CPAu4WV0heba<;8P=G)NdExu*@mt@EEvpSnKGqnFP%=u+uw=D zC50|;gkB8`_DF& zoYW0V#3XlF%xH>T(^$PkZdU9T%RP&w{0qh8?8k29pA^pbs^%&fQ4-Mc(5l&N+g26>;i;IhI{2-+&>Zdhu73Mwx#It=v2w)JZV4>g;l+nO zpXPymJ9y7gPrK`*ZIZ_9B|D#Sv=^hl8Xw~!ylfEB`$koUiK&igB4sg2WDFL|^9K6@ z+^?OVHX+4g68?tZcn3?Cz6(0T2rKI31GBQU>smIMJZs1 zf#=h6i0cj?!-JM$otV0pqxELGad)dN^ubj;6EJXK|=g;=HGU)3H=KuvKi9}&x@0$ z$}=ABBff-dG|<|glFu7E9L3#l{de3W_}RAa(ZuDZ$Lj0#m(v8sxF>4N3N<5`enE~u z`EN{ZMIFX9peeTk=i=PL2pb|(A+9_{G`=IA7eM4?vV_BvomG z(rHfSiD@)@oC+i zH~JNFxH-mAco@`I@r9gebvRid=2|7Xr^V>Um=(L;^^ZI$JADJUs}Ao1ADQ&(cF{ZzDF3iL%^?6LWXG*77>zA$jQi3K_{g)1CN6Xv)9x0Z?INgZziVxR5)q zgH_80dxfBUM?*)Pk~o^vk({1tYtrE4S2E<+04#ltI{fcy)}zq(5~ zazwM7HM*S;!wyyXJX4m7VJg^pi$k;`1P+gjhQ%rBC`|qhJS8Y#q1Pjcr%*ZuJ1{`_ z8$CCGlHNPy3^$>W<=83ev9}h(=T;3i0_IiBy;67mC$VF7q9^fD7h-U24g#$hteNd} zc2RR*xT{rYs!$-R!~s9D2y`LRoHW%8s6zWRV;MNG@86d^Nm*g z5Zw8<<8252YIO~BF5QCCwy#xSlQ? zx^^9M{L^^iXWbKVJyZb}!Nv;If2e^{vA{W~Yo+rKbpyH)Z^9)`ZDo zjW&tbeZc{Pmd#=MCMA>Ot~zld!rPG%Nxtg>45G1ZYFpY;A;*uIZE|FF(`1j{*vitJ zX4Bxu%Y$%o5yaxlc$~G{(PeR)mWzz8d!HUgml>+?PqHhkdcZ6h_6%jF{N7rIr?wyI z9T;<1h$llb*gfRiY2UNIV>wDlGgtw8c^PY_{Sq490bSx zw_G6wG>yRsONk~lvrL{C{@ytxz)MV&farGUX=QoauRlcXYST#U_2UBy#P2Q?e<|K)DQD8d4(V-3L?0368bz_LW@m2d-7LY7I}aaE%mmT?XIa82)kx7+I13^44q{4LJT5s%w{(@X-#I2HGdCfO4AyNBUndMw<}$!TW=Cmgje zUz8R7?&qHA)ZV_;0aJ~LHh2+!oZ}Fhn2NQEeAPnf{tskI~N| zSG@`(a>~N+%y<7He?KxNm`cP5ah}bZKhrI8&;6(HR zEV<=*dL@croyBVGIAT$sCZgA4{)*h41~S@u7!oeoM?OG5p&1PDH8& z$P36)GnstK0_$W9n69&Pj9W#h>|k;=y>Qu$s%o*0L7F?nb%|YDYz@X8*p;hNv~Wft zpwbg*jr8O2wNmyImylf%rwCFG!JBXHMNbsH%2KeE)+j=xO5oSTGk!V9%ONR{R*rv= zy8gf>PSDU|>iItf24x*ZtWEz0m5)+|^*X*tyEnHrS0g_+jPq#)Y7|99jX9G6J}0Ac zJvxJ5iO=Dz?LjtMS?{%j{=V@zu)7PT-QInxgkLP0qISi&rEbJ%-~|fM7Y`!odXL%I z)Y3Vq0nKUO*kRt%Zdk6Ca-oE;4aR-^S02R_-#}G9eHZ-A;TYL?toKDEFMNFWKBLB+ zN686X@_eByMxSi~LyS(Tqc84WQxb9HP^z#(UU-Xoj=kS-^AXHKar`t&;3D*l%5BN! z;#N87AS@BMGLp5ZQ&7{@DEKNMp!-I19ohAcr)dD@+Gp@RMrL6l+rQShiS!B_3@^fJ(lcgqs$c<*Q5p zO*f0`&`wzJcF)E7!^{^6Ja@ z*0#P>_nEv@lkzwExt56n>}c$~c4Wui*RBR!EqI=%ER!ZqsbGp+V!aBP+h&eSlk0k1wz`{$sZZ+uaNuXpP!EB(H8t(N{EqRtvg~s`v(p9oKuF z(PUe_HlH9TYMb;@l<~=_2JwherYr3$=*cPi>2vg0$Asz3$=Oy%_fQqRia*QDmh41n zdvW4>9t>02YnhAkL7j*9)p^FlKP%y1ari@rAo84j1J@DrACj!HDoY_V+s3{`lNN8< zyzgOL`GHPn-De?=|2;dZ^OCh@-G`Q7KD)1{GaCnQhR+Ozo_g7BNk&ZFh^Y@YwRi4+ zu>LiI4sXB`)D#ek4BEl`DwiyQyT_%O(NtNxb+ho>{)p@L{u4mMTg@#Eifs%LiZgeP zX+64mxI4DTP7d!;2S0OHG+^DP_N)(CvjnlKR3(rLgQ6*yU6SvNPteiIuc|;`?Y4^5 z^PoMK1nAMn*mJ=1A7^X@Q^qe{ZC{`I_s#PU>l^N` z>F{Yl_oWoiGwy z3n!@_EmI&0X57X8$u>0dO@*2%sTM`pglQc9Zi{MY%u(s_g#Lws%0jU3!(;c?VVhQy zsBRW}uRwC+J(da&`X!pdrOyUdJ}BEz zI7EJsc<{62->HrX!p^y5+t2RZFWdGpOC$4V?8G8hIqf{LEz@9s zCaELK&~?_gM##v>aPwX6)V0!wY@)A&wmRZ)HB#49PuUdrZqzPyVL2tIfA_o-UtLnqIxbKP1f+G9Tu2{=z@S{>i8A zUiX=wn$C5qMY<{r^gc`~zc7n@u`>yKx8-XjUG`S4WnD~MXk2yQ)Kqf{+Vr>l(!jD% z2oC)n;X^gey0kf+AM6+XBq7ZKYmn7DH>nkGP4ST+t8^66hKnGqk6u%i1twrzm=gT> zTC=8wR1IIO4ovlX$`zWbo_3f}0twb*ipIvM`R#5;{wi%uhNe`e_Ou?8N3#A%cAtFmpD)+jEYDs;fMPStN98ZfOkD;LX z@oSBv*o)na<{uioVGdVYp>_tPh1;t!hN+!xqsG;N*&5;E^O;->pa!M6)pmC}C(|1CTMwlyq#_3!4)HE>Mm zto2^JFWGhkC!(~ZdDCCj8ohh36IF8WZ4LddK-A658c2ct80Jhp6%zvU&ak-}({J8M zIn`I3E+GD&Ylugsl`-1|A?aBM2IIOvmr=Y(S&&;p3$oJ5s=tSqnX zF8>q&wb>hZP;p9KARtb%Ll%GOJT6XdL}#J!tzw~bdR|C|NYG&ko*?pY9IE>(Q6@Je za4ustphC&zLP>L=40gW!CMcWUB}z0TrQ*J}9*KXnJu5M241{V-VFu~!Xu$kSx8AQ) zJoYN8%1HmuWV_I_#PfQ)5{rp9jx~6r+(xmXh2!Cuuo}Yxzg2TER?L+ow)x`xsu=b`xrnVmL!WEVCZeXe-11QAY}7e@x?6mrPf({}N?#(nZA2<^ zBbEuqqFKH!MqiBt*JPgy^cejumR{jQDz z!!7zdDrk(QV{+Yop5w;(lkJ6*QLzeodQ(@*eiD@ymf55jFMV-r4SwCb=H7oC$5|wW zaRTK`GG?-`ayp%tb4$4ypCtdfI<|oz0p7npy`=_#E+={#(Z%q^fOCHY#yeyfq!6-^ zn@(tEBb!H^+`QK@F;DfS+MlgVr@Q8dbLWa50dMP=8b;whherO6W-NOE@Wv#9h)l6w}^ z?vX})uLA6FO(D+J8#5T_6q@rsLw_<%o*mpHBQKOIKnaBppsL!N`kosCZg!>dc?(#P zIMO5iTftvzod(d~W;j%y%FH%eK8SM8G4(|dnugDqq^@sOu}vXcrUiH=xj}`{%1v|o zGBd9u7ESUXcaUAo0ogD!?(>u#jPKGkj>cWrtJgAaTzCb`TqrgHFd)9kY%3LHy-G|d zaST63Wv1seahd%{aJ?P#L~%6GIY&hqyfO!o`#z?XJ+j#-UV(mp*R!bteEAC-kC^91 zWJlR$GXz)`swVd2hP02LSHtSqe2;{>iu?uIQc&~;Vkx0g+*Bw;qW1NUzR#+>UDP>N z^}R|XBe#{+KuksM>?6~?p9(6y0CEBQ%ZQTK2mCKG?|TttTC4f`l?k!8jAf#K(OZ2( zsgg@A#kCDLXIeUTqWvy=K4uDLPBuP%0)Mpn7v3r=W;-$Un~vD)$68oewi=qBRpV_?t(0MIb*CVLPS)e}n*^qs9BJbGZjD zJnc%sjYZ(UcfmL+h#yWPg*u8ICteC^TxoCgG~|;A?d%*~TJ?N$YAa>?*Btp=2Wb)KKHI-2OMCu`l;k7e4$v8QklH-}L^Qi}}9-L~|cfilll*91~=}zl_B8 z)E_2bIg1CdPs6kMV?ISC9`!N-Ooh7(>a09yd z8?^EAAudKMt*24-TI%eSkeLJ|V5ihHevtSL3u4*2P4td!VxIzThphrvhj8=LHK$5_ zb)`q^Vj<@dE(5a~^~=}ut&Xsl4+77V4i8zGvF!iW47?CY3zSGx>Q)QV@71dNYAO7% z;7pf2QwgZ|7Xue^#m<=mj^}jhG*;>JzC(0(nK>-ZmIWo&C}ecyh5cc}n@D*|8|i>c9Te;WoH$1|eLHW(FR zFl2>*6X;stCgU!;yfH%hnNenZAtcHvHyYRCk%)$lb&%2LKDlx`ZS#36AL_*fOSqTc zyoo7RSt8_%GvKxr?wU)cOjM3>0-(t1Wv1)A8smQbIi`K?ZR^9@m3;CSt$pQp>k7IA zcDZz#yjKl3b!)_hg7VIAUPQZ#ee+tr`gDs;C4nj0_c<#LRL(y|ELW9r z`RM7^#;jlUG(1)=()@|d^C@Uq?B7H%_Fv!j@a{0#x~uis;o2{yt`!MRe@E~p{d8&F z8W44QAT|ZD@m}iER*;sPjwP>zN8c$b2*Hxkp*}mtKe5rCZz6LeK(MO7l`uORk zWaA7ryv?NAD@HT=S~bV#Hs?T_2(Kz+8^8VB(EF(M!GAc1Abv^v6>(5{d`i@cxQ)=q zGL21#;054nugWR#oi7QR&7J)#=k)2DlzA`a8(x?8jBY`ypgWmR0-##e{<|15xp{Y%%T4o%^Dri-Gqqh~uM^L!r(bGf94 z50C`i+qAM}E^JvSUFr0zZ~3DK)LyKUHMu`-+F6o)#^G(UaFRCMgiq>x{B7onS6vk& zzCzJ?x7&ntJNK5X331TpeqD*k5po~d_$7E5U<0}Cz~2g*4vG^6GtM7QcP}29E-&%- z*kj(AC1S$Oeg?+7_I5zenvFe2>7CGr(IB5jBed4xxL5hgaxd5nZxqxQ7biEO8C1dX z+$%~=EJWiIPbW-G_P{fF&SzdE`!z<|V^=3deky_c(m>D3#0A)UO70A$%m&pX%LkhY zYrMM&+R5nGSRV&}2ZnW+(WFkhIFA75Yk*r_#c~VOsx6~WF9#brnE0{f5+J9T~PV~L!Q{i#6{qwobc!-%9-xn8< z@elAy0finUexgoj*@{vjt}=Jh44rHbp8I^-NW%zy53E zoT>VpR=yvH72C#;nv9puEuq`zl-713r^ z-UAlowKJ)hga?6pU2+-^sA6`FeAF**a#u(3Wyi*yNDK0NE1PcW6~Kf0)aLe$IHk-` zspE#^ukxf;r5N!VnK)Z2w{hA6_DWtlzmA;QG4Q+L4dX9m(-}NjG&+X9Urs2TR8oRh zN>s^N#Omj{+MmY1;g~x`6nVexzLz(T+Iy(;(^Czym2><&Ym(nj9YrqE@K^rlNu`tx zZ(X5i8eJCSgMvBW!AtExxnsg*b(tvq55UiW^vIAK<_zlCn#>65;SOt5=99V{Sr6;g zC>%3IliUxG;}43H3!!YVi!`r_{~t}~{ZHlp|M5^!LXpf+LPcad*3p!#q%y)W3fUav z;0zM7GqO1qSs9vy%{0xc3Oxde!MhxY69S~6=zh|_(lnrvX%V#{0f>uK<@u&u?~g7@i;@neNX zMbL3QUB2l;F8$C2uKG9mF2?ivgA_9G*l#2n*|>4~TEo12Iqx5tq4j> zabVjQZA`YplrDOf#CJ~cx!Qi;v(ZR^JE!G+g66Gz>57+X?@Wa9_>cc<**Kp3o}r{^ zB%HYEI%yT0r#hqHn_dY&R1^UM19|Xv}(iZsx!Y8wNeH>TaI6C>yoqUr3cLNci5%i~Z>x6i%< zy@@%bFKnL;aSzvzn1gB%Ue7MB213jqqGxwE8W1~NN)R!lXrHKF7C3gIBk_8}E%023 zFgk{sz8Gk+7=uM|6l0$cIW2wN5PF+{nO_lSYs^o~90Cf0t62S_9IaKJF>g(UoMo!U zg^e=l>WL}0_gvrd&YQJud+FeB<2R9a)-JRBmtSb%?yEd>Qj#%I8`_W^vMmtzq+mlT8yAA4=FcWk^eD=f+c_Z5*uKRmGZJR4d#k`A}p5~%-{hHF8$C6ep?mDj~(!A)v zpR2LlQB`IzmzixpXi?Ss>b}R#uFqxHm0n&Fh`ae^nYFM%mpU5Jql+&O_nNo+RHX~X1t2ZEjj zhZtaUl)oHbrB*-?^VlDQUr`~@=blIfv}vNcO2v(%ZfZq#)c~*eSaL1Sj!U|$>|hL7 z<=i7}Wq$&A_Oj_1D63+%+S?`4>5-M%tK8dro+AXz^WdmcT6m%fv)Koj=vH*}m~07| zOf6IiD@b38>l}p=;n}P7SrmMIwQchP&0?e|*PP4ly}xg!bKcz{;_L^T5S&zE;P>!w zaa7GcZh0#PktV}~#XByd#7euX%@>%NI}#ITn+&9cPx%Mp((or$k{a4}WDX!;Ja9-D z+ZtzoOWhnB^TO_|O+fni4xQwFg#o2i$t3x*UQqfADpwQ$Rz;f1tCw81riPTl6Gd2}b2e8h&Yri6@R&&#Jp}oMoSkg3 z$^1QNj0o9l}o`F#Bdn9JK>8#4A+__UNMA8 z5F_J8eAvlv&m82DZ6dp?hsl%TSPz7un$jK_g@`7Cag2mQu2u2l{)1n(UF@_9~YG?HtwjwLNH z_a-HTIE+~d8m(QIk5sS#q>4c@f zOiy=?GCWD`*28MhO^&;d#VyhK+s8+saRe<#n?PPjPTQ_k?2Y`JiSp^c?1EJpSA4Ke zcwadHQdmcG3T2Q2PBaxe@A^j}rB|#@x`F$m%Qm+;UO4}B?tF1)#o<0%r~lpV({p9t zKU8iz`z7s@8tMAd(dTP^HFt8zM8)W!yJ2(mC)Cb1;oT_R?t@?_nT-x0IsNUHWH>nE zNV53-{GM2+IOy~AQ8ZY}te{wgbEYr>u3%c#7VgXpxn zyt&CE*6_?(xAs2s3GIfMpCc4*jdJI!-oBlu1J0Za!={eE>g&9RqIDE zHbm&*v~I(}nE0cN-j((EzAAC-xC}{012YU?bx`gRj`6VdasA<}F8X0Zj zvw}COm#2-m&2OJNAVLvT`8ArX91%fr@t5~<{}$8biI*KE3dWXUDU5xoUQZBYb1E?3DH-a_U0!Y1_+T7sgMs{q19tu zIV|S8Z>zRlLByIf6j*KHn0L!ez9Pg$#X+evD#_(Ofb`&86--I~z~@U;DddCHBm4ez zb(ME=VX&DC(&~xY|DXnJ0ipk9GaFUZO_JojhH^thcCy=a!eRM9fikkYuTy=1Wve+F zFJyp%d0wcwGWizb2@J4mu#YY3N>?(ObhOeS3gx8ML1H( zYSnu=05US5600=QR<3QwVV{*q_?~U{$&uf=17^k51L(|d=#56vlvfpe*CwQC7Qmx9 z@I3edV7$NYNBF?rx`scNhx~wAwdDZ+j-*Y%u8C=$wPUNvUH+n^x9}g0!Wor&!gGPO zh=`4;wcU;o_dN+Q)txj-^uovG`$7_JpU7~7xs0=X1Jk+k+U*(dhzHb{9@OjZBv}b8 zb~YuHj-iC^ZHl14MHv5Rfit_^YhDcDQlv|P0U{tyWh!HeI3q&zK> z!_nR6Lj3s|E&MYc{VmJ7VR*?&J~TL}*S)a6J5N(KjLd=V?R_S7@}cD$)gXo&TtnrG z68HYY1HgUCqFK6a`e_{;%cl<&NHkp;ANqhkYTJb1L*hkiCyk!Sj{`Lp)`Mm2J~mMn zl{Mj~Y4qKhaRYz))$zVO{hcAMCNXk}@G4CNIzc){uoDzC%B!h5CmSjgE328g4o%Up zxhRL}{O5%^gmTJ$HL|YAjBR~(vVI(oc{r$sULy*j-yY~}H|kGdDEnCdQ-`|eVERzn zKpwGv0&Hc1TmL&yiPhLpH0(2Zy!dc+c+&5J?Mn1}Z5N43P0b!jnfx@*C2bQuOiQnJ zU9C~5!E+zKHGaK2idhQyaJAym_{OBa_7Kr#-7k~9(M#p>$3I7|_s|o});{@W^kMI2?@mMhyn#KM`3(B*; zl&o*chfStRwfP)#JR_)4hJfuo;C?gl>Zn`iW8m8Y)Us1+mwqqDc&qD^xP?o`n01a1 zoa69?;fL8z<5p_UB+&{2x!fvCNNcN2wIGVmz0(4^lZeur4Gc4i9`Vu8Hykq3=Fp(%hOB zxbInYH{I}v&e~EFn|S#Zw2hoNS`U=sB^a@?T-~fyz*VnE4`u!-fGuh zg~Pz9QHv1L$?VI+%+}za-J2umxsF~3s)0iA19sYjAV7-w9@iXKgHqz%r}FlOjawap zpfCI~44rZRyci?J`R}VzDwsl+SAOyYJ(nwQC4^049ihirC)rs>uOCXtutEa}kBi+f zq6@!-p~{fvwKv6hHCm0qFp4MPdt}gS^`h0(kj2y?TAeNX^RX#Ejj}k1d?B{+ZltEk zYnRv2+GvbG`D52eg&n?bMKkrtYx~K(fBt=DDZ%|on=QSSzc$jD5{%DJ0iNW$TFt6C$sv80iKl%nO=z2Mbm|JKcmQY8_DLB&mWZ`&o&W_hEn{uZ{cx2uImnFm z=_j4n?{q2MSIyEIxaGUGi+b4Xzlm7rMy4-|PUMu|V3QRPUC!)wm@}#ejORcC;2I%; zf88OyQ~YP2ba{aL(mDqnN2c>lH*N;@UoPB6o82GX?H<#R9CASc%WYl4YK;*$w_{*(%)(bWnREaYD5Ge6&@x_r+y4 z)M!J~H4hao(q=)UT)6~)@A`#3W{M7*glbseayieLM>&C4lp?`kmhr<^<3p!r+@ZNU ztZz{Pt*7m6irUwSLB7G^Wtrh)d7XpwCtqKRc1ORkI)70N%I7h*Um`LEQi5(7w5)N* zLe9^)zrWURz%vC9Jt-WxU9r&5k_a0I`a&h;BSb|gxUh3V(4iRkaR>9tFDzv$@1(Tl zZ`})AsIXA;$;;=hqw(=H&)rWoSf!iwJ<^Ni^X%#)2*i-lhNZ9mRP6{6ZGMbmI1(UCS6+9Z$9IHiQM0t zh4i3m{NI>bn73wa7LYQzq7ZlbWD&8Iax$KDgTJ3>yH`KC^J(w>$J4VY^Fq446y;Fr z4V!}(%Ba0uf6+%K?Bm+d(rCoP%8N=wAz_0?jD(PFWVeHQ6-y2MQqqxTV#j~NeSa<( zR#36)-gfG?cFiR&+Y?TkhIjbtUtKeZzQO%&mgE|Fq-oqqQbHHLWewaEW>_6iS1sBv z)i^FO_b->0^aeNuCki-Vkk6)m_pPNwlNMGb++Cx{Nc8y^(5yS0-*wyt#01ZRw)@OA53bJz4YjFfn_rAo-jsTJDEM$@P#;CYL>JVUaqAA z1DbuYSbJbA0CTV`iC*~~gbzWJvjEJ*{VNiGFzpsITffn&+?UC{y>vY0EJ?J3+T>u` z3{6?&!L`K_@ueDa=w7ZcTdVTjN%`Vlfh+EE)e@PzixvC+mPkNVV|WJff9bG5vw-i- z{=EVAzL}sFRk+Y%&qz7$2f}WZ_@u6{$dLC~dP=z;TFKad4Vb7qI$!ItBcaoO7Vpe# z@+s{TDlP8b$xb6D#lt2w?Jj?YR`2D8!D$nkkW&f7r1I0>_fYvwGIh<)8QTFI7CUD9 zsM*?xWvt7L9DGlESq%Q4gT2oz-K<#fi2tfV0P;6J@K%>Uo$CdA!%)|d+*j%9=7v1hd?5=awstCi7PnOJTo(tx!yctd1stAGe)Wo}4Oq)p_po1# zANuXZ!^KnFcUCTJa3%3aVQn*e?RU+qv@HN2s(XOPy$yc30)NjQQ_a`iM92?xt-*bR z%sue8Yn);A@3w0;O~lFORI`p+@3)A9|35NM<$pmAK?hTW4&NU|iOPQTe#6#HB53=ZEh3H?NBMn+w9I@RVm8E;+d zej|)*-}anCKNIU&pvs@S`1wJz_vicfGF?(L3suXHOWKkz3?xuko1XoXx|7=}z1aR| zua(EvqQwQmBn#R!xmDJ9U@)}R8eFcqZ`lg*jEl`yF%}Fji3^EYKhK81*#ox{X96-a zHJZ)o=OT=dkMgSQUDppkJ~JR;_+7_RXnE#e^;CrtLn`5Ip>Il7hY=9%hyq1}FPVFZ z#Qu{vlMQXMp$-YgJkDf?3&~yAwPt*vTdRKbg(p;*V2c03jv;uUZ@MqfQW}A_U{x`w zKd3&Fa)09SBb5{=;uAvWj1o^P1l-*YR;+=&Ai+c?L)c`Lp}TFbCfAFAHpjo1IjJqC zaY^-wPKsVIXCd~&vTlS0qN8PM`?lxe^IY^Vkqp5dUFs7pmE0#yBlFPGAG;>Df1Vo8 ztF)c^!KooBSn8JcQrFu3;VkO&FG{&`P5m0L+Ow;+ z4PdYMFBp%BX~3-jJoh=|(}N9J&Z5MQj`gjBnEa%LU)<`>7qu%q`~E%;sNIy%4*vai zyJJcy{0Hc<&vSFHc$2dk@%-aaqYIy^=Evwi@$o2pUfW$4I_B7r?V)D;N^oCELE!OU z`b6gTth9RIEG&cbOfF#9U&--OWPJlT96&p}^AsS~k!VUdeGdsaJ?2O$Jh~27^#SK- zd>VSuvVjHMFp_0nccD)gR2pg_qro8m5rz?EWc6ayu3p3l{PkhX9v7(oNFdm_J{_Dr zeotV-U(*&janXH#4{@1rA65mkf3;^@1iNhYnH}G&ygur@w3LqW{)tt{;!r8Id_yQi zOHvVd#IPYv_Z5gM0A+tOAsM4iWDr^YH`VkC9J8MQqi$`F$HwPzt0$s;1_fsGlz#V2 z33f%MsB~3aR00KwcC51$8v|Dcz>EE9V$M#NhYIZEp6=ImITPArJK3r@194Uh|jaIy0qwq3zd}WIP z^iIbDvbyrLb6{eaJ>5cAJ zBD(YF(KyhB1!wxe@{c4^2@l-%Wc-^<<7-qJ9#BImM%doEXEhmf5wW4{PFz%MS*B&6 zldMks9^g^7g+=F{hz-ylB&ev~jjJkKJWN-ip-NSZ`)U zodyUe`nmb=yoAx_H^X_B_Bf&_RwjueV~H(yL|;<;Bw21Vx> zaLZ`yYnao*sWKV4P0sI`<%Rh_Wgmf43ps;zOpxA?J3JRR7aIGTxA% z`Rndk@59&90r;qZ#V6-fNpZaTZVybz+K3fn83Ywbm6rh$91iECu2Ik`jcLLF-wPnr_rPMANEX~b|wPsvxZ-TB^<(61m?$vQVD`!7w znZ$vQ^_0mBaob(|@vI)X+Fmi-m@@m^x?97|VD<`lhm7b5n+b@$-5{OGZ*mhb73EBy z@VQK=@p-gA7D_Iqc^B@mnXtMKV!em}eG5Xq`YIv4AqO!t>YMOqPyUl1)k^rWuEmby z=Xovj9tp!v>I*JW616xM&LCX#zQ=mv^^ z?&P{w-cjApW=~%^``W@es7@*2ZC5H=tj}&rqECzD=1!4CF^IY+tmOG~gj)Vb8#l=p z?vFEwF`;JMf^_iO^Q#)p^GF|G8I~qRd8#OX(PtRcG0<~bcl&cCq3Y?M~| z(Z#1A4|0ywU&}WL0R0VeVy}GGUB|VG0}=cu5}HSBN6q*UsCj7RQ>Mx--MQm(MI7Tl zF3WbO^5RCS!c!~l{slxyW20^iffO=KY&>$H3lV>%5+~}_*X<4+u;AEq<)dp+O_@kx zx)dTg9o)JtJh9}_Uq@`*kAc1XJ~KD6v;B+MG2THC@q`^{Vr~GQpTX_LCgNn^Xnopv?XLT?ZKOWi!@TPkXQq@{l1tO_6Z@~V`hJ15 z@dV!+u7I|D>1wF2_lW3`$b6ceJSZTgvMpMex~NhjKgDw=)|g&ZgyntpNV;L1)$pc< zfG&OLkqFh8`9WJwTEksE>01_^aW}7M6}I#mNOvDFhlp$Z_X&2Xn97Xw-sb&JW3oMU zu|$IejaPBrlB_!oU1(Ee1_?B1{`n}sPYdCj-UI}ek$FtpQm^glFcS?2?~RmGq^fVA zKBv$Tr!i-Fzdd)Twx`!TbyMzD|G8dBhpc9kiDC3q6(uW}f5I#_yPVtjv5U5CB9H@q zE~WKs`7eZT5m^R;t*e; zMK-n|R;#nWufKUZd|C?AFntKN`bWeQGDwC9@6yZ3vPzl*BdR84F#R^B(oQ`BS1*gn zcz%em-hMaa$Xx3R;#HyPdc^L_QFT~>CWANIw9ZF6(f_`1$&<|Z_dIMJfB(5H_r)QG z3Hth?OdibBSJ(o0-o8aaGg|Xk&b_fWhj*1m!+lHC@B6jHi7$WLr#1eg-TJX)6r|BK z>H8=9bwPE;zM>E0btB~6+HV17W5l&@7@?~7 zi7#9Fv|hNn+lD@XjbwVon8@9;;SXdY-!7@esJOuo~qc(C`KVdQn5A zhOi{Lf5aDQYoHcjwfr~qv?2rHl}V`YoRILw5fke-8&%cTx1j!~1AZVY_)_^P=-$kg z3Dj|7tO2RA*-B+{F(UOM#UsJCMF7*A#ozNZ`&!t zXxS;*b-Un6ZR9Q~C+)R$>c~@ZuLmM1!^kCGkSz~2M677-T~vxE{;5L%%vFv0*_@Pj zNwgqM&*hUv-7H<7Udl9}dupAmQ8|accdHs~QFbp}=Ep>pF?1ulTxIu>s>94MF!Pv^ z-s-q}Q!xDKe5N}?JL?c*-`epnOMyfu%`|}6eYuXZ@P~SwjP?NU8+{B2yMnMIZpSiv z+pqm$9Q!9f?UTAl+ti4?>3La2^Sv^oPNpnVLrpf zQVN6z20}XgyfT$0S&=2tURSpT43AV3O)^d^B4nEb#svZ)Cpl`W^szrG?j-WW+Slb} zi=BpPkIGp8UKHOfRmbh*v-8}5`*QvA{%QNR+AbuHrahB|n*K}Xp(Q-@?mXI`T7htu=8FM$7@NVu8XbMUGHJZ z2VQsit;4NQmJfQEUf-il;Jti9ug60ebGuDp%Lm*3R^MI&uU$7iGoH6*IFtSauvmyv z?K<5lEy&8-{s-e2gthV4QHP^y3M~J$q)_i_*s>ux)Rylu=^YXNga7Iu$cq@1@>A@Y13RA z@!dVvr})r^w4z&})4l}Ue?AGT;XDgVD2~D%?9e{>Y<9I7M=MFzQdp~7Ax&5b2xmM) zeL6R(&N~?$!x4ADSpw(|u7G5;eSWi=Yz)TeOoo=a+wD}dpSIWI%8K3!ZHJXNDUWHV z>*P%3cZMQ04X8#5R;66>kGAs0Hi7=j=1txokB`+L#FqqD!@t1CMSgciiY7L{hju;# zt|v+Du4Kx;u3L}&4L$x%84lRRHZOjDRu7ylY(whqv?ZOYjgrK6x?Lj9h<{^Do-T+44)h)mQz0 z{JBGT8R(VeB%|z0fs9#Qn;q=#jW`faA;)GnTN~X7_~8CQlAFAP{xmM*nA^+pVD&UL zzDWijabpyCbO?8tGbnz~96%I<)*5*lFzZkFu#?<3do1*)VUMH(`IMCCy^oW??~$ss9BC~HR->2M{i&{fd)2<1up8do6Q z)E;$)796hwj0ed*`L2?M02054 zCP{!6Ka>b15!#d4&oG#qL~a7x4~d(;kGN1`0RDm|&On;(H{+741BNNj0~!ykgwg{; z+p&&Quk8rJNleJ4_vh}l1T^MLwrnpxR@>gZrIhi6Id(B{o6M*xBeu*&yCH_e{}GHp-2e)K85|SUBhG{0M(s|Ww&_^3nSwvW zjnAhxUmw4bJvvRT_N|CFrn4IE=mRs6kw6g&51ihX zf~4#y?=ZN2V=Lp$Dp!qr`+@DUg}wJ2)Hoi&&Z9@P-ov`CzA{xPSCm`i7g?03{m2_ERoyBVn8<^ zs4G7xnZht+m}R}q)Rnndw7_>8^^WiMgG*0-^~C&M_K?sO%rQb-`gVN%;@Fqun7G2| zto+K*u%DN>H^kS%WB;4e7lSLYI6J;>QT^QtppkoqDh}GdKMk1xv0YEuHbl(DJKjKR z7XG%qS6r&3S49?Xq$u7EJ4jAp6?w_cEBaGvfTrD~=t?Eh&fHF~k7db}LYmqBmmM}U z{$Lb@#Df%SyfX}}eQ8f;YY?hq#*k(1&(3q#tIXod|G*#4A8q-$)mDWOQm6uy%;iS9 z^q5k`Rl^?VNH*F!d{s^6jFo%3n}WveQzNdYP7e)fxW%}zMpzdGZY{kpXRV>}BS=d%zvtrgo+T!Ei)q6t6;M4e3{v5EuW85ZC zZ=K_&Qq;Edde@#0za})=0uB9c)4^5a=(QzSd?L?Do)`AU#~aO%=gbf3t-!My0fNF! z9S}~xeyqtf_%NKkK%%2R#O+F$>@9D4tk~o>+klSZfFg|6 zJw%_Ix*mtNuw}@n^H|v|7di4X72EjYj%8pb4?zRwLeQ;q+uF4ocDlABGA)h#s&Z| zYdMYpWeiK!W|9gyt=Jv4!2>~_x5CfhyCB%wdl%PYIwJsBb^v}Mh|CNiS>4(T%vR2s zJ}38Ym1g@*Dc{)=?Xdo>K8jx$asqpkIeS>_PtN?olonR-5d7at5&UDrFuJ$iMD&6} zHdAhAm#<+P%q#c>{W7O^TPZ{7vfnJgG{2)zE(g#9t~hrpDlR`|BHe0XGPbW0Zj6>) zF1H*wZ$Q0UYLAP~?MeAQgYFGa#)G#6D5k* zv|+K3F5TmM;vFGp6?S{4hueG~F!(|?7c!lR9 zscGQk2|Y=SeAioQlo|UOhH}hoMJrJm`!qo^DvMBeHSS!nm5npND=&E%bnV zy(J{-+phH!HWw;u2R{=J9KW6palHWx$tn<7$p5-bcyRJakeduKYd_n4hpQ-V3noY1 zS@aRENH8?uupekb1wq8u8qwAuaC}eg-t5slU#t5zKS_ob?oKj)$sp|RyGW;Xp)KGKxf{ezy1 zhEuOvhh?CCP4U0$~EA~uFs-$r=fA0GASpA=CGU_f*7Gg|lw^}^r zje>X$kECZkoO?XN`j0`tN%dj+fWa61y@f%Pd9el97G2ARsF zRtso*=h7qUeZ4Ix7ddoZPYiim#lVRiWR2G^w(Na2cr>^Ndm4_*J6kp~uhfrgUVDxX zJck$uHqW>=%}JPAAOlXdLERXpZG&^l!eTi+sc^Cd46y#G7o_Nv?o?pZOihzN1pyNZ=eowy4t&Zldke1@;O+3LFpx7#>>y!xipbH$}R)gpxkBs&f+7Lsi zAiK+!$ID4}n_V>%CYN&pz2|_pJCOHP_7AxTjyr=g=*AJ|?g8O%7*^Bq0=k8vPA2p? zF>eSQCwda$=rpj0t*EB;GaBPAq-F5h?Q69W0cP)cpxtSD`pALKyj!zP*6oc=C{6u5 z!7fo{%T$GU7T?7PoAO(Y-@YyAmtxYmBrxP4aJ2+T_9U^bTT`Vpiy5AjtOq7t-dwb5 zoMu*@{;#i#M4JgV`Ek}v{i}(q*CbDSp225%kmqroS6w?|oB+tJv(Z+{ydyv5vt5l? zdl3u_V_pI$9GL^lhsGy-2pfgmp*^G8M6ARIf+X!t5XTge)>V5T`c*xw0E_SXB<;@W zUoJu2@6#kZpS@@>y)&+XqK$Ba3pKkSan?iDWz5+#|8(&Gv@@Oa2fciJ)$h!WjDg8M z5S7U|7i+D&o0wSRMAe%_MOu9vzT7izZen3;+oBk3S%0(w(OoI{(E7FDqX-HD=H0=|LI4Y#`Azwx|s76`9>Wrac;>SW-Jy$7cD9QJD0 z?q&yWH21CtSUViQ@$>bG_iFrc`6rCZnB2e=H{R{3@u(0xf8DCc(7{8 zHBP4g(Y8ISy)rkQFQAE1js3<_R!07WoKeQW?{^+eOywM-VFt_ASz}0JG)9>0H>!^B zo%dh(Gq6heh)wUY_c7GlA>ls>_58O%nxq@EPV&uhfoCRY+UUPkbVJZ(NldE->%D_- zNsiW5hDE(!j*~7M^4!P`eGmT3{>|zl+h5gN9kH+x^M>VcfOSIW7YA2+1+M$u!|oFB z&+-Yp4cMn2d)C>~la9IPYYZ6uP3z#VicDaepnE}nqQ#3EEttK*gr&5D-v$$%?nIrB zUfb92;6I*c2RrrGkz0q68?tQ$HrPJj4IGIgw&Z*vS8JucVHfE3I#a&gJaN|CY;j3_ z1Yu8!E)My|RF+m_&pR6A@tE-OCzAQ3@2ng%h1dkaPmk#1aqV=SxbZ-yp+pO5wJ@?E zR*c*}v8T5**?6NNMf{)Dmx58?X|*V;J4tJ5OY27zV~x%C!0z4h#;934jy5-iYNkG;5eQWY-7t|-Y^AT5w+%$XYE5|o>>$vAX@V@lb9@U!5 zDECFljRmDwSu)f==uH!vK5YObhO`j%(EW<~ewnF=bH;B3WU#&i6;&pCME`c>L$-qEc#JXV(KI9ieP;j=x^lp#Wt zK7_e*3^yz1@5NtP67;zV5Z@ML%Bud3DezaZYn}l^HPUgCKCc5`2FmWD`aPpZhw&UHakZAb{Uxi{; zPB)D{HHtX!-k0~J#aGb|Vt?qQh@JS^QIR$dKulio>mpD&81T>@yfuU01R6qd{#NAI zp9d{)=8*mCwtG#xw1dR!QrdmvCC{=B^5D7S)j!uyvo(Z@_k2(p$H0bB2VN_zMF#AsyC-#(PS}>ApKuU~-uavV+3%1!YOif`bSa|b zPvJdA^|JQQ5kIcP9*$~5djiASn)t2oR}{D;G{-Z;uMo0ni=6|8-80T=@5)NmoMd@Y zP6y}jte@8@vi_B;WYjA>wfp_FLb7GFM=_g;(6`+xSp3EymLa5#BP{Q%>RM%sf7JVs zF69!(K|VWtYs(-`(d2dbk%aPu(zuXC2VFUEpV95pyJTkC+p#grxv{Y^Hpa-Sk68R3 z%SYZ8vAPuh)#AxRr&g7NipFLKQh zcJ=%!+_~;=T|wPiBXqxx5{UfkRQIVay?g*bH{Y&rz>;CxWTK|eR%YXvbLyEKjr-c$ zg%Y~TaQOV`%QR|?d(Lr15}U~Zt0Zr~uvb?p!)f4t*R|u0tGZ|6U3i*$>*VwL~3KWoe5MKqFD7AUqR99EQUa1!Dm+Qh%XQ=q9Y3H(= z1$j@*6+UWsZlmo%_!U!8xuI^;^|G^{Rd0jkQ^CmHx~m-T3V9Qq#XsCdqCVyn4>S(_ z^0}K3u#8svV)SbzXnZL2?YrUr#ZZkKZeUBwqQef~kApi7&?)=MV#eH-`tiwpRsp-L z+mDowP+GrxYkQEFLN35EbsN;G<*voJO_&)0SAX5v+~29=c=ymHliO^gAF1*wXa(Kr zcKG0w3z*ZPqu00SRS-px#=vHu_4c#xarfT4c+ShIy+_r7o6>i%+IK6^g%djrcO_J+ z+=LTno17qTz==j*CQbk$qmk;OInJc}K&N9vE|2t9-Jl=7*eu**39xNeK8)gg{yv|KWiK4mJham`Wq<Vly+t+-yKS?A(zcHa9>B@ zH0v+0o4=Xy!_RKC6{L#zt4UD=lSvOjCX!0wWny{`K)7?gz5n%U3}q8#abGz7d;Zdu zQ#1z$4tQK(A|(v8H8>@Mmz>#T>0_w~K!ClVxk{`_^j?b@sK#69cht42_N76?^F2zQ zRZA(bhnoUwi|0AO@SdwXHws)n#37$T)(Qw8cc;AgmnB#r&wyKe&QS2J>F5Kqz-jO_ zK`hY9DLONudPz%ow$J;PYIsSbi|=fe;J-u=9(=8>`~O)0i8qRR0kR)~|8B2CJBAW_ zInXOTp^c9c=QVd+G%s0yHBFCXLbN$D-hs=Uq4tf0xM3-_iZ|C5Vs&$ zg~`V*i2St^MjX}7-AYYb<8IoCb5|FWd4NYU`cS6U?f$wLVz;~F^eF7j9TjC7JJzH2 z71UxSww9a{efQNn$I`#AZcps!zx7S>Y>zD$YluDdqa}^Jgp>)LJn0hDmC^-CaVpJ4 zJY3~q_^gJwU+Z_LutKExL>0PjSl>EYD01;@<~hBw(;Hei7Eo9k_lV8R*w>RwL3VF{ zl_v3p;25fSeil6vv56}15}cK2^!jhmRo`N1gcGUvFSY7N=Cjv*Uba>h<+>-bP9R-F z9h`IW+ws(!y|Vd|lgZ)7S6fHRJlWjIJoGF|x3F;gTB_|0o*v#im9N@#k}}n23VUlz z`p-Yap{$o44CLV)1Q~4xA5dyAsz>I}S+h8}U0u_}hEN|n?SnG?->2x&hD=gD_>q}~p-1`v8Q_@FQExp z0)pZCrItz`#Tcn|3AJ=@`{{*Y*NTh0tl2~9wUPX%DqZ&V2oAu~0F9fg6r5A$)V})P zs0G$LI+4LXX-KWlw5j&{Pvjgaks~k;3qB{GWZa zJZZlV?U)XihHIP-Q9?kPp-3p*yzS8Cw zEae&OS+^bUkIPEoeIXn=A+0ww`+J!4mUX(6(KEBLtD7%7-UKqRH<8QgTy;#g7P|Mh z-R|K?8~;t54V#(h~m7NEnqQ@xcIWAawjD0>QJ05c_5^k6{d&y`99e zyu_3eP^bC-E4a9SsgPa34$j0IOPGuG*{PV_Nkz$7-`;1>SX3q2#{_XIH`1*=&z%q@nsr;TvO) zaUumSI++`UHA92#=L_dW$?IDEbhwQ_Q+jsG=x9pDdaCJh)!pb{a$5c6XAL5xNoOI3 zNIl zC7J;nsXZE1W9vNu0YGxD0ibHX^Y&VM% z1goTsBK%oBFr>>#YB1-lu}?+p@q7ftGY`j&2H}g=`f;hZ7nE1(*X1sen%ITBs5t-B z8|vKTBbMET)Js7m(GG5F1jyHDQ5CldF<3GvWb0~seV=-?*>;B}{@Adjsy=2w+Lr5v z!Qt$2(CG4NiKEg#XtoU#zf z>+%;*j%xL?tU8RtuJtwE{`2#_%_`qq1DnP40f14?)inhVEo0%>pHwg-ePOa6G>e0w z;gMj|Mu$^-4VM1&%{a7lxDt*kusupAI;tr_wl6k^`hLH;pvDgc%0=*qMGJtLpG+Pt3A&gHB!Z~itsBkW-OI`8H-~C9LtEfT!a$^jM~bd@=>>MKx%GHm?{pB zTRm!!83Dww8EZgC3O)m&?4Pom=vtV;?Z3$$&8^&&%9$G9uwN|*|2nv|*^e9ixtS7v zC9QO*I$wvERpoD2?6&j!Y&1;$6uqqv3?D8jw+qZ(a>%ynZy+n1LIBQ@h z<3jUV3VZ2qKt8tpem<#hJ9IA)Vl`h?f6*b`Qg`4FXtBY>LlAD>$Euj4fHnhLz4joG z92lo!fUJ!(vO)icRcbxsnVsSCzI4BCVCs=hjP=qLd1>V^L}YrZkJTtj{18Vi{hf^{ z0!9>CdKsy$q{$Z<#l<1^7r(>^b85c%LH|vNMRjQyR;(K&ol3&86$uKK*JpJnvdsZ4 zJgnLRM$Ci~YtQ=@nX6jRx*y6j%^q*!I_ z@p4#i4yD!TiosHMuaypkebgg2* z^z^H&WwY%!+e#xR;jvn%rtyWWv4A`~Bk$aSeIe7W-Roo5x706K5j(74;S;UzRY5Wr z>gYA9yX^D5@$i)SWjFZVo8j@)n~Cp}sS^El7Y+J|dZ3e1U>2B1R z_4nCVqg+Y+NT005&U2YytoceH|cGoSIF;$LkuUEGS0nHM}xPe}Dz*nT+rWPLtl@UD+P|u$F z^XHV0+s_l`_;Re3!Muz(ncI^$U-Q8=@udQM(4j~r=bLBk7F@)!+%KMWe=~oXwWEUyMPPc4~*Dkk&JorhUl&lS~NMQLMj`Ii{D;x>&`k*Q1H|7jf(6Vt=Y_ zTNZ5l_-056$h&XQ)X>N_J(cpHa7lMTlyEOh!Bp;_<*IsblS-c)F|)-}!_$0C^qY;{YYn0U+)VIZ&tw+Bh`3(reKbB*`>-m&>_G9JgRR>>nEsFWsCw56 ze_{hRN};Vfo=qCr)klQAlmTlJ*H*S9LlF;S-D^zFO=M1OHP0oL$4aS{=jfvB}K`28^X&0E`Z=N|pFF|zVc2x25Q%OBkx`Qdjy?{~NF z-2PP0j9k`#iC?^bj+vd8?h;YEnX$hF!)PP14-x2hTZ(1N#tm7#ct#jU(y0#pnzhNE zHOQ3z^=H-Kv0{yNyPbMLg=dCHJo z(`|PtT=hEfs$UOhExHKm_V&cb4&HZL5(-$aBZlqhuwmB4^)mNI`YZpd{MdW<=uNNB z=u%`eMT}g1-Bu5r{I$&lTdDsv9=j9IqLa|opqEr733)Ou<=s9r0vEPWI=7YN)5@p(B1BJpbvrD6N`O|}=XOn~MixL?spdT{vHcJJUd zt7&4sqe+z$2U55iRH+-0(KPnLHT%UkhG{y-{*^Y`UJ|S9Vq1$UY7G^_lK*5+D(cY4 zlWWzBe znW~lRmiNm#{@*Y3bNx+=|73Imd!dcJqu}RzEP&L4KXs?<7 z0WnOcps@U_ynU>w$CWIYOkMvRl7;PTmiR8>+Hv4`wuc>4^C!J zx@&mK7|8sUsaona?N*+>a3GU=6B)!K=UR)h_<2@kFrAZncn2(32yb>{ULgJd%wygQ z4|Kw9vX{3Zzipco0{mcuok~dE{?ttrLZKatS6)kQ*`TfZhUfw#q0cNF&crbQhUSmT{6VL z=X9;$Uh8uB_*(Syow4g{xt##MoYAneG<>5ew`s#|h6rTwxTzBOyOmKXmz&tn!6!fc z>ygZ(kZs=AM5U1kwphi-#%8_tgu{7<*lEzSNEt` z%BSnwfOMcLJrd@2amR9OscD%QRq5%VPDa4=3KAmssaq)=I+s=Ok)}~NuiTySDSE8``VRi3?iYhL+wOT8!^_v_E;=7al}UMesS0$vhQT_vfqrp^ekJN1^8<7z3DR`ug2 z=CKU<7#+RN=id&w)E_e|%451s<#WG>Mtolzuvsx0>I3*zbi;u?lYxFAu-y-mpSsGh zhk8q-+6z{sE#I->ymWMP=i^N;$lkbbE!$7nPg>HxQQUoYu<|$;bK`%+o!~^sqKhtl=EtMzV8UJ|v87m5pFe zCuvPOf~`2;VRRXJNIrB_9D`a8^y|pqiP!fY5jw$;ZAf}fcQecuc~%kPK3#{4_qsxb zX#fVnVHB`=6(^*65<(ig1^>lJ$4$>ja#GUX9qiUC)7A+ikpQoaVG~t(qJtIME&l-5 z)sJ9v(zvH`2fR3M$Pl{Wd)ooemK+8@t?j6 zM@)E1X4<1c9;mmu8;1{W6%5c$Et%^18E!Y#=ee-+C{$Q%3PTlvGp7BP%iMHH{&2p` z{)wfrg)JCP`JzjW-Tdp>E8mo3?~_pE)f!|cTx5prier17@1(v1;oXah!Ml?N$p6{z zh#2PKb~TR4ueSBRBZFG7HFu8f18aWXX8^&u=EwKNpXJFOW-WB_E@d@*x3@?1HCEs_ zoL_&c(U7j?l!#IkPyJY~(=lE~N8j*9i2oX;io>5?sHW=UcK=1xd)M!I%rwHPi=~)+ ze`0@za~R~{BB)Z%&}&*nEXVzDdf;}lY6=9Ko#!QI&PJ@6=ek}I{2{e{%>Sv!B2U}c z;#EBaLu??$D%Mo7%7>L3EruHF{8})mWJMM|`h7#*(;N za&zgd!5lpg-;A^jH0~0zU>UC41-1pojE+-y8BOC1w)(5zH91b88vZuP!6Za*maO8z zw?2gwzZGGF1(>=wF=Pb&TOO_WhJO`CHg6U*drD8m93$9LU1NO*Hz3<0V>|uCMt~1p z^m$X_s{uyn@c@rloB+Xf=)T<~0kyl$u`_6<*+|%n*HgFT2R#DZ^I9~8N#^HRo@Bw)90U&(xW!@^=WOgV z^W?L_286u@FMSimW3(3y;~g>jVl?yk7xQ`Vb`pqWG_QZ_O9<#GuKw4&;}&R#77wL4 zHq60Zgs1d_&|Em<%IQL*Y|P#!%K^n2eqeB|`;%hXxz1$9anupI2>{57JGqY^FF~|K_3Y z-#xFN!!%z)j6B+&9Cn8Gp^YUBye+n@U?`;7{HkgCIO&&};2D0_L zK>GCWd?$B?Pbrr%#8QSWN!vI#DaI=~u4)=vkoUc5uFxx0(C^o}dYl}-6S4W4&EQev z6e`*G!tyk{cy}E%EU76(Jl@VCYP|2fXLS6|mgoVTY!x9^>7j5EZitRhy>tU>Cd*QZ zqC}p2=f1QqNl)GBBl@K0OuhhLS!8%uF%rtPVxFzVAjeuHKm5<(!QhX_<=4gQKXT*8 zf?mA$iZ!zzdFi4ohk0fExG=p?S3!93tp(xn2~*t{$r)OGG4fF(3?G*(WktMvgvFqL z+CmKDXV;Z!Y$CbUPgR2ox$6;o+<9VlLO$W;P&gDZ+i=EbilH~QT!F}vEFOWbG-!Ls zGl!F|r0xiC{LcH>-Flh%2F1;vE3vx|C5p1Pu>Q`(z zg|=b^%N@+=a)kQT4~Xjb$iW;1zyGz*uYi82Y3xc=kSC5JHfh$MBl-JYdm z3yii!1bW>~J}8A%Y3AOo_K#S;QmHHrFZ=z1esW4mdD2nKiE$1)R{UvSXQuAwfC>-J zef-^9bySk)#r@O%Ft3Jw`h6PU!5c&j}Fk=!8Fa=O-Rf31Hapk7U~$CwfSI&)1_Z*LoL1Lz+lfuwd~cuw**Ofnat!t zU!=SYejkd~{gWfBDDzGmX&OIwceZBt8w`RBFjeFG7 z>4qL~3T~}P#89~DWDfuJ@9tl&eu&qhHukE6?wqB(U!kC~=?lA^?nRNqU&qyDpTLs^ zGf9}kzeEP==JRIv>S_TYU2ap>diU1A?;EB_uZ_<^x2}uvuvzOhIoWR&PaN;OhyD;f zTDeiVt_gTHi1^vvL{9qRK0!Y3cb&m0`Ln2hKEgOIvlb4t!h9Xn^&4|FJs&h-97z7^ zTUj#XMc065u-jf#Iu;pw#D*Z|8gAnM!cX$2npm#dz>C^-#iFi(yjM^C!Q(S+Qv%vk z(c<0B{88UQffd$iy;=eM6j&uO^Tbi^wCLS;C)+?d{E}coH=kd2;YIA{4rRDJ-rxZ? z8a(&nLSNC?j5F>;2zpjRRyeFk$3b9RG|Pu0B>sXbK~)1b)oc{5X}(EgJAazn9jZSR z_jo+5;J+k?)H&_J3(+XTZd|dymc?4LD)AoWXG7*y*e~Y+)X=dH z$HTi_fLj>-=+T5+tckxH=FX$wfGN@EG+Da%iA#(+JypB?_;dj>b6>HpCp1eH=RVHq zmh&+D>^RI4$KBzDpBUpgh@(kzJx{ah3 zoapE3oPa~J4je5^J~y}R)G%9_x_YZ}Tj*cwetjMrj@t-N(Z8j^3+Xq?;!S0y zhZXC$!HYSYqYXGC{F@UQ(;9cjYy-aN@f{I*MIF?4ttlm zUeZtaUYTp#cC>yydHv(`EvLkJ zp0zxfB~vEl6O7@QQhKG4Xs$7!Y1WqV;d+7FDj#h*e6l>q;Rf}5vi_-h-C5k)STmGm zl;k%|+3`;RrIwLZs}sYJVWiljppOsU7X&_)j(S;56IaMSYg_TXbD7_+Rk zLB5Sa=isXU1ijTLB8#Cj^%*b0oZ$zP8=TeD!@$1=v5``b9w3c~9JavR)GggzVl3Pi zqXH7?{o8*pb`evLO6-Oo@!eJETt_apQf`N}o`ja*x?!MtEJ-_^*Sv3~UF}=> zII-1bFP?ER5te^)#RMLvpSnfK=c%>$cn9uqlCf^S+hq%TF?!~kUzOyznzOzN2^Mts ze}fb1kxJpm9tkwEHII^iPn18n-7Qu7Gizz0c-n8{i|g6RyZJaG5H=R)$so!}KDGWR z!E=;t|0I|=#44XtijV$NL)xG3w$G-Wn4d;J=9}z;5sG30W1y|7O4iT5HS&%THH?zz zQtjStb$9yq z$~%;u*BWfbe^g{+Y@uhG+zVCd*Xs7X>vt#BQQY|qx09^HE#aEi1WIa{3MZ3 zaj+v+beC#wgj;NSX2TL`>U&(8uK`*&xmm311!cEg$U@s5=AO+ALv#semjqgg4d4xJ zS7_9Gtozo;vHW<#J3OtgiH2qry}}lRKkq9|u|=%$ zyD1*UQot_hfbQ@hKk*fg$qhk5_X?=TM{Kya5w0D!3vVwqpx+YYevxbLd`3jpfq-A2ut(qC9(=;2UI4Sbq4o~OF$X>;t4HsoZ zAnfC9H25}EeuDya1SpauEC=D3WCtlrfx#?%6i9Fb;50i?M|Qy;)sit-y1iU2gYNdt z23@gNM2>;QX#e0whrjort9QjV2{pE>t-Yu#~QyJQHkksV4-(O+5RdtDa9a;E>Y$GXW*aAwjbWU^F+4kc~qItXZ_4>clzrj14EZnqKzn?`Pk`A7o>X zzo$$lA!gpx#ly1szFUOI*$SFVTRMK!dfYNyde{GiiziWRKJfQh_)g;WOAf(hrHMGG zEoz<3Kb9cYotf(vZTefH!-}FYka1@jPIEe9ysotB&hcdVZbUid`!M|sE@ZR#!=A{@ ziBqU(4X`-yozhiZYB!FLW7uX0f*@Y;Z`lgs53Xmb3VEwSOiKEU?i;nV;TBg$Cc{a5 zQ?x|XD^;}M%yYU`<}r82pEpN_w;nUobDZqLf0T%PEwqgI}rHf{}Iq}Y+oh#2{)FxYlVGn!1r z-6hS!$-#Jel2?M;?L4br>cviK^(erNF;!2OCSuNKqU}^N6FrfKM#Al7k${Xg#rKpOWo7Hx{|?FPHT2ei505dZsLJ zs;e@+l|sHR*C7Pd>d;J#C(3=Gx;K7iYrL-VsNy$Y2sjjGJ%Pzo7XP3w_I}BUeGZwx z6rPf7J&y|a|7V>=)r|6Rtcmoq3z!NnAmfX;>@Rmab z!WS3JL{c_ua~2~}f5n~38UZYGXX+KF>~!4oTY&5%mTwFS6cZBje!{Tj$W59Pqgxm@ zReySauVr1=m7prXuwjxQ-{7yl=RKklP_~%WWjOIg$8{>i-wadW+=9bx!HhJg!w>45 z+kG&ue%vQFFjo(x^(f*<`%!R6@77z~obwjMtU~Rdl{^<_>`*kxyQu_`W*~Z*U(uZ}4FDWYYIH#h z(S_SDw~{Awj|%_1(=ZU0CE3*AB;y|r;nTSOL_<}=qRWa>={PBS5>wv}t}p(-O&b65>5HEx ztcbCM6q+dlrT}mv*yi(X_h-e;FZHhwX+MczYMhaTTtjhVC`Xx^k687Dzl1qIjEk@12G|8(n#r|X1V z$R>O<`Jf#=*91;3)_UT%r}{i6vWs;QQh?Jz%DIC8^18+CKj5 z6PTKSiuk=eAC+bM?vTA(HMJa$QoRfKd4ish9iK%1?biSD2^`NX2!gfbqI*xo2 zrGH;VE%L6e_rEH##n00hJ*-~3oa!IZ0=NscK=&VzmJTO-S1W0TaO&qD6|+g3JYaiV ze1$2WS}<9aR5s<6mU;ZV1U?gu{I3R+7DjO@T2RZ9!jhMN`pK1iG6n1tes3MCn}e!8%>s#v-H`xx{NW5{_oB0fo-GC&hM~GX8AodE_hgAu zvDrj*dmEX2)Y;(-fD`+yAx@-|^ISn(J<>CLWX{Ikt)^(5Q@gLwf z{b|%*&;8m>ahbSl*_`vZI~G0w4{F$(Mt#-&t6CN{-RC(LO#a9)?gG5dM(W8mF?v5z z8@F~0bkBwyO(!?5R$^^-`%Qh^y)Ue9HDZ$hEwMZn!BQm_@z#Y2E{`lhEc&pivPT z8<2%czAQ=VjWEvXc0=PQttKWx)Pd|9vTW(N zDPQfQSAR~_vW98;-M!IJU@JBibUNQsz?q(|@+BVY>)$6o z{sUg(oDCVB`(iTRR4^*5jk8)2Zdi&#aLJON0o&GBCjlo}WHjdf9g;KeIJbAEp%bru zcF>Z*>sjlVBb&@q;#M8KmM@p+uQP8EUSt1|;T36TG(@&tm-0VN83%Tj)I|6|Dt3lU znB&*y16X)33oDUQ$Y@SgtLTOIFa&5~jbH$U|m)(X*$ z99))dNonWAa?TnU3JfR4HzbUOfjXNyMlg!#m&j>;ccIbV0u9O52qc7Sj zJa&^8ARGG?SdU}s@X95oPT?ryQ6rZByn>dme_ERd*ktFfe;Xa?(OMWJ2mK-1-?&^G zA9=_O5N53SiG(yu8cOjv&~qzx#|Qq3^X9{$q{SXc8-g#slts&qiTKZ-=4PV^Ei zBnr?gK1#3SG{j`N^l#zyARv~~%*gzB05&f2+kgF5D?q617%Ewm5t0`53VX z2gkjH_G#lbc}(1qmeyA^RtcF2hYq^UXC7m39zAh+WU_=hT1QGi0vJTX{gSV_JhbW3 z-T(dk*Vmiv(|5c5q_;rnkFF3FJpz6-Z2Dx&N2y(zEo zv|deLgJ{*##zjiY0MQ;>YW}SBAB|eN!fAzKZr?4ENRby-w6VD_qOJ3E_aCGy`e=|m zX8x7wvo7!AB#!BKn(3Iu4htC6l#PO2HM$h62d0XW8LT-KwaudRhw1kJJQu4Yo2>55STGxNldq3_UETI*obV}mQeJ0_N3QN4u z`JPQY0qqED8}m1$US$h3TFoT#H!(h2^*_9#h^oI%5tADbI;AWv=BerRbG&pjJ;}I1 zqB_CGd3;w_{s$WFk{OM zLXvdr2(%NW2LgepXAn~(ebIyugXKXaYRfmn9aUDF)NjQz#+nvJ7@Om3ro?9Y;LRzB zCSDq%5|yV$d3DawuX>|EF$>f4=m{~`wBtDhx>A(;5Ti;&5H6$dkOf*#(%5Y=fyTdm z+A$aayi+(jmI-RRB86C(TuE~E51Xb4zYv;FT=cnCb;3tc5|5}}Hn?zl*r<{3jTA6_ zi_EJ+r(rXP|E|onXftF8KA)a>riWa1b>w3u8d$=NdH+x^rSv8?xa@`V+clPB3Fok@ z?53+Cu(gM(X9nc&yS$C(P2U?I=u2Q;&?;iUnEnb03Ow{G+qnIrRKz&v`s9^Y(|qO@ zJ?u*#1`fR9Ml(&(O_zR2s#zP3pL3adcmDgGGuvk_5vt(>%Q)WBSF=-u^Vc?)GSYOI zOG+8ghBJk5hYTgTi~9Cqe=AI+{kLh=Ql5qL9UK((2<+3ynZ3Vh9{IGiZ*9Jx=gm)rBHQTULo>7zj9$q@~gYx&^U^}b!hKN zek!foYhQRv>lzHPYI^i_!}C=I{vJ%@^Z@T>Tw8!Y{Z>LF|v8gpo)Zg_>JUkM9UalJBw=#MbmdM8A`zYZN4)Ukuc9=2j9u6*#Sz zea1*AJ50$W5>XRgx62HFO0jr7+BCRF+x0@aHzP5#xQZuN0z{tvTG#-&WTX3fZ;P(@ zEgPIdRDeveVNY3>=EOZ6&7@*gbS{!N7$$D9hQv&#he~ek)c$bX1oyLk2gP{BZWhdH z#UFM6QhpNvwm)^G3ClVkqZ}d7P89L7%eb3;tJ1M85>1P%^S$Cj0GxNDqV6)f*4U&z zW!r(~Z5FQ!)Z1+72zgmjc3m+7_Z}!t$-|{QHhRuDHGPqoP#sIj8A%xb8COdGebwXJ z-~S*1wXVxnPQ6bW-syXhz){onD03D6v~?}mrbA{QVC(i2*)D@{i8DRAf}YYRjHR49 zwL*v>7U)pK*W~udS9Ob)d&_&t@)A?uZ2zkUs!~Ajonc1e;wPVlCR+$3U@TIr2^m3EIeedao??*(^*6#m$7zPUbR8*{kqwVR^KrW+pND?)7@Cb zxyCGjdp_57W?&}xnzX=nXfNu$M<$Homu~$$eue$dn}{>yMZhRD0;$wHKU3fVW zT_40vW!{n&w;H=JQ;=)NtcP0ra~w!UySUOd=_RcZZcEa?@O9r4&{}g!S9Pc)EjCla zvzvP9bDEPd^*%`qQeKBh+TehJF4WVV{vjZYD|69ULb6L)M-Qx6{mPbD*1~}e#_XZ6 zRsJ+Jg#1!?`GST<80@|CxYcp*?ynJX50a|Cl1d1`O#u2Zjewa4hc;hG-NEP}UeCK; zVnel z7)eLHeJ9JKx$W$Dp5nY2C;LS~*8MnVJP9;VkeYa23?5EZ-2H=MZtd@}T}K;XpY4Nh zx8gTbO~0yFR{q%wN!Thg`8;i~o%yn)VJieM=3v}!ohc|k+6LRhjX&OhQZsSg{d}rd zJz)3rT}q+WE5jNnF3j$fkLz_=m$5CbV?=1anmN-j-ek<#Si9eU9Wu3t|H+kzc)VJ_ zMgtj25?J5U0TEXD@(n1dzWQR2@TfvPOs$>GD)3?;gqwcP-r$1v)8EP_usl=H#Sg^2 zSNl#qjNy4_^7~!SKd{iGYT&A0H&shka-bzL?nUIyJuG$&bF$PRM~kzDFR1ZW{WTPx z_=#$RWCftQQgI$4|T`;&x&^M6k&4J&VEr&Sg)fu2OPA_ha z-&iVp`O9HVLx0#JOBQ@=Nk1F#vO1fAOk!+m{Om!H<#>pj_Z?A_QYY2HDq?PEx#07X zk-~CR{!MsAPd3wfN*1(w2Ny|cvab=?h_`d5QAuohIvW0tH1qvUOHKDIF2MLl4%Z3y zlI4-_5y~Fiyo=vKysuwBCv4s70^_&FHi|JFf#GoOB*iJSFBh zEoe-r_WLY3;EjmEOg!_n&pyer>7s!$7@=wEPTNEqt!EdJqiwAwJ5>6Q!kAHbz#M8 zzuJGWtPxmu#oGx&yR_hSeE>- zo0d0+Vn1z%cX6S^m=p<1#`e5R@>fTEumjntD-oC&YV1@KhBb*;T)lT9@+s$LFAsSJ zfvUrPz1y9E2^N$ZUThaqcPYJ!Ya--N{rF&|#?jZ#kHxp|A)pFd?GE}VL-hA{g0N2_ zHR-4d>Rz4T)doi=wqO}LF4?fpD*A1M$2s%P!gHM`rzeY+shko)4EtHZY@g<)dAuHr zZbPtQy1fMEzw!lfkAbM7=Q4X<6&&AuxaziQ(x*$u2k-a9iBmMneJ>rEtWMz zh}*(EWC;9rRi0jthFhsSr0ayZ;3necyv^1yNxg!hc=}**x(&aehAJu;MZnMIAc3KS zQm&E7-a__RyT{x{mA0!?un z(J9X8H8#8&_-mic!rr=mj&Z<|lr_*J-C-FPF_X)!{SfG)V6-wT}lr{>6H}- z^rkyBR{rACg(=+kuip8bo%_={r+z_Ka<;76vQMHk59fXyk_57K)VJXWYm|Uy8Uqt? zIiNf$XpCX&w>!;$#ig+3mYnC|1eoB-9+=aBP*g_zV%BzvXxhQycq6o2xu=v*LY$I= zPDaYolEYW-=Al#iy0D`)FULFQ3un4bdF6&pgYN2GZ{w}0{XgS7K$OhF4j5u_Lcxc7 zsQ**!um^HfRW2#j@Ib5&TYl)8%X?&|_lPfbti@{?#elU~{tDd54HyTjw4`zfoS2B9 zcTmxlEpF{1W+N!*#;_dpt*Mg83zH|#{&S-=K!YMkgw}JfhZ73#GUINZ7iDoqd!K=c z(#Q!kJj^i-I zck3rii*QEB;gR(7j%i19=rpHEz?QD+DHA7lo;r&J=)q9SdGn^9sbJevxN2hE=j_T7 ztJ0C28*BWxUAU~L=8--n@3*P#AJW2iA})CB!2Sq$>pF|M5&QJmu7?izjCx~ad6Pf! z1b~rNv-~GD7P@eW?UxdJ8K^hQHjgm^PFqmH(mO?hM8p5xsZlXBL_|}B^pAy2_g_#y zc}F$x>O+^1@8tPhj1@>jWcfQN=I`jlNDp(#n{2p?pl4SU9gLa2#C)O(O$AU;6?um3 zsy@vFxsq{ce=~ABGP<8o_^2Zrbas(b&pulkFaySEMF9vN>Pv7?P5P|AjZu?zh1YGZ z@PGYMju;ua5(b@AMW6KXGYONA@~j=nCf+l4JfX`CJLxsz>1T)sxR;wkUY+o6rUM*9w1;A%D+vsTQA0o- zyhjD%R+L1&(iHKh>Oy~52u_Aao#)f93ARm3OdXV7^|hr=gafT@C4lYPPbOUo%?ykh z)&PDFJKx6WT!2Doqw~&+V9&TDV+C!7#WV?@@;0!Pc#pvsn8#e1mt%bW4+OaT^GSi5 z!kGo5U|mv|lXk$Sq3xq5((kgMfst+ksu6ZY5&kw zbs01Gv)OE^n36z&ujzr8+rLmr*=9%dJl?ZmAsVp?YP}oAX!e{t9NT=`q#3=s=PN&P zqVoX8nk%742F^Kkd0U4maSTe=^JpyL9Y^I;`pw|K?2;`W{L7)0ge6iY9oi+i;8LK6S$y801%r zu6uHXzp3p;>M!0N_|Zg7u1H7$y7z&tCs(mTAk0u6vIUs5RRMikqUR#$&M&tf@JSvr zTVdr8A!MmIzqCx$(OZKH_cb4v`yTR`)^h+ZPIDTrvGq_NH{1ys`Kc*N;kP@(1jHq59*(tKpbjCv zt4E12ft5OlAInnLRX=A)Lt?M_wW42rn~3Ug7MCM>W4kGX^kxr`Hv89B@LQue#gjom zX;1a!&6Ltfyj*hW3)wTFl@nE~_rXT12PTE?3lTJiCo}WAN|<@y#z*)ryLur!$sRT{ zpP&h>NL+;5!qi@^<%~F7aSJzEE#j9W$P4=8=>gQ>{DY)Sev!a#OvTzS9vw;~%=Gbg0m2vA| zJdx1)|15x1x=8W4r+|)VXH{xgxR5suKhV(#Ed*TURm>n~+h@Hz_SVEzx#BVd67Od5 zTnx8t_53lxq^tC+0wC>qNV4|MKu2QC%OQHoeZ>y#c1I4D?fW;x`4mwuQ`Y%tX*!7S zO-6`9IS8{J;_iEH%0EaeLrSIpp$dYM(NyjN$;|>d36iKKs+*{JJ??DEdFR7tMINV> za|?&kHy3VgalQK&$BU(0(sOO5MfZ$DrhEKPYCyHEM?wJ{mjMAgKxqaQt95TTrOVJU zQ2k}1Z#ICZ%@)hGqBGn)c3E*&xs(F)X!M~Db|+j{|NZ69FB*@&+)aB*L9lkls#SImzvTz^fInx20sR5Oklip5zt=$K7&tdOu_a z(vmipJL>sBXNQ;X_hk%zH!I&UU7~XCMSk7#olMQCTp(@rZ88<;kh#BhOxy~n3p|c{_%xeH_w<>7?We0>feA!?6Hu%4 zXUQ~iFW;3%XQ(SBqLTb3NrqS@5;e4ohwdCxo5s*{@;Ai%DU!Dj4Q;--%X0CE{0kO9 z4*ts%U7bOv#@4OOB(*#$*Q=s;W^T6e{NN`Pe%<|y{TLb!O)UaN!H7HvyANW)P*PLq z?D*zBZY12>0I1WW#Vnckvf~FV+kMJMEBJ+vUsg-p z0up9QAO6a}x3EW4THlD%crv$^q(87l>ZylLgiGdxg9ZNf1ar*4ewi7w{Cf>d=$Ia|R+Kld3LDz7}3~cLylDI}71$tq*b%Ac= z>mFVUX+$%YwgOu4=zd7^>StxgT(o^grOc6h3E<5N6JfY6^8u<9r8kapFo5n(&lvekT5R0?t4 zwjm8ZQzU`9_v$qy4z~L*iuqF>bJL1|-`OwasY%gNc){xJTAXeEdKNP2U>aBonK!#1 zgHW@<3dG)@RbVhFP}p*GnVPHh>rLif9+OtBP6$af`f6fm9O|BlsiE5S>+dO-EKjeB z3bMWs3UK8V`qzy(EDN%;icP}R7wVh~gwhm-W@sx-(GE*iRk}Y~6Zn!X`vvvOXjHP( z4#_~gE#uEV>p4J)fGK^fO8`nTHITwUvpjEx|J{Gq@D|vd@0;WN-^}!I8&mI$dWq3m z_7q77J&=@{4&I%jG@G^@?J=b~E`Or_**}~i1ZJrQ~JzAkvGd7dR6rY_WG(FxlRI_E@=O=QZ;PV*`P+=;T+h{|(IW&plB0 z2N_ToAM5JuWVT9sY>lU!^iwHN|*S7Sd(Fhd1@Z_%Z`V)HX5HTXu4 zo%=qnX*vm*>CGZ(G7jxhJiO!=I-x#${aQM#eszmdt{>G1$-=wWY`@+7FXE|ghHi{wF}sr7vj|jRE&`NA_F+s&ENE zC0iZcefr1DR#-(iF>a&#)qDPFCCyM&94Y2ZoB)tx2{}?9w^pC?Jc;Wwovh&;vv9gw4X;x97t3mM=lG}?_>*R zHtQZb*(8^{zIHPda?1fvX6UxH9Bu}$X0bjckMSfntlyF~|OQg|!;mLRwof`E{jBo2RtqBA|# z0B_K!S1#d24FMq*V`ZZWx~ROK)f7ry~2yfA@w0{yf-zLapbTf^Lx&hdcAL68leVq7FjCtUv9kq z=qBl>9`Clef(W7cD^MVi7a}A%wQHrd&l1DF+ z;>4$PmO=z%f_zmxBGkOU`bdXuj=EUUE(Y9@(zQ;F?Y?47<3#GZp+&o9SmjNL2y@=1 za8tsKi7FqANps|Vsjp})Im+e{x<45jw0=aaJ47&!(rhLwqs{cPar$qt#r68H(UFWh z0D$kalb=BlRu1FABR4g*-=SlV#KRuK_My>8it#?u@u&5db%mS9v-Rw;;0r-vZTXd) zz`(z}9*lIV+^#TZf^?{kboKK+1NQW0NAq>sAI1QCYds$g;SF-1xqQX14xSqQ*YPe8 zpZEQebr&Y}GFWS2kJxP#wvR7JxQGnN5mH7by$U$e3;22VOgmaKm{Zl1anaNhYAd0?>&w+@r z)htNLR)pi|hs-UHKP>nXbX{DXWzSca$a|IREL2M0`fpAwZcf!U_njak`ECymdh+x{ zm#9TnFRJ<@HeHBRoDBh8e?wAzOVaohQyU9~0C!7OR7J;7Qnl^F~yGIG!M$zy#{SXgr3%JH&W4v!N z%u~dF{PtsHW`*DrNL35FdYw>Q*!}CX+~6+Yk%S4GUrA-nse2n8F* z(oEX&f|=C^fNp)YfT7=V865RmcJqA+vY?@*gr)bCHgib;k*SfoT5P!RR^kOgelP`mpQ9 z^7|NxC*uSgLdE*E|AKB+kt#*OKl~s$rG68>0qMv`FQnl!Glgn1X$H*qzk+RRya`;G z&OE>4vyW3spybYPi-q-{8w+3b-Kr8#i>tvY2r3N`I2Sy!!4*AuZDLW%(AW~9#uHxp zkyCC2+My9b`8^p&^#m;%uM|KUGAQzI-sGbjcz?0fZjw77(s6NER)BLLo?8kgB-Vlubjxwjr?mFXb|-noryri+VK zjcL_8fLVzu<7sg+P0l@kmR!rpv;T^c5Lt+8h=PJ*%$sa zPkO1WaYs3gL&%_g6a4 zG4j}Cd;}WbB1SLr1P-%9lh#cs7GraM$AynfE(|JAOP3D;@-#Q$L{@bMr+fdE!Nb7U zq}OnRfToHcI|h!Lmo9Y5Ua8RFQQ>||!HS^-ZnzadNtQNkJ;6G z_bf%3#i||FO{Q1_icqEUv`gGKmV8?$7f*v9LD&u?h~Cf=yf$loH@pwZ5$;K8KFfil zCeDVdNPTF)J>Qm-J!x|BTgTpY^A=LmdJ?n>s za_+Dxw+KyDl>ZC1zo1d_A!7=U#K$_f4vTs4w)(x652`+^PqSUh&(`=Hv-7s=CzIlC zy-B#aGOZ*B+Bo&t9`LK`^^n})R}UH zDz*}T{yzK95Wjv~?sI9U!aEzN`QOM&8{n{L2W5+Q)|)(!crOK4X1$}fQCcw#fBl=A zlc{X>4_HW&XPX%*T^f^j&i&|ZJ-gPy)-c`{EoaYYD|NimGfPn7DnIro3ISHTN`0u~ z6Di7#WvJG`hnspDS&qH*Q>w?Y55kiIm}O6DD_yWGat5S7j^pZkdQ$ILS%duC*K;v_ zqXS?2o1E8|NENpL1nx;5AojS)suuyOhp=I88+?^f4ZmB5fiMv@9nJm5xH>4FP8W|3 zI#bJrl=NLeiu?mn+mVk!hw%We=i=3$ow;-(n%-ioBvkZS5K?XFv= zx(FPN_aTl~U-xyhs=i|Wxbm-lA{-O8VX-oZ`1KL&fxqo@JoOpyRWQ|k;fJk&8}gHW zP!AYp%}r&eEw`!h zAeyE!@40ley06r9uJu}K&@?c3`OU!+O(}d{TiNTBamY-~a^{`>ZN}hnyBaAELeJ0h zxh|8M+UCah`P9ql&fU2X2&c*Y>sAjb!oDctq7zA{j`#-jSgaKTkqoLl%ZE|c4CRvG zvd83nyDb3w-~I|fTpadB@ZF?rkl;{^QZfzYPnDD=U|U$5oZwk49w2dqr_^uY}KrG&znmgiPENjPChE{_?Eb% z`JAUL!@08V<8>DS(-_n@emNPRn2$Awy-U~yIJwcn5uM^VRL+Xm4TH4UDFub>Q?tQ* zU5;^VmA_7t^CrJEz#H<;QLn6H{|cXUqSHv$KQiQNYPs9$ZO&d4#X0Co((5JnB6ea| z{WyOG#9=$A6|8O_}y&p%tgkPEJ&-Fa?Jk%g1dirKh+^9eHA+e~}W8LLP?Af|K_t+F81->a2S{@KBl?>wCP-b>Qet&QtEz50h@fU_@nBG6vF$fFnw50!n|&+YzR+ZhyP zo>NUa;>0)d;|SHf^MLVb!Q}I*GP>^^fv3Ir&^zLL?tLf1jq}FS1EpfWDyh*KYJtf> z(m-$x<2TFGhpVmKf#xFo_Gq=9dsn)EtV1`L8xQKDJqfnw7X+)Od30?f;yB7M`y>J` zkZ-Fk(rg?>G15J5v1#8bx?A2-4Ek&cao?tzoHP&`Z?8PkA)9rtE6 zrQm&brTG=`OLw?nVro*^?@D=0>hR-Ya6yJry$`UByXx=4HCReAOZKSZ13=kFI(vo*n=H6+b=rPf^Hz$xTCI_dY?$HAK9#BcQk z&@~8Gw^oY8pa5DN3U50~m!2a_-%mX#ypK0|U0Ncvk*U^w#avz4!jIxyb$p)Q_`a$p znFN!jE?c>lK6x*HPihA`m|b!D=EY%w5J0>D_gRomjCGE)jTG0XmfT}N+6qHmUh+)7 zvi@?q^P2@ahP|8X?UN03SW2e*7GZ2C|B$=hez0ujt3?kgIrvkUSAKXj{yh0iVj2AT%&=y3|?aD z%6^-0sq3^T`{qWz_tw3Anxi!D!>9y%Pv3#a2@hR6NH>zn7KeqmAl;;FKP+J5D(RZ{ zr@{Ph-3Jvv-!nNAlzCf; zL1C%>yr{fnR^Oh4Iy+FKn+Dczk`_b<64W|$hw|dKC~1*qafL_&GMaHRld)Yo&4w$;2r1IVu_o!q(m z1V+n-PK4O8s3n=48nOK1L_ zX_blB88sAPYDc1O*M6xY<>L6@C0i0@Y~>`Sv-C;aw%M>I<+?S8IsW~qWwJ~I(B5za zSWbT{gzYIOk;^)~?t2Z8W9`z%ziei(Lyq|7f4(by@rJOYW$h0k96Ic??8Cu~rg%9@ z*W45JuUA81o5G_3%aDMV$*1>Gl$VD3mLj()eM{aMxU@-wb#XX;HcO`2iI5EbTf7Nd zdT&R_0;~wz`y%5C=~QLm#joC{s&>Q5R{T%lyHg{nk|5DEO{NR$NW!_asjJ7^y!WcG zntS{Yv7{W=`}MX(zf^AhRD}^MmU@f(n~dQvqHn8BcJfXgC9pAfpU$h)1pV63wrPWq zwi2Y-x4Sf9pPoJ^9>C<=ttqj-o(=eRwf>7#k=1ofln;EX{6F91bP7guO7h}r`)cS? z+!vgq(XWeprK{>0@T?-WXS4k& z43_S3uP)NaVg@OHrpmz#ia3)KqWe^Qy;z=8)at&N*~1+e558gOEWx60*!wL_H=s_$ zhY}#ZjBy6tsbO>+y*(h8;m)4xVEol=OHpE_Jq7Xp(Lu&2bN&sj|3LS@HhozIRe_8> zHl1yzjYu_aQEeg(i{WU$+dYUZ; z$)2xP-G7x=(Xn?TYe}062)s+p+(-_|#rua7rb3+S5nB4|i=Rog3QKiOYk|_H>^o5! zu*72-2Fgnb9esE9zA@;_w|}2tjXu9Ck}g>=uH`Ftoj*c;Js+pAZ6xRjdOmc@Dc9en zS%Vl5oq`90@w%K5_A>}KQ%hoSlrMA(RnVmKChyE#I~84TY^LcR2C0$Dx?86)`0}^! zw8Y;(flr^jBWu>p^PGNrL)0x}XyaKBj&5=}QpJcq{ycVWk>K&8nt!T&^(X$peR$$Z z8E%8?fsTjilJT0USz}+wfv5?|S_xC|yf07dp@d_}EAVc?1NrsY$$gs6TvyM~*9?Qt zLg#ghmZf~0H<7pDQp-g&@jp82C5s(IV5)+lEOq$L!nZ8GeGGr;ykBF_o=8gft4pHS z7|6^1n&9HhDr^_qAA>e3g#4BiEmKqy^yq{}N>GIJ?T>DhS$oLU+RlW-;w?F+Z*^a= zfhsRN-xkPkXpA=^o2w|K)dV7BSr>BfjV9?l92TTw`C!XVmdZ`*g74DElBPUrG9cEy z`Ef-hbfa9_BDQV1yDnQzSHQVnq}erNKj{S*+Dksq|4)U*j`AeFjVgq?@Gft!NX7krmi&7S;Yc_T$_(aD|#$abZ-YQO~L3Q+R|zd(WEQT_g4Ph z`3IhD{E8vDYA*9Ed$>hx(~8SY)_{jsUt$aaY#;0ZsmVm>-ntBAvt?N_!XwMAs!fzLmc_Cg|Z#;D%P78>E$j6ux(HQr((_?=SIG4?#=AWB%pW zeE7M2KeOO$ntvNSa1_;^x+jw&-uxlXdD`f&gug>rN4L%P)*FV)2yE@$4aF(xzPof^qzE&trLnzE%UJeAB>OGHB! z+SaoKLw)rkD$lu6o!Nap*i^GlBQUqGxWFK9(t0cJ{uPZxj8aekgh&Q;>$2Uu_1yVZ zFR+@J;~{=doeAWA3#hIx+0V+bd!TcR_e(lQ_@iNU^~4x%*HGUk1j1gDBK%)aR-log`g7CmdKrm z+EtyY77DP1jF+Gy=e%=K0pICCr>lHB z1w<6cQ+T;)ZM9o%8|EVHvf*ak|IrxSa{Jxw+~i`)nC*OQb*Q<1BSdP)1R2kY(Adgk zsBfaZ->38OA}8P)AYB5qHQ0~6NM9an^%*9`xm?xC`x~kxdZp~%rRHR;-@(QICLhR40+-98nojoc2$f=B5ApASXP^h%G zIxSqpksRfAW#*0TdVHDO$RwX~z!E+=w6bG~WK}B8_0+4#*mvYJ8K0&`#>*o18q8x^ zrx(_=580d6cM}8<>#)T&uYSTJU(JG-$Z2ey=nbozAJ%ez=EnX1?Ygzo(XUk;OGA z)rB%oSI)#TI|j)Fn7N;=^NiZ>a(d!3RN!dG2!=;X+Z?VXU{Gi4H(kEB-O0^YJ5y8U zRe_7Ea}q6!uPK{@tQww=>G|$q!SV2uhy;Ep-n%^Ea1Y;6MGrd57ILWt_AR=dTHwel zU@zzFw2%zyVT&ORkblq?Z!6^BU$anD-qyS-3ski{Z6W^g9Z$3zXLf6Hd>!~OqBs3k z=XX~{L(Ml?0%FV@=O@!Y4}=nlLdTu1H!=HWvB}>8Ne|kGAX^Ux-ibOU{8$FlP;-hU@`x{y|kNwjdLwqH)#khPn2H zxSTF+6{Wf9+%+EfHTTrQ@ASXUoCCkaG$_#=W)@~=L~*%1Zw6)mDZi%v=x`{rLbz$y zVOAg(C+l-GOpxX%tEKn|9T=j*+1R{Ci+o;+qDLP=Mw}`%J&=3!_?iT5yBs!0z^XhBA^V$Di&Zl>jY?lRa{z~f1)iXSRf z@sc465X5CdHV&Ea907yG3oJ8HTku(nRXM>N=(N-+`{>Z<)WCKRP_l1F(DrU=i-O?7wam6Hni= z?oYjqXmdr}hf|(~B=5wsC6DaZwS9(1?|+TZl>fQ^Ns=kz_B>bUPyXnS;R5vv7PjHl zVvpNVksM<&)uOjU$68N5*vAX|xN&HdaNDhN_R*sB@9Q3FLUM2NMTYxIG)?d7>2l- zkph7T!S-1z@N%$0@b4N_cG~o%^Cl*oJ6=giA-(&2Koiml#Ohr=lZ=f@_NuN2k*vvkqeIoriK-=N0=f~<?zr;R#Gu*W1Oe!|DL_yf-n+1bKf|c901yOJOYhn5ueTiYaNv`< z!|P%v=zp|&yMug2IPdxr#PTm&{*~%andY!<2xxJK*8-cTIQ(v0uDmuFIj;Fo&Qtx}|znC7DBo9ARHf3|dqlGbCvZ$Z-vlZia@l*>t4|lkog~UnLb= z-1UG;?jsSv>!aKR*7s%5U&8kZn#qIC><3v(vAbjGjH4mEG8ql;98hd>Qt1Oq*Wf?f zr$~EDbSSdFuXZ*XgkP}$>Cu4B_*CcJ+a!~Wk_Jz{!Ss=Po;G`gsNX^@dVP5}?ot#R zP2&?T%)MLq-ykL)GC90USivv+i{&2rg@G$azXK_82^l)y)ML+4p#E=YHk@BL-0qtrgLDvn5EvKI>OOm|4sg7kKL%iEO*PIYv?tT1k(O_gIKua2@ z|E@)W0AC$jJjdV2b3em|R7XJmxssQc#u-vpW;fp$c&GkO&#cUuZ7Jb(Dt>xqG#5xV z<8HL%-MDG(Kw$_MiWo4=<4Ll<4ne^ z@dwFUS5>^R4S<<%T2v5DRiv>>s2(lf<7!Y-r@meLaD{T@&WE1JzIL9xb9`$Ww{%EF zGJ5UuyP0jH1gqm0hGQ15mnXdi**^;mo^~?%cST{-DgtchPwe@rPy4Wmd(;}rUkrE1 z0#>y}Wqv+vPhoD6NfJGDe(N;od?1**0(z+gz$CEOdY;oTan7$hk|Y)jm;5m7`<>*&k9WYmd@ z96FOhsb&J3LHxJepJ(?7oQ+MH$ouM8xmC3S!UgZkWc0CSe?Hsom zF{iNS+3Tu+GS-jQErZH$+nYqInq?XVR->hX@^Ut>;D=X!lOMpDuDgwb6`0Iz2TV`1 zEgJ9B8JGtR?ivrQijpo$;Xn07@8MyBIg}vZ5|)G7F7x~)QpwZEjfp{Mz;7R1w2|rw zFFPo1mERt*In@y$KL*^$~vwjo_*H8WkdUtyOgue1$S=l%;B@G{MwZ~Kvl zw;e7Cu;grGG-4pT5&cohA>|;NdKhF;U=u)j@u3v#WaE}&gcSVaX93BzWodt{#3agMl zF#B62P*6FzatI$Mu#gNUL|<&5$-OuWxcg(v!>)t*6^%@cASe6{Ru-`;cM6&SWJiHM zoF5cI0U<6N=VxL73-EjYbR$mx2}VXimpT><4(^n9xI+((J$@Gtt`*SC4Y?XPBtlU$ z#)+bqPJzv72xs=xS@-?_@-7;2;kJ3l7v1LWt`V3VbZF8PHh+IAWE(};zMn?t?AXWe zg5=e&XEsY#WlV;lbJ4~jtMtgUplvUFr=^_NT~@U5)ik z{82jO;t^x9I$o~0ISA7W4e~&dB6>W(SpR+K!$#^Ax!J2m3b4Uteovz2(o||+5MzRrfE2B;#YP6Av@~MSQ8xT+?YJTB8d&*8%+gqwQO zJQ2d8ERa`YrK{f#9$lh--4BrDi{u+Qo?&oh=Fo~-X7ip_=tZkPOS|?Y@p0qd={w7n zoI{2pk6+d5i4>|%Fua%Xi5GJwz-rA0Zc|y_XTaqz>6xGXRP#yTeY*BnB6%++nC47R z3g?*GBvM$p^UAz=rMEdK_MHYlG6L0d<~IQh>^=O!bh1C5OE-9Fh*(3rP@apLNXXm$ z6&H1XBRcBOZdQ5v8@D<_JO2jSx2o=X(lHj<58qB01&>P zhuv2&{3!J>j&e-g6JPy>@PjD_w7f2mlaU7U-{?@6tzHQHpXAfpEI)`q!Ll`ne4wH-%)U z3L8E$t}9z~y=7;UgR*suDU!xq2Qos7(xMnH+u;q$S$5P0=DM5)<%A60akn`+OK+4Q z0zGWWeDB}c*YN*O`5$vZ&{RMRytm3_T>oN9PH(u z5x=?=S`EBBsT(5sg^liG&DE-!^ljiuPx`gosXMSENAguycoJAj$r(rFMb_f2Orp5g zZm0hJ@O~%`OZwnV88Uy4H{jV%e9m(%b(R-=Tbdg*EF|ug?tR1Vn;N;V1E9DCW3gNrg8@%jG@y8dvE;J~#tUb~t54?UZWb~>Sd=Su)X-k}TwhW;uweM~* zepXW81oC>Qq=&ozG1J0f$I)^h+a4e=Y4kM*AyTO+`g#9kO^s!T+=uvwY2B7nCd>gH zH4F2232bRUe_-#{ov_OIH%3)Hz4-;G(}4up9-#oVv)qiJB6(w9t{Vp2`~#ajZo3b5 zdzh|ss($`#?#-LYDajIO&9dxot0V}W=#qA(_98LzcAzvr2M=w z_`>JIuvmDBrnWB4CF3{Um^d{oqHigb`TeDtG%?{A><)2_+yZA|N-Cqfg4^YGMp7BFZVZh_OkxLu3+aGzp*&Q@G-PJ@|o~|0BtX4C@ zRd9rI@Izsppy5ScD4U(n^jdpJ@`vEp7lWKsaHyF{!-OH;^of&OKjH#+}; z4OOZS<`K%t9+BdCdWzvVJtm@Dt3PhPI}m;F&Pnf- z3W=wqbPR4sKz3XTf+QiM9Y5R$cV4so*PwUDrwoW}DHpwZw?1IOb{sn(>oxGV1bBCe zj)z|KfbDHIJKeq8T**2WR#hHv)ag=T|2T2s+{_vNc+)SHxL4&#qMR@5F?Ge3)9S4- z1ln~CJitd#NpPJ`p=ns!{cxh~Wn11Dx2SN>VGyZaH)o+>+tGsiYzrydUCG&~oA*?X(rv7* zlS&P~KjoE-&H7Zs2B0eoiz z-{@H8f(jb7i?}O-7QLE?H3@=wxDCNsXRvbAPBlz_t6F8@_GyYS80LcjC8HckrwN1e zE9oj`>+7=aK3RT2S|Bz4imCaG{k(>}yhD`Z&gyqWJ*U^;x)-V7iQWT6{|RI*E`6+` zdAWICrNxk{x=B81ee>!Y?X#u=%!c9#iyKnQrMfpOeHD;6Ykndc))l4t?fscz8iE|S zFGyLK5)Gs(!B+;6XWJ`?smD%ACsY64EfIH{RL^8R$PFRpY2)tM4Wa{jA_=FNlaY-? zqyxC#33VxP%h2Uko)q%ikog%ViZhS;`NgQn*av4BV)pjWVF7G+hP@&yNRmBXU|6ZLJWp$YaS!gPR zd|Tmc2#U00&WnGzHa2WhIGrivW9zPm#mJNb=6?0a12cLNM-T>y#Ap81Y=%$uf*zFN zJxi3n6>Lf73!MvJB!;#q@gI+VP!&+{e9)bmzFdd5+6DYZN>eZB>@DlB-0X?@WtBrs zWxy|)`67&9H?Hy!$@DI>DYEDsa7ZS01Hc?w@9sUGP_J%1KF7fB9=2c=()_ zenc(xrN{c~KMj^JUQrN`fwV;>wV{Pk{mA__*}9M7cAoZbqZ^}-)Uc-(V|r6qxqb&eBR%;`>B?GPFV|oem{?Ux`b6t+8mWV;yONu! z^7Ir-ADXRx9u)dA3*w|hzctt@&ic0GQo&PqWlTT~WRuyd|IR`6PE$%x)y=wQ!rR{? z8DO+E+EaE(tm5{Y&a`_${-30y<*I}AJIvzt7j6bSOSBM1dNU8~-$ZBTUGE$U^;2W{ zlp_gi>;bCJv1yd!Z`xum?aQ{1NVzv`|2rX-r za8R7Z&m=dp5!QgUWDUjKX5AR=HvEoFs9j9%S^=6Aneqfrnq zaJ1^v>rIYKU0Uvs#5OPTI3<{`dl<=k6^xe5fE z=KlQmte6FWD=epJ&ZfOXaCiKY<2(U%vp*lc;JgzYIBF?H&C=_0C6FAr&L?UrMkPgm2Foqub)-k{$V^&1Q^J`fl@UM zHXd{I(B%^HKN>g*eyAJ-{&Sfa_^-qXdK$?i;n+UieSS zv%l>RX-oTH`=hS(5;+D)SK@kp_vW?QMF!Wph^JLLcN(TYoBAgKm7>b8bCZHL!xWZc z!etI??&Cb>Zk86P$V)#9{lHII`xvg!pT!`;(iz{nKEJ_&?UO~%O@VK%R70xSaO*G% zZ(l$GEMm|SQ7dYkLK5vNvs)Z~VgMvurWJ~$oT4mpR9&etWm%v`%MosH?SGjwUqpusd z#!H9foyQw*%TfU$h(;8t(GhY=0aCHr7x}u4f}q9kh(`gFkk`8b){5Lu_V>qA2g~w! zS^w_Aq_m8!+-Y^eCVbvcQyv78&J}*KAdW?YoIuCL@Z*n5xCy(%=39dz3D_u1F{{uN z;HXGYcD4mG)L}gtJ(Dn05*=8REK}GEP#HY0+1Rf?+9TNZSB=FW{~gKFaA|B;t&2?G zzug)yK$c3Bu?XLkyZ+BvkiI)C9mP?yVs!ncHgm;NR6+!2Wb5<<#4n0|#i5yP#&Gd< z+m)j`Anetkb+FfUW+bdj$8RIaTi6Mo9M;;K+;h<2FCsq2+jo^c?9)R5v^qUUUr@x5 zOdn=WFf2)F@o-ZOSwyY_`>7s@MFV-`19%rlR0ig zWp^fLXdz*bSQ0#z49fweQ=H%YJYr}bNNcbhg15ADuw|EwS@22+C}x!s`c)OTF@n_I zRaU{(h+szAOaZ!8~`v@u`8@@#Sx1j_pYMi$A#7P&C%mm_x`RtPk#P> zc?Fe5{@)bO7losm*Tz~%aRGQQX%%e5p{asDM>k|a@6x+g@_q>-_Rwa(c?MUh;leW7 zDB_ZzY7}*=JuOV$?9b$j<%L7F0h8fsO1?`uA+-|T1hS1K5U#TDCSTf%z@@KFf z#N0I)%})Qa{6PiGRPi4ZZw`kB4|cFCF$cH0qb>;Yxa-uSbieTGQu<9ssBew*{Xrmy zZPA8Is#ng@YF6uRTQ)(vKk(sltcE`2APa2knR{ti_I-3^+u3ui+m2+qnl?bYzhsN^ z(y8_e`eMMcL-oy;a+^^Tb9ZD3u4@N^0)uXsqp= zKe<}pCYoy5d*U##dHc_I#{cgHpxqw?a=#5leD?(%kBI1>4JPnA2Js?fL|f7a^Srl$ z-z}UjD|FAUG(TNp$D{L3zaj1=8mhSc?HoM8UbYo+SuMg$PS*>TVobq;Z!G#~NT=C|0CZz@EfA-xbSm=Tagm%IR|69;!=8r$=46vT#I z`HMMF>z8dU*lJz0%Pr2?j`X4LC8bN#nVOC3Tc^|gWdMED~-dZiW@{W}BvdBThI()nP z_07PbL2$G=PTOTNPBJc$bF#sT-ZrF?&Nuf2&_GDe)SG;fP-lxekfRou4x!iFNVZ7s zpj~Xd8=2dU6Hh(wHq)7sZb#Jv9!NidNq2MTe@4-t9&;lj9!?K#yqUwaABUL>K6Dn% z^HOtbEOhT4V!AsRm7DE1GVoVF|ELn?EZOXlR=YL<;Mvp3W&WMgk3S{(4;zCUHX;&c zSix>j$E`JE0zOG{$S+3Qz}k28`z|ShSZP>SXpWsVr{RVvY1=cvtmwN zSBaO#|Ev22GV&^pEzWC*L}lvSd(q$h0^$YUR=a&V33W;);>a%y5(ZyidKPVdclSYn z_X^arJ#Av&;8xIv+xHc9y_E9w*50mIyr+hkGRZU@XES06pES%nvteKi;e~Ebe~O?P zR&yvfep=Enc<5*|1hQ0uNM)f2Dk4rJ`MyD^?g2Q#Y?bXJgU9ptm$Ic&gGb8`xo_Oat zel6iUZ;%$Ki*hc-@abG?>Z5wp0z7f92_IwK1}AdS_~A5FGX4}yT1AJQC0p~6Zs*%mTfQ~c&xQVWy6D%&Zz7vl zi>f-)y>JwwwL>f_EUhfZgBbMze+uKHnY^nHc3roKkmy|P#t6Aa_zIHK@yTMkFEvRki=%S%jiVOOhfAv3ZvufZ&Yvdq157D@JegW^qI8QC6T6U|A4Iw`$Bj5H7^d_K?FY$POyRVrswD&$Pg zhdD1fEQvV}!)Q)3>|nOr{kR|ZpYZwNx<1$SzFx2AD{cQOn2?ubg_h=&!3kyMD+NGJ zIa+;>%7yhRY9g*HmT%5Lo6=Ssk`gj;ElZp9u5&AYby4yt(n^vEzZ_z?VQDgKz-2t$ zh}pYt8sCqeXxbQ$5*zBa#|-1;*eSFbb^z6d9tRFMN!Wj;NEuO%>qvpynGxu_SNc*(h3wplLoQd+Cr$;OTJ&j z|11t~EE9Axd^kMLHtxFlPNXP_O=<+h_Ul~J$RQeYE9p;+Q2NYIv<^He5&-$_V)dH` z95UI4!=adak0A|buD#f;a-386(E~?f3t_yUoqz35O7@7jCt^?T&Gh<*a{`#LOd<-XIS*8L)g}4mQ&QVm7GxaOOU`%prdp z_e8Ap^+qxAr{?pZ>#$%`v!gEp5jn?YKt`&v{DK=Om1F#jeE2~+-s30^TM8_8zdj_S zXtdEdi?T6lF5Ub=xj;&6Tpo<@S!YRdER_Scq$-G+6sd##sg7GO@~zV%7wjk1b^N9* zR*!rqgMNxh9>6YM{w8M|R*3v6Ff4*la?X#``FP@v9xgoGTri_)yyb_5{)Eo-z|mht zj6s$d&BBiT^QgW$VCFSQC9D-KwlziPx((iwLqec?TtIthoBH_DC3N@bzMP)h-F8?T z^Xs-89LICKf)IoZ+>B@Cfct|E$g8g75LWP9$*m~lK;oCm5>xCqMzhJ1-*7_4^kxra zlEYBUK4&{qOjxX7Lc!(Vd(Du;CD1l}klTFvAOkhuWU(Z`1$W4Tpfr=u@(aOao}&!z zHBF%iAbb##uDali5RW;Tm#<^Xyp^i9JM>?X3Q}{ItLo#?nB$<+3WyNg4WLK>=(#{l zuG9UYpN{um9jNNAtDXL+e!`(~@_k9!VsAb)XyQduOd^JpyUnyJfm_m^t3boe01nxK+%<9$K~?BJP0^4ADeV792nMS{NUk}ll*%&%r7X?hjej%t$WQoz)EZzdGK-Pm+x6(q+OAoeKgw)AD_z*sQoeYqJ7F60Ker z>&}GsXc$LV^Uus%+g2`dSutMMcp)1Wi*#D7_0#24lziJ`ekks;r(X6M9{*kuC3PcD zeebc2j}y2^r_obG$<|z z^*Fo|e1x1%fvwURoq4T2PhGfRJ#b1t{zFw%?|98t-QR}+57f+uzxzW$-N+USR4g3Z zjid|yghEQ&*Pg;lkDU`LX|+>D*SYap^k>eU#~ z#ANg;>#Yg~*)Vy#k0167)n5v)6)#~Q);rqC@7vLEpbg%UY@61dKm35amAZ&CjXiDX zR#fQdH2YD7n=4~ETU5bB_;uQZ*!0j}x1Kg{gvyPUlM}`fY>|8U25e&)V~%+M#;C>x zfZ0*%#O!M5E!EQzsiU9jt3a#LXK6dCLOFY`pS9q0s!Iw-UM&6|sRz@MC4&t8D9IaoiuON?h6#WaxwsfOtyIohNSj_iI4e`9S822Bm`yLvtxq`T$N} zqC7qk#^V@o`=juzTCSai%FEi~58)_D&yx+ep3&J);w0Ej{1X>({j@T7@BhnePoSN!|>fP(E4*UlCC7Fzcp5T>Ui2}C)X%lc^gaH0Avd?+?rf3HjO@JezPsyOx zgE`bnnxSv&IWWaQRb|9}-f-RL*K*v-m3@%qw$e>(O49VsbQRI92LF*?+ji;Uebztu zs+gQ$S2?Y?*W>;j$>vhe?+9MsJlMA=PpOy%FmGHZf6dr2`&%pA9ESHF#zC8Y47_2~ zxO^|C&mAjX`M8vcy6IZ~qN7<90sUY`Eds9T-Pl0nf`g>C8Y&$>QhTMt8u@OEZ35 z>+i(g(Ik2R5_pj+n%PLDlRjfK_B7T<&yzGIsuz)WCHY)>@t(3lS;R3`@8x8y2rFxp5RzB!%M0_XM5HCt^O z90=C=2x(2PVq%2ti6-i#$LD1wN+TX3kk5CKZ8D&RCJEoMGS`-W&)qX~4UZxxJeewDe?_vp*5v|8xlndFVif51gI$ll~{pT~qQ~Wjax*WV$Q$om6m~>& zWGOO-KU(V{+BjZK7@$oUO~vb`42n2wtrh3Af?=&-bUJoGkz1BZnmCDq+j=|4VuL{v z7iv-AX?-(l@4#?BWXPZ|{KLV)%BSA2KBbe}MxVFLnJZ1=nI$^=NJ^ERS#$9(q89mf z7!bl8vBCJ+S7!%Gso!-FB22a3DYv2(T@?KM_*lad+h`n#OJ27i>sc`cc)XyKC#7Lor|wssWW_pK`6G=3tJmPRkfse*3C|n zSD=4tLNvc?p&nG#`d+$h-wW)&KdEMX8RGM{nRDUgVOkmQmQ_4%S;N3HiHm|W)(}YrZ7-O z)oH%X%INdko#C)5<|u{$3~9R$*WRN$^ws*{Esf?jC?!5K>$U}pNJ{^||8fhAD-@ac z!IXf*_gyU}$FaGVM{bBJ>eDCKe!KR`KhDS5aYI6{eB?BPA6D)N!F^>xks?wrXu<{s z8{ASh6Ywd)&Y)xq-bG7)pQD=EZ}e#{`}w^LITeBu`-97uCXHW8LL}D1#awPlckrBy zj}S_4^lMc4mN2iqW3J(&)HUE$D6guZwPjvM?c)F3>Qg$kUK>}geF?cZa=UbBzh4Nt zHP)Z`#^@91gu~3k7MR_IdqM@Dv#x1wuCP8W!}HZ@fDgdz)M)Td2Yow^pt$4c4VxEO z2_-FLBHRAWpv&U`*2HGHegWpxZ!v)mvq8qYAEomG>fEXo`0h0#Cs=Q%G9ho;e`n=7 zn!#=p{;~{FW>bGL6N2*DjJ*4I(hTY3XD$bix<>wj(yEO*ryz5`sBSYh+>{5L(>KC7N483g=Z1MgzSSDK2@% z?RO5*9f+d$K9nzA0lj;A9soUU&n4sujj9gsUcIUdec6v{{33gNFI`gX@FmbK-uU(D zA)HcOFady~TgUY&TfH{`&XC_9qJ+VIR8$#_`Y6<{W7&?^ZuLl=I6_bNME!1_ov&Vu z^4800>Ki^(kOp1*-;g_hdNLL`jkJ_!sxz(h1oD>3zyJmm6E!9psKdM;5$nBbi{V-? zIQ$Pcj^nVEsWb7z*Gee}~8 zDYe4AR{tfRI~o)n-*6~Q$pczPvv$Q*-VV8x81CI)Xe;0oL()nj3+|M)t7m)+6@BqN zYzr<3=@C_%Toyp*6U{8+RUWZ3U|F(8Q#ae zV|9`q(i>(WjCb46p}6mjS4PZIR8m$xiX{)GwcJ=R6TGOI*4rEu%HEzlF8MxACcCOs z>&_Jku(R+%x7f+I+-r9 zCy>6+bG;^)XmTq|N4|bf6%~`8No2L)nB92cn4vmF;Fyf$6%+rJ0sc9veAF!q>W3Fy zze9&L*E=P3CmdfrwlhoNC`c9(pUL)6S&g4@QE?g>?EHJBfw=D|*O-$(qt*Ymjr3of zqxsy3m0hWoFisMnNB9F!He>TmyQn}deVF!fBAwg>eS1la2|-3;NZe6WvSFuvwD)$W z7HnoF-GV7qWlJfuwAmJGUhMtnJ$gms*G75!d{s+sN5Cmh!Ts?0!6$g_*xyjr^y|Dx z8_q?SMPN)MtMGAKwhVm4~6(xfg*M1Ld zf7Xn%%X}9KTtP;=sXqC0CWc#S?#sniyx1Va_po3}SJhkverD3gmPfl(LPMFH-s;!& z*QUR9tZv8m#SiI)AWkXoBCYFvs(NpStYl&b5))d?v_3E5)vB^P4r!U^NaTqAw!i;M zHyloJVYubv>Ytrb?)QjtEaVK_8uRMGwyk;DhjEREgKu@^6z#N8S_sr6_}WoAC3=3W zjWtDhn$iqjt70qWb3N)+_|ld*EFMt16(2x})<1EqnjlVd#n7XED~)c*3ZEnLCYzmbO_8tpc{h)izuGh zqe_F&@P+ZJm_6!G9O*Y{T5H#!cJo`|b01kN8_A~32B$sF9?eQpBw#D1EXJ{!-~utC zsxn$W-lV-`l-P*}4DM^%p1|#`J*aconz3j36X~aVW*Hu{K9r<9`0WaXdVbM?`Jmgu zZP(bRoQIo#>R$aF^mGJ#c=pnZ8D6%6O-2dv$N4YioG;IbC2v*8LK_K-0uP{@IV$r8 zcefvW;Cj92_naG>#_0(A0=<3Sf`7#c7~fXJ8CRn0?pub=dTH%P@&g&91YA3KrMXuB zyNqE$A7+ru9uOQ-Kkv7|*c7j?Q!ibtO34q z7jPwmyGgO@4rAo04wSEjfIp+G$}FfR?lD3B)pNTa{Q+~mD#bZD%eE&S^3lSK0l}kb zEAxi8MW1EZU0&OgtjdYhMHwJX7F$`YVt_q4*YSI`EiYPw(bWrzavYURkI6xYt%8R_ znXN)1sE{4tXm5#{$DoR^FfY+_Uo{9;`fH?Klx5 z;Fb2FNaybLKLf&NXVkYm+9|&&1qp4<91@~9)URtfH1o^NSxS1t40bh6&EGZd4##xb zO%U3O7N9E!f7>H0*Q*BC$ph(YK7v*Ac2lIGv28n1;z+V^=^-krnG#eYh8u7NCr53C zh77ng?n4hb*mz9j4D}}rx37g}aA}6QPVMYbb;E$vz)}U~>4^BH^D>3waNbUC zm}W?Kfy$kK{kcjGWEFsf_;h>To8B|B)5F)Gi~6x1tN%vR4tnocRvpKXt2OFONx4K* zzNy58=sT%p77-CKyW#XY;f$$B#f+X`fS~q6<)9o^BNRP@08v93c!9|MX+Yr6aCc=g zPIHD=II|h7Ypvv)Mppj8hf6GjkzkI3_c=${!r-x=la5Y>IS0P`sAwHQ{9vtrGV<63 zGqkv=`HdFKo;L6_M)F(NM=TeLru)oib8NC&s{;+a72-zBdmYop=(0s~|Ebspz1%IWWMbZK7+oS%kt`*q^T(2zx^EMP$R&S7 z-CACk&gW4Rn-ffOuc(Y%m`s!as_!-)Ef=Q6U?Rah9h!;xe{in?*1lR&hatPjp-H_Q z7|()U#b5~EMvH#=u#k$Wicy`fgS+dwMbohIgI{wAvFqG8;SN!&C$j$8>LScL_AMDN zAUW^-hrus>VdT~!9OL;t^tdRw9IQLfMfG$;Orw`Vuz!TW-OF?P*>JzHS)1W-DZ6Fl z&S`7k~_a{wv9-b@>AlI6$Bm}G?FmZ)fv&gODR8m7T{3Hdz*l~bA75k@BQ)9 zDdQ^o>0&swK+)c4x`qAjmZ&jN96md^1)68k=xx zY^o%;H2fxYwV3h>Sb6M!oF4Xy4SBH6PsQPgm?A+Bq(!xZ*2JuDK9aWjfDOlWWheXt z>t3Z|RQ_tgusks>9E=j4W+c>AL8@eJM#6jZi|%qcwh4kLQa`5T%;eAFr|Y^l>?gpc zyH*!8(i>L-2)eON;i#a53-sGw3rUDm30ZaTZ%w_F984CV{rJ}7yGHB{b+A_+SJaN~ z|2B6Xz&{UoX>#K zdaoZWSVc<{{*l+CParc%_}f<}hl!x+m#2!cl^hr{sfH6(tu(X4=7Pk$FPo!ck$x`4 z_6wEjbxNalV1=_>{`fq4vCtpbMS_lZ1}Ln(^O;YDn0!a1Xz_iF$FoGGA+>@q45<#p z5DgcAZ_d~c@(21I$ElNA@Q=?t6h>|Hlmdrulph^9J$iL8lJw!P_{1#m31WyVT}D9J z;)W%r)RshmX`C6kjIF$NrQ8EtdM*|@Dp2B_XCBw|C0Ggwg|>OhKN!|T=u(!u3-&X__|hER-Dk2fg_lem zpcKu*I63X}>~|=P>&k}%-1ENkuluS64!)%KrgnX)>E!n4=`#s7eAc;h)}R{g@~^GFSaH$TeetO<0E2h33$ zQb0LK_FTmW)b*jqLA-`TnOA;gz-2U5c|n&~ueZ@Zg-fCq<=~G5khuad(3^4t0UhCUs9u|xtR~JiI17Oj zr(dgpn=_O&84abFjuHx&U{Ij?1e327-l-x|6OYBGUSnO6a(m$* z_BZH9LjYUHDGEd)_5J(l_LEEpCl-}K@C;Mp%bqoflOKiudpG#>3;JAh4{H#|oWLVk zN&(7=Q2K8#*p>u>%WCAU|>gtk^_s;&(ij4o5LD|mc)7&%QttGdI*<9#IW((Bv&u77>ARsN0QG$k*h$cF{0v1 zz%m9_xczX@;);9(0a~!`$DR*9uk*qe!K5#f0q!jz%beGpZkM~(F zjrC{Gp2_*kU6>r?dZPA>(DGTzq}VUdFCxCfvL3l=Km43~t4jC>Z}SD3kFazZ3*rST z9Qz~S9ATOd>@7>$XCTHG_Cr0y2i^s+2sTbIK(CcJSWSRr9~5wNc-EJLo+6T!uHq|k zvUcR&OE;anD;au;O0Q@fj?A?pG)WZ9Y+Ay(t0XK0Rfzw}Gq$?jTrMa;scsPg)k z1F=~nhtC_skK1R=+L&-7eN?ro#&?h|=!4e?JN)uv_rPO0FY*MhdqpW3mJrwM(Ca_X z5WgFoOU-!?PK8`jsIF6}^~5D%tj~nZH%g$h8hmp=Mi^)Z4o=y@%+E?-B&ZHY9jq$q zJ8XsSV7(-W!V6|q*3V|BS{$i-dz{j45ypEemvo_slQc@am|^^9U!~ufEek|ZBG;si z449Nr8b?E&YbvO{K|_1JUbYmi;#;%oTOU?2Z7NO$8>xcl#}_j)rg|X@D{9_?*3GA} zBE03z6X5&-z``#%x^(@)Z6J;~F5E(qTV3_IMsSd3MN1fGyPkV~-lJlj|=vyxtuZkja3@9pFlcg>@k>uniRZOQ)_T?oqjJeR}qyP;3V1{We+|MqAv#wd$xBC5p6N_%Ft%Mt5>qbkNYz2?)u#9g1Sj;|Z{34m3{zgmMJ)8@Hbci}j&o9@StaaIBUK z-iiSL`=?@mHS!Kkw*(BH%=keFJAV)Ec@-RH9@RQ+wST7|d5ZEZC4dr5*_F}4i91?_ zMJ^s^^=gKc9v=MyEKHi^lU7=KO)3~)Q|IFPpL{|Of?+5*`mN(69JDcVPsMdElPD`p z+dBhRwxM0x3M+J2rJ89t1vWr_`zYIw$DjxIQEW0I9oO1Qqh1w;-#FsS9rA6_D%aL- z7)kUy(Y1szxoRkL65n1UDpwV>p&1|)^dnY~Fn*mexu~JE-(@VHYd|Zm&EhS1D9hTa zjY2oN_w$-Q4^sYq$~4gC(P(NKX}!5O4hPXVdr`F2k^py;Fegwpvj%`Xh?wX z`$!r%uqMJKk$ZxGN9ETmC0E_$((xsQXFVW(ofhpN6^)(Sj16C?>!9}<0`J+0XcKDwMC6+=myZ`Oft^dQ+YA;CkAb7R8%Oiyq=D#n5{xJGuv;)h^4d9NlP z#Bo_}K-!j*7gAR{$2s+vyqRt)rge*Sn0EQr{ELOpzIflxNWs*r5s$i*i{9!Fggtf6 zwZ3op&HXCSDC5tkv>RcQ*4<4BTfDW*3)+t4&^_)C4YWF?bG@Hs7NBB34V|w6Bl&Q; z<1bz7*KbV-2{yYPc~c)5&j~HOd?)cbKLT65pRQ3>v2AJVI*bcdHLvai9OX9kQkpL`p_D+S zGT8lWec#2%x!R9vLb{19+s)OegC;z%l7)pIkg?yfJ#3|u|zR)5>2yo|*dHCXfZp?+>pW2yEO9@ppb+~Nn z1Wv;eaxgR&TPwfr&#ljQP8N}Q^3IyG@eI$62CcCVQOAIdC8!CQo4Q2vMrgYW6qf5T~dbb ze_Cy-YV3>HO0RZEk*czbk?HT)wF+}o3)cpO**^O&I#fZ^(~yv1c{fnFIC?V|;QQkO zG$(ScFd;6!nHKF^QJ$@%kd>-)Fu0N$H=AfHP7DyiJGq7S^BMPx*|ats=U3d&F1iR` zWGhveOdG>Do!G{^_9H;c1Wa9?V|9kpm0nB`wa^>F~iLy{yXT(&V3PFzg-pRGySbyMHAB z-ynNJ2b~ovzUizOoc4{jDw;3St%caa-evAui7j+T91Z19II1?7Erc?Ma#1Jmop;xf zH#H09E*hYcT;t!~S4->>NhR&gKC9{PUK1~JWKL!&b#T_15BX>EnBF4u@v6x}LeAg3 zeO0Wc;_Il8{o@wH&9o_PEK#;*T0}PGDVOa`sVKZ_PB1W~Dvo?p6zA?@x0mjtqW4i; zf&b^k{KS)W*1W2UyTDA*7ZLUcVWmtXdY+xX(q;k;~HP`19RB+#{c> zDsg{QD;=%&$uu3JcKJ#@jJKLvYftX9pS~L9)T16b>Hp_H#mT=r#UQ1%F`*HlmSxf) z10vCmJJjE#QUyA|;9U>U*7O|zF*iZ0Z6zOVab$md{N4}P&uH_%Vfiolkj)P;Q$6+V z2bvdn3OWrj%cm6FD&3m3Pka#mr($VM_tCcK;q}7F`fM&wd@i|iH`9IPcVJKK@7^CM z8u&%PjG!RlcS!kHqe)%WH)=${P6Uz1^W-OU|7y+ZfVnB4Mwh zxFXcawT=2018CWEFCFBkABIMgLW)UZ93za#PGys;To(V5Z)Pk1lD~QTA^+%mO#Dz( zu!~fz_3|sH)MNFQ?GRNKZe&n1{4Ds*`Vb0w`K-9t1c)Rf%4mvtpZ1@dTVNP4Oy7lk z9lshoKvUwasiyx{XjFKx`}2bV8yC`Gb{7$=0)*$pVQuuJ2(u!3^$%BdoA+Z`+HFcO@6UlLFO9%M)#!B}%W zV6kAVWH=Xaa}XB@B~XA%Es&Kaf5`AtljGK5_10-4`b4|KL69<#e8gOde%Ka!YlW$) zC4)BX+a3xIP_xV+Gf&0l9UD0ux4`a;c(bEO%ryGC&246?p2=8tvn-6wRPL9|xguuu zZ{C`im6`&VVHuMdaU+_QSmV65C5~j$b>-^06l}o#?fXAIg%2M(xNbm+9-kBa$^N*b zE^N)CB>3s-T(Oq)jMK6P9o4mgzVUa4vqp;1RoEpZ@{{U-p&1bao0IR1kN@~@^32PA z%U#J(iEzwAM;8phi{*d*mTfzRHLbk)Qe?h;I%7)|%-J5!zj}3KEgCE?oj5rDQ2evT zx0LG;kp{iE#Q;2gtVgt%p1$rfa>5r`U(JS*h~7mP(@@k7ll^OJ(dh_Up-0otSBvK# zVK^#=PhHR7ljXceDg)T^T;y}BZpw!6V)a-41Txnn6;@v;dAMW}B>6bK({NSFb~`KE z@hy`+1q5Ar*atVCjo{zjJrk3?t`+Ktq({6R9$qMT1MD=J12#0bO^Ghr;zzV{!0*5} z2&T+mxiNmN-=l`h3ZcUm6>b}!>x3Yg^Wqy8&qrdqGf~wl|F#wzWx7)z_|F`rdJV|F znvGU%uo%ghv%8XP;7Gi4Qxnk>%rw1Bsp>BzL=D;rqKx|Aiq|r;MOynVo)buIs9cW% zp6TYlZ8zT??)ca>d*{C6>aINV#gbTuPRSMaPAT{P>Yo_^hZq9glm~l&&x9sz8{RvV zdA0rQkG0^7QInu4#X>QKPMzDPZELzDkscgcc*ypo;*kz%fG8gD+-$xWas1ermpY67 zfNjA;YrjSY(q2$d$!Qhby<8XNy}cUqKhkF{7G#G&rYdbdUete$ zTp>u<%*I6|?FC-Yff=hKem(8IiB;XqyZvPD&D~>{^8E%kcgCIT;VZJOfbVs{$rP?6 z=vw0MAi^#B0_OlGrO;3=asK1F0in~qWFLDV5>LeBh1ieRz{ecT<`8 zTsXAtco#u2D_QofGjX!haVRFdW0RTy-&u@DXxK)#`%{Q07dqPKui9{Crp!cFby|<6 z^Baz{W!NX%UWZ3zE@acBX;%$>yh)i3mrSlu(=Jz@>+QePReC_<@EI-ktCur)^N78# z6Iw6miwhur@gUAJVCD;THLIAor_Q6FdlMH>UrJjS%_nhYf=pS<7NegTJ62K9!Ri57^ zt<2vR=|ROp=aZEVV%I0ALB1|woH0AOMH!J2tmGdf)Sb`X9Um+zqQ4%h(92-|OT)6D zVJ2onEvh+iUzKR^jcwQ_sipBtWwS{j=;_^NAcf|S+vb^d9DZaNjPNXapq4UUi*RW? zV7h$`3e4Xk_faIAozve|+X*}xNE&#J%A0Igt&0Nrqyl%0cjv5Gy$>HP0s@ZyduUN0 zea3I438A!$i~W?McIZ6PY?6-RiAT;(I4d5w?P_iPix|S#t3+oU)vfjsE+)P!o$H^oZEOQH}uc)?sI^6wtLk0|4HdI=CbF1^3mA7qVay2P7ro#Ik485$`W~ z68fzTd-{RUDg}I0252L2Qs5PVo{*>goYgB%+EH7lmuZ|O#BJTH(s)6Pl7Os^Owtfr z%K>wudhq5dEbcOaUiV}>1q&Z&9>GCR>0&V={G$Vur@I%~l)lN?$&Gcc-4c~Gj3sF- z_IXq{#!bBE^S=D-$>r5(AH%Jy=U#y&`RA%4u0@m zS0z8ER%_6LvEU&N{uIEaUSKCD8I0TJk2ax9METo6xS?7YpM1$w1js3?EUz;upzsNzQz5PV_$ML^AmxO79?j8AWbqJ##al$k$_juo< zyG+6Jl-r>-_f4hIWo+)i2&7GnMxwM@`gxc-=wW*zJ&kFTxoO z1=V-%6dQVEybQHI75wLFQbAG7_?Row<89G>Pp(9z7dmMUaXVHo@BbjQm!vQ<)f~2y z*fXk5=*UWBz_ChD^0mgPv(-l62#*`+W~QLT=74qAFrWf*r1Czr5`K`9m0?J|+{lpl zLLR6Rm5p2GHHD!40il; z%?WvgjEY0+Tjwi#i0WH6fi25L*s*kU)xRpi0Fl~lwlig3@}lw`UVSg_`*%3(aC;g=t80bq zX1aW{yz-bJkV@q9p$%cA%aadNhje$W2K^Y_z-Q5u4*G)3$?)x}xt6Lj%^t37Dax|;((q_+@m);7J*ZUMqB8uXl z2S|M=Sc9;-O%=VC0?5-6xU8Dfup_|!yR)>1>5v#FFb!LL-BOidCs;${dEvp#_z>w} z!&{xNO36sGpfC5wEuMBe3Uo$b&R!L&I9IpDW#G4Kh0}?L9L1wc?JSv>E>#&p_Q3y% z7wIkL&wR|38fl|lO}P(Xla5XIQhg`HegN7W%(Db!%cpM)BECB;`sn+u@&Bu#^$Hcb z2=rvTz2Ca462!P--!JfYqd(wL^9W-kh`L+|t+@a1`Ww{nISr7}tQJ`~`ke|+GT!UGe{7mf6o+_cq=+HXG06p|qD`3w>Yil~X zmIF@tF8~BPlABk)@Y=T(G-QxuW7`P80xs%M1avYn6Ms97v^jCn++o| zB`)Fu|KjByP|UL1F#Gwp!rtXAek&ej+W6Dv)aPY&RwpJ_+@H+eyVlsd_MIjkvYvs` z9wsiEIBo)Nd((eN@hHB@Gx~TceSB&7w#jMqgG+g258k&PY8^_j{Gx+{=(a;-v37e`vJKuk~YER9MNKG`$V4c=B z*Qqt@A4$>odNfmA_{erbzo{=kvA)Or#J%0%i1OtNS^ar{>PbkYzWHP<9N1uhy%0jEMZWBW-w!1l2xlc|<9_6O>+p!K*CuWvX z;U!z*k}(>(4uRUM9d2%z(CV}f1?!hRd|-$BZ*D%AoX#7xA%BF^hH(r#q8vPV!$n>0 z?u8|tZ)x_FsdzBs{!I$%q4w@okLg>MfSGIEhk6S;b;oy=3zx>2rY&Tx2g%s&ydRw8 zX$#=^RcXGBc4g7(h~jNYF*4*z-+s9(Y3c%*-RPI14TNMe^zP8Q=5%J-@ocKqV9N9z zkv{7wv{fCe6~~aKzYJr2(8t@ghM&SX{vyTZvp_FTI zzn}Ke0$~ejbW9$S(I+5L6>0SEO{+;2SP^`mOv8UyD zY@C_zU!4&+W48s~OBW@3S-89MO}Cqz_4UeI-lpm<>{~a*r24H?x#r0l1a#-bE4s)m z*Qe#9HYPnoCf9sy4+f?fk7dGK2LqT(wkll2sHPRAlXjLLtp~*VRs$OOogx=%BA3tH z;v#<-`~|q4;kD1<_;6mGeoWNMsJ`(zEcn*xXq#jo{(xh9`pnW=TQCY>n$A%4Qt%mGz-n~f(zA^)YAh2r`ufupS9dzD<@<}w5WetGqeuHFgqBvNPC$kD~R-)O#^(fdN|YV#7E6x#rU( z7IYc^f$4NSfR$_VvLGcl{dQ52ay0mnMKYCoKn;Nalb#LSN5kD~~*WLU00<|sA(>YboT@#-rke$-&h?k2ird*Fmz<7Caf01WPv41Ap{`NOIIX91ie zwX)`<=r=p{5Xb}7qZwUV9M#RYU(*ri>r>8-4~iV>i9)9QOY|eWWhZLEPxs|DT@k=) zlxyVEweWbUq%F_-T|c(H1^hK+E(x@8gsep2S+T6Q?+n9}S|uU`j?X8oLoIRevBI?0 zI}l|ldT4g7X=@<9JpV*-I$_t|z{?T8qU#@|_Fr9C-h{s$h3%yUR3`cDt!(S);2=iV z8)58CzgkL@W_d{k!=UU#oA`9zMR0#5Xk%1Uq<;B~3^5i+i#}>~?;k;O_LM%VDwM>*F?G6lT_R7I$_`f#AUsTVCK&+&O)b8L3wk!etQ@%K$3@Ww-#$FL^fU1=*>izT9mk+&2i$!%O_TjH7}6$5uwBY;ZpGoI3|^PrDzkQr01@O{c^h<4VBvAWQ=yN!AacC*wn*Y-ajZuWq=o3%PRwGwW6ho}%N*!z41A zHzAh^%(4X^hoB?F4l%&12zb4}dT7(a;2sb&VnM|n5go#ID~}$9FPO6a<)(w^nY3iM zMy09nbfiV+Mj4Ccv^`uDHdp(-qw#y;_b>HdtgeLBNZr(QtV(WoFL5;be1GUP?~#;@ z_tl2$H#I$dm3tjkmy{oi8uuu_nCuTLSp12gl4OUI!Ka?52Kh}dM38L21cH|^&qn3Vq*F^3iCJ$b{ zqZ=T^d3_~HGolS*M8Pgk?#FH+8LJLK&(C&w52=|DzvTa`Id}0T*>jz7@Dpv(@mOe$ zI`zvk6~9piQ?YpKy7QbL1f3fEupr^@H>n`1ay0bOoPNA z`=4@kBuh}+lwgCKR?Hggp)dXKzu)_nhRwdSexJ1wz+OEke6$v!sGprDz)l2$)E*Y) z*~YZAGRU+KB7K?Y-YMB6JL+B$Ql(mU!+UUe{$!@egO?u5QzjO@ZeyNy?8_py|4?H} z(sr^Q%}GJ{d-Q&|>s-(K^|9b%wz-p{Bt$dgCLgvnsN|fN8Ttp?RJ*$`p|a~6IHXgv z=j-X{nRMrr(eCW(?rI@J3L!S|N$HT`T@LuMfJQt+YS2aA&$ZOJiRTcJVqn17WnLtp zxA^Q!6eB|7W%gA+93>#LdplCrVb6kU; zH;LbW%Ti1knaLOD@a}ER0UrXCLsk`ddh-ZBRlEJ9-)9PGRQ%oeW+p!BlBTl`>gd*1 zJwqw45P6KXe<0$;c`I#K1=;)Ck5%Pr*J=z-jn5{)bJ| z42%d%62aj~PQFVrHbmlF5`Cb&rFok`O&y&t&BcZkA9Eistol4IK%yEjdA}{3{aT&^ z%y&gl`<5Y|jm0whXSb8w(L`KQjYFa@HE^TLyk;f*c5~`ZpP#poy09P3 zKs}Y$bGgYqlhPjNzB!%jLgC{p5-yJ@4R^vhe^mv|QQFTf5$SHf_HLb$GMWT~G z2fiuPTcN_E3TXGF9Qxgh11mfzHFRQxdVpjxGFW1lw_-k-_a-P4IB{Y{w!StKZrHVh z%7TMh86-nc599fKg^yG%?81zpSlkS3lD;?zo#e|5IK$ih_^e0$_cCuqBnk$~i0EU~ z8n7t_y?-0v?su0FwE}~0!nTGE5X-xo%?DeE-K;?^V{|-E)3)zEUX4*6+(1V?=N=@A zqDnB~#Qx(J&FM;<`c|BLlY9h-YlE6Ja$()u|{x|#I=B_^6MH%L&Q1HCPFp9DqT+n%bD(N zT~0{Ki#~n1{;8#bqgLCGdp7-PW}`*hu0zD!^gz%tnOgw;)M&7i$`Wd0_h`a2Rx__A zMSTqMuROG+*F=iu za&-$yRZHCS z4X~ZvKFFz}A-lb;11fLkNAq4vIn1Yf@>YEQ&=auv+vazdLw~62iQ}J52xaA}?G(}` zJ9CIoL+^b+zvx2b`1EFdcphGPp8-N_RnxCVpz{%#8(Gp%T8@gyXNJ^X2OOw5m70D> zd8wJ$waMJD`a4JD6z9-6;u}iZus==4L{K_zqwD} znwGLW#z%hTGe?2`k;r!Uq?VvP-a$p@hh~%IdQXS?jZF6itHXC_@~NG5{^|I@>`XCY zxJ<62k$y*l{1fJ|IiS`A?=;Drv=1(IyEB_apt~h(ECFbfE1`tVMt8;>^Skd+BeER@ ziL`@=^gS+ zff&H10e@Dh!3`^9gTnHZ;_v2`Mct;7L!4*!@dj7A`&c^9t(?!mdTzB^yE-zuaKpA);RNl zORlQ48_ZgRuxnoO<+u~QyapEV{Ewzhwfz^2doPVcE3dyjvg9|PRStSVvwI|Gp2bWP zc1a|(_SMnV?pi4;5_!1WtJT!8}2Hw+chN~Q8XkUOEs7=P$inmYrouB~v z(y@77@t40l<|_Pf=u;3}1fwzz4d6(Dg_iDU9hwyC4P^{3zSFwTz(Jd60K|HN)4ad0 zj!1B$IfMh|aWz`C?#RlaFURY=tDx|Y>#)5a11$ne$JTYO#1#E8siiW(q?+Sj*g=;F zPL!MY6PJpiqhSSXt)8;)HJ$UAMb~%dkp$_xwOhun5~Ke)`ol$qkI4fO8za!?qghio zdt3wF;+-WClW_@iT`gV%ubW(Rs7mK)D`4X-gRhqg^%$aTDA4&y-cp;X?=xhTq-&!q z{8EH+MnHyK?xpJ}{cFsAUb^XmJ-bZI=#H^p_u0#6F_v1q%TPb&Z4uxNZsRV8?porZ z`n;;wQH1EMC8MN9Y@PRGNcK-xZIYJoo67kqa)NI;|UE!1Ca`B0$}9=g}L^*8$~=#(i#Wdr~~6V2u{5mXT7uo zQuWT-pqpICZ;%lA{6b6Wz0qMdhM?}2)vtk7$C&dW!IxR?)Uznp^E9NX90~mk>23L= z*~i=ZHxBkL{gGcWTUn&9Ld-jJ!}@$PLedR{sUGV1g*I7l1J5MX4af59-*-hminMrq zbfWA<{9sL%zD48wXmk6b_DN+zm=>3UNFZsMD(OAl(aLJsFi0EaiVlBv+QX9ZSO=rb z8dhLHU*gv@S+_(nF_~56Tw^hwOyjWRoBqxnTRHQ|k?;Ha$3d3?Z7YS(JFf6td|pqk zd=sHfy*8T0ZV}PDNXA?@L#QW6r;-IFJmGUyYADFvplDP5mDEYE->Lmi5?|IwHm@1{ z3}{EuZHeo*t zXj?$=5kShG?%jY|h#|_o0C+S);UI9FX-I~{it zV>Vyc>`q=7`)~$gf@pF`=2on&732&|Z>@~`pRQ>qQP_%DUhI<{#w-r3#E>ALr&f$cWf=bmRTkolWM z)ywl1y(gk%uQ$*Ao!cUO7Q5o%J|eH>f{P4Ga}@Upg|E_I&2>)_jO4xLE#A4-Z3`la z>w<)?M?HRZ85k688=TyI7S94Nx4MCUg&zaj~V2)WVnR6{;fB z|6FK(`J7Q*-;9Ga74(cN4v>sms3f^|XQDH+kQ# zyA?%)72R)eFFHu)F&}E{-+u9F?>$r!SHAch3FB@32e?nml?YTLOQU^O)fn3eH>HPM zj9!!~c3Eo^+|f4sF}Tn|kx{pPPqF4Vfhn_N4&=%jXIHs77&eKW3NY-hGri2&1Db`3Om1)710eb5HN_m!8NSNih!F7gC=s6qBxg1y)p8lD zym^o{7U@>zi>tpQ94mx6aU+htyD#FaKfsO9C$t;P>L<F5z-fYT(Hj$H**uGZwTvAq?!)!~U$U zy)X?ro5>G0aL=hsJKd%Je!tW(I=-SIQeeDl0ohSM*i^MLg(dqZh76t$EmOfU1{sME zeDd5e{UEqH5~&b<9bZ*v%}fJdc9zv)gfokZB*0pxd728oG9r@?+EG(+9%u+6aA*uY zjp@?TZi&=5=!EzN<{iW(GnUos@n=0RWsw$C%4=MY3qk|JY|pKK;^VD!En$a_ce?JSM zt(uX~dd8e&fpB+I5&JcN;wM8u(woCcf=PIH)#CXa5W$U+k9;8HPe3u^pmpezI8YD5 zBJZkH{Mi`0|MqpNfSS+K2&4+-))VBm`3>m+pJkti_N09(koHLd51F8RJ@&}qJGi6m zyQDoIh9FK1EyDjoLkPPWYHODU5Aieon8=HRiumT|q5A?wlLqF_fzd8G&@$L!c>Wt% z<<;K=o73PMXiMicrJ#RF?&XIw&x|KCG+#$k(N}1t(18acT!wq4z7@Xmm*Y97eP3DL zs(l66)7B6?49JM4**|0H9*9>axpVAT@$>!bgH^{}ThhJVRQdSdw3yv$Mx6$yF!@{D z5(k-|5J2-MzoIn?9F#KZ)iYm>DyxSLOUPfXu466a?7kan23^ShRK*1p{%^;QtL3W- z;QsEg$xG+Z^yV?wks0@~#cJA7e@n}S_d|EjJdBpSV(oG$#q^;R!8zGC+M>bD0r&*Z zCBi)Hqjdt$pDk-{S(4}nbB%d#>!b89?xe5Ro&C|B$&VsKuKOjz55}1K0rUB)lp^||vqw3+8Q}bkAbw)Mru+zu z=0g`-rJFeoG%PyoG3I3-kbM&@mU@e|)9$qgn)R&B|3N*3>_lmX_VisD^ca3Gs9@a= z_7vpsn`X}YiVW;hI}eVDIQ~O0`6#8+-F2#nFrw>KFqLIK)I|}bt#wdkgN@i?7NvsX&{t6EFT=M|&9s+4+jfh^_gtA*}Cmma#VrIX&q zg+5Zowtep@6uqBeVo4c0xft*hGS6xx`zi47OD*~N#dUbzi~C)sKW_e1fVS;-Wbu{; z|15r=AyDMCHS}g~q^gYUpUOcl)%E6V5J_vX+6H^R?S&NL<`@NO^EwA1&xStxFW-3p zaPn4166|JyzAt5ZANP08AC9Jqo$*gb)`G~ z5q1I;E_3{B#eShxyhsX(dE)80 zHPTql$Sh*%QG31xjIWhDGwUI;zcMOvk_tcjys%pz40h~{VjPm&UA61LBxkDl{WH?@ z@wgkko!`%iizdgpuem7Epa1j~skkJ0)By-CEb1xnNz^_gH)t-ZbBZ=)%WL%KYp;L( zXmF?`i4hf`t!~B{y6G)WfZeRuv&qT>FfXG?ODx;1wtrC5PrTlvRb7^rl7qRnkJ$nR zW3_p5`vF+QJo}Ub#~+Tl`675HVm!KOiD-#fHuTsp069&a@UI1@)HPVLT49e_1wY{e zyGm>B0l%Y4_gC=<@!)j2Y2Y~L2h8u{MW+>co~ zH@qNM7ZHDDS6w(qJ)1C^Z$Wg+Q`@tJtj-` zc@aPU3y>h)qwd9}Pi3E76BV3US4~i_;0U7VgD5+ic<2r+y{j?&nNO+q!v$v(yQGs^ zxv%r*KO?UmsJpzv`~`UKwjsr!A+6C#Gkvo=nNec3ECI~8fBYGtPj;6K2r`*oBbUeQ zxW-i2v-GXxi=q`1BYZmh!)^|Sd7rDVmw8rgryZ&JVoL#6hRCXuV@YOZjHK*IIz-f1 zWOfBDAhSmpAv!x}>cF%U1#<+H$$T)^vAnK>1&Q1`TdN2~omU7%V-|FX=#azRf?WDM z+#kDjSUMG^*0P>chdk1>*#)imBigT`cs7FJ>xoWSpBz^SR*+9^$hSy~W3Q#`<5>-n zyT{7UDb>y6H#UO?{tNH&!MCJRni!R_25hYwpbrp5&FF^0F)c}#vI}~7HDBhWmVfT@ zTB_I_#WkbZv>Wv$#fgsVAkMe25`!snF@wo7XJ?MtQ;}( zpSpg^NPWDmCfbwh~*^GqC!gukOpC2gA0Sj zNRvCQmiR_0f`~Zo-z^W>vYa|uEkPqYXYaOCK8pkAz1K*4n!frvi9ntC=w&$>=$~0Ge?UeUhk8-R-%Vv2*REp6T>x0=tAtaKHV!Al~ zdq;Jh*y({OND_a`=G1zZ^wth)2e=uYHV&(Qa42VUaF8Ap^Sb@t?I!EozzbheS{t2( zTwdxwJ?z*n^zVtSmH%dTW~YLKeAPRMcQ6d(Pb=P7Rw*IibjFymD3S@ya*Vbej`GPR z$5vB06cx3e(bs7?R_a{#nrQR|>c{=VuXE*O0BDIAPXF@aT(#?eQX-UYH|paE&>F01 z7}dLf(ybu5cMar@)Hslv46Dw~tGQV#MznZ}sXu|T%F_;$6eqMc=CUh`%5Z?$@%5K+=uH;@s?ljQ0gk{Ab9FEO@c2^JeK)f?Ttp0GAX)+)Wv>~>auK4z?cG47xoFjjhW^-99jd`dkYV%kJ zv+TUMq9-uas0u<$^Jozl6O91ylHa3jIL*^)g&e$FO;GkV%HAO&nZrQXoGsA4-5^K z4+o=?p~wF^JlkW(e7P5ij>gUA(nce0DfCY^OHYVlg24N&MRa4&Ep@m-zM+`R)z&zP}keWYA#s(Ih*MoviC&5X)*l! zK~u+_{9olqug|pKK=}R3h^km{l`-cPc}`qrDOiWc!nBvPbHaXJ!gs}Wn-}jJeIxj^ z0tTE+13ALdcmvdJcO&@=HQsB0;taa>xCiyc$(2$QS2r~w%Ux@%%oPJZdHY#pMKn&Y zjre;RgK-$@ zka}8ZS%#4Ikq!0}tknZ8@*uXcj&~REuAsi}WaG;abJH9!fY!?jB;K$~Q~c?c1kwrs zo3NPBuEUJ54z-Rt{EoX&x3$ccK&ggDe_E!R8lLej7fp5tJ?Iy{V6v<%Mo^gQlv12r z6Z(2{{V7dKjAk)}g=_&g{f!2V-=^lP${oc9wkCc1t7Y-bIo?|Ns^sZ)gtzb3kFXBK zz2i!q<7x#3-v6$Ji>Hc4eR(cXt&?nZP(^u7WO{G=BRYNI(^{Ul)p1VN=-~C&^>@^f z5k^hh5Rw6BE#PKg;Koz|Ww0jYC&2*T@%vJ@Ik-RxIGS3Z9j-*a4?tV+D`-9zG(~)| z{WA&P6x9z5%80pI#b&Pk75QU@|6PlYpoVaSywCl}ySs=VbmLaU$rvc~n}Pfc&O;#( z6}-yG1(NY-(pW|jrtURI?u6QD8z)C@Lr5EF6oS>{&~_%0ZiD1oHE{EH(FwsRc~Pq* z$bY|b46F8Hr!)@+Z z(R{l;1mfwl_ z(0OsiR@CCqd((OO`hobX&=;x!ym?*`Rk>!AuTrGK`aWgydb^jm`OsTI=3SPM8L=Bt zG1p(;k%bVD#GCSpTgFpAV#UEpScC9n;Q}FF>j)Ft+5Z^&s5=*?&i$GpbKjXR*59Q| zGr@v)&Fg^^3Xcx&8~EVZeYOqGI!xiNLkN>|&WtZpi;MXPd^9%VG-Bu!8$>`#P%lX# z1Tbws=MQU8kdE)tTp?>Pj?n=kWzHcf(a;z4Y`a0#SM)<10^Y8L0 zbT04v(yAi-+?f>72+-~zq)b9=(Gln|1wJL~QV(>75i27lK)g`PH;C}G0+~~40 zV;eL^TP5lJ&ImA{cx)>lVBe(3YZwqw5|UdxK0}gQ zvHt#0@5Y_mlhKGVxepAlInoNn)Eq6Vds=-z~^tl1l8KdJ^`Yu7qt7Ow|Lx}-dz5v z>!seLIJ?C3qx=%M-w}`>8xHUgQ_|Y-d4!jK=yY|>MgC<;F|xh4$Iwn?M^<1~gt(S5 z@DIgvMtmFku-X-BN+eoBSF+KtcDqOHGds$`-BAo)TFTU?YE_-z8;%hApmyO5E}QBI zWFFWfcQ0tbJaeA4%$h#Ttx*SsSx^fY$E9^(ifMU6^HwQUB@BFeoI?}Ema-VqA;hVz zEJl_OK7{_wbx=zm2pO7tBD zMGo~(G%K06Lv3Jq|9`*n5kx&5oP$Uc3$l?Ob{WkQFUPDna+$VlOe@ohinXi$v~#51 zOt2TBeb5rL;%ng&rzO1Np?s$PeMJjZ5%u|K2GZ)3aoPf{R47Kti{Sf)kEc8b|08vM zeY5n6cECOM%MTAiai%HHNJRc~v=DFnA^uk?f?`#duid{>T^4`SCG2w?;X0!30hiNM zRC(0LtTjVacqt@w)RN)3FB&zl`p1OUezSs*Rr-J!cI&?P`s1e838CN6lUI61=y&KYfCV`| zTduGg)$z&?$%^py2xR&<1j|Jk%!QCWf#xlup$?W`jUM{t|2DZko+eW?6?6hXEbM}* zVl5}QhTF~}PqA7)bsHpmqQxhlmJt8nP8%iVIG$+15JszD7OeBd!AEOQ&Rmm)2&8Bw zm{BT+R3B+L8xG)j*;29I_Q3GBB1J|c4PG(ALz%7Wwi$PW4nRHCxKHGcG{e@=zI)(ypy zs#WPDV5!PgZ{a+ni6W-e8i>d5AU^uahbSHq2}9JRCBtg^P^4U|3w+VO zk^>k&7F z3McEezB_zFe!0hV`X6ioP#eTk;$}bxsl)Jr`_xHpdT$Kj&|Iwx8482sz(u>wjQ!3- z?arQb&EI=TMr}}O|BLQ2FS*==BwB|6C6kTIJXl(sy4N|R#9A{vn-F)$-zLgjjbeXW zXLg^!dJBbuu1k%IUd`2Ecspc|p@++`ki@M&i)K6GsaV9RpJ zjn--D%zv$mD*L(?p2Yz+IrCn=xn2f68)GL+Tby3&f9+sG^xqO-y4LSd^7-cl=ervc zJl49p6VJA>ahokZHo}6N_kG>DUaBQ%toYlu%iiRqp1v*J5q%M$q?Y*fW$`%Lpa%0} zJzDYink#!9Q0BACLX6=|T&Mwq(~J=2t@!DqzyhldF040M_j|y_H+Kz2=NxIoG}y|b zJB=kC>Ea{qb7X}D$W&_178(4p@3Z={#|RzX_t8)MTl4)I63JGtwv&Uv2bOjtX%y%m zS~v3K!yI-IkB?(g+dSR0PnQ95z58KxA5 z0uy4%{#G5v?fj9quv27-ioRIF`M}%K6B)mOYs9)|5;=(L_7Yu($R^c-bTHXf4uF*46gdoZnru}l}g?2*f<-qV=iL> zpX`qx3}IBRTrmmbJIy9wtfV!a5OMJRJ`c!omw(A{QRk`n@w#R!1H<4VpvbgdR3DU- zMQK05pGa4~^r1RqESFbhSPh!}6M13|mw*!#6Y=&ew$Y}ye%eOGOQ#57x&g7Ry zkJ7Fb1^ePyngqJ>ZP%iT4;HhMX?l4ziM=C{ID)#fO98yuMthyZP5UXn8H2uSq-)YX z{50W=cixB=-E^wMgYUqnbhD>;tXwPlbhJG4ddW3JxLYr)RET6?kSyQ=d8jI2V8)?c zA+O6OqFlSOnDNib!wLSbBX^-8N(a&NOV6c9=&wp2Tc~zH%g$(PN#NFbgZ_VG607(w z16GY}gRJxp<(Apf-sKUFME5>7D-~gd#92NRZlpJIDqQLD9;dceJBU!N;NLv`l0LyK z#Mf(o6kV6?w;867vG#hMm}E^D<7pAh0MM$wbW0fQ{IxXYEYbfvV~)D4b6e8yA!Z$f zmuXczB@@Jd5BgrN#J%!ARBxSFF#j!H#%sMATJCI*Nus0Z-=Gb&ouPo4Q%XRKCYeWr zWujhdv?6y;$IXy(-4KNXx^>_0YxbB`V)jJ60ui?q{!dTmz-G4Jd?kGR~2?YMR)jy}F_3QKDD$4C@g3A?`5D9&d>@-a1 z`;5R`tCsqkjx%DvlpVch)6L%R3oN?mNqswiwMUQ7FkYM|y|>n0g{#q9NW99hY^(Og zyq8W#G=*6@6YqdBt2S@drKLuCC-Dotaab5*E!}0f)IgR6Xp5co?;7AUcQ^_i_VPs zr=ea`UAp;6k&PrzmKH2aB>oJ0g^mS_tr?$>Me&lw3@69esL6--K#9CL=X*bqD~;%P zk743+L#0T%80pPYtzI8LC`MuOpD2$e_Y*$~S ze2uX`(xbW!tMJUZcd-MEcU)56(4!!SDgH}s3OOsuaG#4$D4ueqvubk_m*pxmTBGd^s%BLV%QXA#{4O3dP7YfFy`8D zGbo~?ii9!RUbPU2DTukLMH~8T5rS`t zuBE-`AQn&xNA`lr_*aYc!8BCJ-Q$d|))|b!AVv#@sDG)yHKW|X=6pmedo5XX$mfokGfSkQ4v*N(-ZQla0{5 ztI+tC79_nF5}pe05W7l_UC>`_Mgs#*xoXRtBc7%4eKZ?q{jd0(_a04CRT?)EcuwcS zqZP*-U6r%$ka11o@b0-mfu^{BT;F85pz;HWiVL1f+*>QDrDTD{*Lfpxn*uYV0JRS< z<@-b|Cp&7xOq~v5Go{00`bCXP3U0#oyEb~0TCV-BQ!qqHR(zv-n%1wInEp!`@Ea(G zcnc@}<4!UOkI{<^r7N;c|2mWIAll6;I2liL5_B{|T^s`_+t)FT;*HE@Y-X};Tn)1> z{RC+lTZ)uG$P8^5B^I^yt*#vI3;I#1^FKk?nC8Lu6l#^Y3flcrr$GN#hwUL~Vc6O& zvXs|YX1W@FgH@fny(gPN2M#{1^YRXD*f`3*IrEIO6Suk4#eLC|ia9k`gPvCFV$hGZ zC+P;y_c0$n30NtY)~H9+hBVSyXx2QZhJNmvrD>Mw`~3ewz&d% z20CI=N!wC})Y9uMj>tu`h5Vho1B}9d#?d7gqBT1oIt9OWQeUYqyw0}CrI(pdmypvYAX48U z_(fFE`N~_ZK4(+bJ>03C7NTS{qC4`{_-T_Tso)7XdFkpSoT6NF?G|rBr?lVtt9P)` z1_C=c(d+O==%-|8+^v)0DYj2anKzfL=Ze712~aNy zz+LlKc37_ItN%8cU+XM#@fqAHCHhy^0?nr$DDXBd}(br?xgQY;;Hgw&wq+El~%X6k&!Ts3OtrqO`NPln;aAytYjx z{o}&2LumF*Q`2A9Jhh(o zrTuM07iz}vx4+kpEhu>!8$jKP;uN?)B^Z3?Wni38bIP}*xZvTBWf0Di6 z%S-Sq-1R3*FD9t1HJp9aP14^wE;im)zSHjemu$AtyHOf9`WAM zIz*WEaH%>cf&KPZZX{+>KRA2Wwl)M2k`@g=bD{l4-PF~f&7J)})p4Jlw**)%vSB@< zusZ$KRe}M77&}n;^Ku#WY3udRY8?9FGomli64wSkvPSv!JmY|Re*GG+6t$mT({$cj zT4_V{ZE-!P|9w@EO*>a4PZ4o9Ga)$0%M)#6i{D5sd*$`wP}7P}NctZIM=2BslQ6;&H zB-`FD6z!1XE;l(#bZX8G9E1(g{{n{nnZ3CSFw4Cs6&MkVz5Pw(^9DnSn62P&P6eNB zq?QRM?m`av^|n}K8#xX_;QPHAmH61-6#ARf6*(LHf-7u9QMDNA#Hr z1m6|cgKSgzl8o7jF{O~pckm|^o`-i z!~D@(FT-_@V9m1~?iQ(8Y4`b_P-ED zEI&BO#w6YaEq4K(ZI&BQ_5z=1DGm;;Cqbv6*jrCP=3QP*J89d0o>=F!+B!J(Kt=*o z;lTCEsRK`6xuy_IjloxF4JWm*2?Er1JTr6wRK68on0K$j^A+Q~k^&%H*}fdU2=N;c zaDOUtrmef`z@nndIZNE;YNAT!x~|)lVKHz0ilK3d7|-@E2yxOO=;8;5<(al`z~`M; zUeV>5)R_V+1gk0o!nNKyzchF>I^bl;aQRBuLbCjx7GKq z$f51^7~^kg8^SaewHCcBK%?H~a^{RnYp27cQS3YmySm)_GY@IzdX{d393%1DTk(Mt zNtZ)}lzs0GK38vAEV<3-6<2##n#ik~Va%6O>j2&&U5mH%qdrUPQ)JMggY8H1TKC~uOPANUk zd})m%JAgzw8g^@nQCujmT+dCezaU#Z5?qRS^}%uVRaIL-Z(pY#OA&Pbu)rqA9#{~!Zc(Q$R|tD5J;);Iv6Tib74t2E)ZX%(ADiq4*d5cJt>aIz=dBZ z%_U_9)fGw=+WX(>^H_|8rca6x7$+c~RWKR1r1hrFBL^hvGq{Zv>h5e%4Z2Zr#UpdRDma}D!J2b=`R7Rib^G8DTgZ30E<=DvBEYHF^?%eT5~&_CMaOi z^z4uE_g6<2#!x?m8?oThK|sJ;^{aBm#03BJBh%tBGcHdp0A7YuXSImAq>he{pO5$c zs~3RA3<7r1((*6kNjE2WGyOQ3R}1^_{J^D@ zCb^y&i6<9oRiLDbc$03&-L$+AEL2Pe6iax`f6)Gu12*Yu%&%jWbSx6_PiT;O-LCyii9(gb2BW3Gt4KSUB>+kgd~ zM#M2WuPf7cuuw*)GfK)i*{1-gqpXueIn}QljDN6gyJGaEw za>5^+e#a(C{AT+gVOf#z8d4JepE8{Xpx(&~ z&SC!Cc;&(FesIUM{81`kI>7DY8#5FunQnnNE?|_=6VO%@GxUA3NKW>Zx0Uo6xh6f2 z1w+OSCyUBot**R^j6D!#c20qUg!sa6bGh`3`F<45?S7OHxty^%wE$m52JP0I(FoQF z!)C857d!uI+fgslDW1}uzA&KSPiCI@o2BL#9GR#-?aSP{KIk6o@e$YeiqfO)4fw)2 ziD^CX101K9czLM3g^fJDfu1io9YW;FPY%Jyd{ojeKE=f>A?ByHO~C0WJ(%Z&rN=5) zj+y=GNUzieuBe-?lZ&Dqs7=-mTz<-9*@Q;9aEpmWoM`bvG4triaZp--3xWwJWoMUSlc*#4;n zzGUbA1QPi>6=WnZ7m3iKzevftR?azEJ|Jk>;f9))o~e3+A_<6cWh==dzRcuj59Nby zwgtar7F~B#zs*=VW2n@OP z&!Eh~{iEgQn{UMCaWooCG`OB^`|J9&xkKqsQQme(u0?0aG-9?pxM&VZegO=E^-{EH zBW?Ii&Do_P0z;2MD~2M6kl3NN&8^k+{;)qNhEKof7EYyy=z+A6ox&|ZS!y_tLt&3W zMTog<$l!eBk86Hq)0y1`A(yQ1NYc&F`gydZY1l{^0P&KpSm_vFbTacQ?i^VOW zkLR9wZEukaZ?}-Wp68}6pXS@_B5hfkvyY3G;!Zt)*fk{;i@k&D&{Rd;e69hdDmMG?^HUMnfmvq+W-z18fo( z87@XWEjWovKtW|qq(CulP82=5@4m;#J0mRoO68bQ*0sck`Mh@E*J`m(_F*g(?AD@t z*{=Dm8!&+|aaDT+%_5eVtv~AuG-485T}0%)sjSGISF|4k04*Jb>*C?N(XRNUT$lfN zVsP}=rJPviZxV>@r11MQsmJHwx?#8TZi)G9A2Zm~-zSEfE?(d^lw9wL^UC&*eGR>- z)pX&^vpAgFmVA6bl&-;3+pN&dd~Y}4o$pt%Fq@N7B)%gt@%@bI&8y`^y>r(HB1tGM z8*fGB5;moi=8b#>DwqC`99hnsxPjR=yE%!&EWglp_)>FxiR^%;AxabYaU@4WDhUS+ zP+RQ@g-0II+38o3(4{w0;dUj+?r{vc82wPw`7_%pe*M1~pMBOrJ@p`X5y;|_9kWCQ zT>GtqO<+7INTQo@>UHp=jG&fX@dlhI_e)yFj9ULC*WAGF-BtK%AN16VAc}ZaIQV$* zT+s(yicj5M3C(BNtoSk3=k$~>;uqq(knwT;w>m;+%14A#7**ZZbd>dm$}tmI2AeKM zvO=l-PcIGLPSAnGf0@6d+Ccy5Fh6O)@~hgICK1r~QyvLPBnTf%O&9-2-{${(gW57y z(6}!#^8V)6E2$IZD%0bD$9t&t<#fAXwM}Im_Tasl55~9dY7VLhsZmWpw001#E6u1H z{^w=(j8bGZ*-P%KtE%$vrcHraauMh~oJPQLFmr$EWmfBbj`{2~yAqx;E%o3Pga>yf ziLXx7;dr)T_6CdU{a1sa4(B#ljzGpnD2oJ9nen&J*IYXLiQ&!cAABIcIjMSbj;VT}N(B=_;o<0{g1)(5Wyyl-jR>#{~WQQczx+x4oZx%7VITiDr&xk~?$opPt>m zDLq(aRz2)STLa%?U4@WF`gQcurAVGaz~4^+r_e;dN=o*|_J1^-_*%xDM2X~2e@$LXk8dd8&!BgvMSs6RU$mWp#@{k64`ldgr-_zr+Or{b{(fOP852WdC z#Q&q|-2a(;{5P(Uq);kE=1^3MkesbjR6?laxHrlnhdG~Zq#SZA$=NEW$yL9+Czn3XU;GDRzn{#xekMg*GAOBrbV4EI`cU2 zfcX8Yok%miKj0*az>yO8q5L%+!`-czE4A-KJFVT=1r}Ca1(oZ+p*|Mzb>@@n4^*1R z(AS1HjkaQ)cxE7j{R+qH#wyhho%h8R_Lh8&MN3Wj^3!2^Q%(zP>%Aj;r!Qq2DL!#2 z&Gu|>1^8~4rY1&U&pVu~Q%bHL>{{^=OYw@;b~*XAO*8eqX{j2{K2c~`dr%DX4RV%H z6(K!%EtBYGo>&4WcjbNJ@w|Xbou{g1H>p@AA^%J!^sY6<91q za5`%R-b0P@IiK=CvsJ5DU3I~$bqL7xF}Ide+9wCUo17Ws-NxRb40|Ckt`*nCkh@nc zZ;DlRO@OS;)u5y~cvQ=SFQ^(E&~pSTkrnR!YxNk>S5nGF1fZPSzLm|je>g=1(YS4B z+&HWSB|6aWtIFS1*He6z*ZBk@y;F0_kGI{N37u%~S;GF|(iK}ibX;2c^;bh!&~(F@maN(~&l;TnTzuLbXoX4bqm_*f1YHO3O89RjOKVt(*kbbF} zb_$sKHn;hDmMB&!;$}rulfZ2tVE=+v2HCCdfu(TGe?Pp6WGA}w52VM|hqL?<>_o&} z3oss!8rGqBv(YE@SZrnAwN+PUz+5EwFdYJ?o9G&{=vXaZ&jaRFmmvfiibgAZu0~Y< zj4DJR`SLVIYu6HdPq?{BPEB=A`#YE2b-u0p4K~wbdypiQ)k3B%mDX|Y&c&V5o6L=S zeQF|W0vPw)EeYoJo4+kKYLyt{Tw__*8$CO)Y2>IJ^I3I?v%MjD$6Vyx^%i%a><#Sr zE*^!;VjqDsdbVO(b3B|vqa?%HO@)d74P3}gn6XJ9O_|#+Y>${IaD+HCFYd&3QOr@N zSLdwCPagxym}v&yZK48)h7s&50VOWh;rrde^hYt5tD@B9em#3vPRf+OcH#9H+W%j* z(e9Zt8>xsonJ)!1b=Y70=sv|ePi|Fi^rd~@>Um%B%?OCYmnw%b^0@56f3nxaYw9tf zg_TCp)pGZDZ4xUAW1Rm!3E7F$sRg|+&nk00+M;`yDr!59IsBk%d#5)H$Z zJQ3Dxt6Rz%#HX`9I(wp);o&>pNFNcOv+Muu?fS#qQ{yyXxrRdm7dJXpW-b4&Rj>Fe zWJd%|09T(KXx!FxMyOaCuUA;WUY;7^ESK$1&4&W1lC(9Mv%rmYL9dxI`0}q#E7r3A zPcP}V*(I4GTCVlNdxJ8A)0yaEqWrO0+J!-vcz=@;@X6nr_5OXULrTfBlygl@O^oh* z2v-GwWNv{v_OpD}?sU4OpE?+NCXiVd{F=u9WRDX81C+eMO4DH8RAE2pHANo z&OAUJnWz+fdI6?1lKZ;hPn+4A%;ND=(Jk^$KL7m~G~HX}E;uKjdq1hj)UN4bm4&c} z<3gPzIgi6#RbS#-wzuMzGA_XPRDU)kSz1{%(~!HY*{& zb6Y2<*l^|aXh*$b<0AYSJhY~>s17Or(PUux-NWt;XN^I1zt6ijjlzViKE7-MYhW1^ zjxQx_5Z6exQK>;OUTy<(G;FsxY-&dj%w}f@X}ly&InqgCNN3b5R+P;nD1g+pTY3D(5DPySEB6D>QLX=>P)JDkb(W803HO!0V ziK|A)N(js4thZzDFl>B+`UxIlhHyUm!ia~w(YRQ8Iu`jeGs6vN?xARY(HBhs=}_>Y zW{F*z>)#<6`90{+1-Y>4C_MSK?JN2IfU_14NTCh8$F|;Z34YHbVbcN(D&`ZH{WYMO)fV?67H4xPYk{g`=+ZlK%@N-2x0Ch zhHV+l>C$8%u{hT-txXPi8s3C{v9bl=1Il55_(t%iT7w3&;<)v)kG|DbCNrxv{COLMFb%ZX z_@6~`Ln|M?MCeV{GGQdis9{>&EUl#gT((Xp8&MgSIFdPxus{wkiVGk2ub=mRY?@M8>BVO9w^B*KSO|<{fy@ye0*UI`nhx-jL0)5wCJyL>7xi?L; z9L6kq13Fh_n{>~^#uecs+1YxZl7@F4LihD3 z59Oh7jI#pfxVK7OuBz5of?XIpgLL`3&B36mS25c>K=3J(*K8@#;c8_yo1jhMz7Geu zlbfnEl3hwXCZ7Y=j>`EnPtj9ma*Q5&Xq!I-{!|OQ0aC8nrBx$0K-dprz=SNK_T^IZ zFzX?}zKN8(RaOyFBmU0eCro3KFU#13=B0m~>G0}ZafnTXwcTgu0$eWWue|j?dSV`V zq;F-&8v@a8C}SUY!Om=I>p{RGIbh%2b5aIg z(Bqj@@_Mb_F!+}6%I{+i+`f6PvD z)oOv?sg%JdDA9d6YhCli2PogkbfiLy(q&nFoFTxq=X4cu#RS3NEPeJTd;goQ&=Z4T~k)AkfY4>ekvC)6);bvPz>U zcGhOK(b^@aOtX?}1s44Nt;@D|OHwJI#kG5~@3jtJ&fc7OFE_+Emm)1UdeyBHv-3Ep zUuE-CNThligI^qN3<4_fWnBfl$T9Ha{=#2or^71b%w_wvT5aLJxZ>+X_`^+j z9~EA!l)PSp{|SFwPLQ%YvaG9YJ8?A(jBfC_3`d(Z>JQ|Un5uhA;sp9XV+XAey-wj< ztOCR#vmQ-b6=-V3$M46uL~m|VJ?4+V>`B>&SSl-zJ@3xXl1&eKj$a6M3UfGwk=0Iu zh!Vl+sVc($ID5ssvjC*38+Q?Py-V zo7rGmm1_kGExtdf$Z@{#i6tn^N#63>LusucgS%Pjb>Hmh-QXvp=nv?-$%IAgAns4- zl}qujH*;fm(fzkRX0^FJLyc9w)^>wKG4CaUdaZ)5d*C49d-h^Lx~eu0lKOfeX7KQJ znoak5?24T;IZEw;H*zSp&$v9w$Z2i^f>urx7<}8`UqMa`#9Yl%u zpM&Qd954hfyNe}zAA$nZ6`k$qF#W$uy6J@7O(-J`Un6ujEedhA;97gQPL<4ivT$@+ zj9~z6DspH$D4>{s;P;DV&?K0m;+651w{-G@sO8buskf|z!M;OZSPBs*WY3D9zTAcN zIHAj~B6e14X#H}cx=~d5&5G>h_vE!M;e_5v%^*L6DuVs{>)z2Wtv_XSWe5V~eD}R2 zJ6dUB@GT2xs@2<_NJ#CX%Tww`4Dz4HWC^^h1~$mMpEDCNbH!raMFQCQ__b8H^DdX; zN;TAW+X}2?EBA)95x{f;(QSc(TliJ|H`Ihlq{8<2mWa=@cwg82U|i+*fd|!m0&{C2XYnRIJN{bZwQ6#Kc_)CO;%=WI3a}J&QLI8vUQt; zWiDlx=i`0sYS1#3VXY_)YhAeS%SkN=9>f*p%O9{e>rQW*6n&(P`4M$~4*St}nl3am zhtMc#7bv*hAd=(!!Vpzn)gxSSPSJF=Uj#^AO8Z;eTfZfoM4Z5cA1a9!C| zH$^QK)}w(?=*zm|u6j=%=E(n9JEOpBYxR{unar0M4Fg#Er>dc2_!o2{i6(KbVW<@Y zcJ`e0kyV$QON_xI9t*9E=cKJLV&hdDwYRd;_bgK@n=*!5{DjuS9WKa?o-BPo{dEjX zdtH4S9R)t<{CeL4Xs?kYA3OkSe9Kt)6)nN`j}%ntd;)uQE%!TZ#u$P}Vum)kgJtJ> z&&Xu^U#bI;LmM*aOHTTw$eAyN@HcNfautaJMq$&7&QPP3WH!Ia*)8Lx+hfqLJsF+p z+Z!`-9Y)E_7taPgxz!b4u%`29JAtt*Wl0yxjAQ&ou~^=354)vbCA)5u2n`@gmt)wM zl0Q>!zv8?2t8Pv^K1?MP92e45Sb`-b|48`b1l)oDF-X_@JjmhwUV&Z$U5UvNxhHoX z7hl=!oxAvyj&1 z6JZN656}F3FRs_h?!ZQ2-)Y<=4V(CUi>h4Ss`n*q|EQ{TcV<4rGSX><@cW*&dzW+* z${&T5TW550!e?ip^(G9UvH_r>gNZxkXuGPlpe)S6#YGeu-o*S>#I_$&JF`fejmW6l zmDWec@MZW)o`_cXfF&v??}6Z46VHua>=6jXLzXwweU@u=X+RdfL(h)BVW=mxEs7$N z6NZjR5Gx{GFnn%P8XWgL!hBs)fenyWo$3YoC?Z)RA$ozY<5M|;k0bi^adUW3AA-?* z^rjY^_sn1gq3&Tkd1lcN!h?Dktw-mfzE-hYwk^^V0E|Zr7Dhu@g%=mrWiJ?aznK@A zQiDkAgQ-zoQhbQ7#7jSedfZl8w-;hAQPcH*WFJ zhfaq_2~TgKFNHp1MaRJJv|d-3aXq}(D|*!es0h9#2aKa`E*fCWkfJCiE%}Wtyz0@f zTW-yhBWrdEOg+$#YJ(vZW4sCDwq?Rmb#l_t95<=YYi{V!w3|?S`i|DGLE)M9BuQ^Wd_Gj_{4ZEOkO2gdGB|w!}0bSCC@hd z9s`FJ-ZK+H_+AQWGy6;?fRcfCu)-wsslqM7i`bznlQC> zzgH2vCz||GDQ19HYexw(atR~fnP`&#_I{1WnN5s{8&CiHg?UOqqMlKLxWxMs$Y>c( zzNYVx1K*T0XwfbQbxy_J(5!QT(AHkh+qbAy5o{>DKFq3On8%mc1xqRJCrstP-6rry z>Fl7)H5td7)9>Rmd~I-0nqe7}h4{WcTS?Pg@7?=S#9&yF8QT0{i; zqRLPds_G>16EK;e%Q@8S&^>Q$zD+)N^H-9GA!KZRuaXSoJxscWK^9H60Rq znMbf@zIn=fL702?T63^tU;v@QCSK0_OWD7NnNKbo(ryJ!@XY1h{rcdDo|F1JTF3o? zPp_~p$2!<};bi}Nb6T|YiY!;{b`kJ`{cPjeI1JCL{axL!S80+p$gRNZ-}O-sml2{7 z(F99RV2T)p=r-XV%oa`gzUW+Xw*fs}R;5%1L61Q{ixO=2r1vzcFDkAWgU>I^y6#N} z&C?QF=aK6;_FvgBicnY&-k*fPL!IxggtFth7bz-`!=hOMs;Xi$L5`smgu($g4BcyV znL1)CdMk@9lh?x)wkM-h(hfisEm#N14b7@~`6W$xB;40C40ilCt1TaVFUA1@H5U1Z zeezGgS|4Jd-JSrWlXY$~1yk2-5^BnH8CviUAT&!U~J9{}BIiNW{HYpITh|3A<6{{}PqjC*8h*h932M|31)Ds;!!z zWTSUq>df4eTshNvqm7@r+S2D2T%YQ0ev;)LnBhjar4|b+=9cy0U5>-_46*0Ev6D54mc*9>+2gCz|ssSlnD=!X81km=4u zs{)-DKMNZ}v-4|bWbn6!Eu#<{HKT!}_o9PmtZB~_@h5x56rGNGY#m|_lSAq|5mbL{Nf3S-3$;^K2_3G6}W zKw?lhGaOZIC=nUrnSf=8f)v8Gr8@9j+Op}-MMd7P+F%7lg3i1-hU}iII%UKBBYP3I zr4|KrYXOu8Pi=zufuz*cwtP5Oikkn4#Pbe1Q3$%D$1D%r04E)|pTtktruC<0P9-IR z5Bqt~Hx9ne-3C;HYBay7%QvjL-w<3P=8d;;pOOFvO~>1N0zayZMTHE|D(;{>UyqiZ zM^3xl-!AX1zJtRGwyK%!0@1dOlJ-!ALy((0jr&ep?&;bRsh_BaJc6tGXBU25_WY?u zxaF2?D@jdAhmXWqx36(KL$6+vAGUj_zrIMg^oz7e9xe58O`UjZo{)K<9QS%G-Im{Q z3k1l^S8-GTvy|R%DFKt=pQa^fDJh%qpK*%Q<_a;=`TqRwpBFLVC6&j0+{4NSXB+VX zJBBRvOd9OWWJNNJ-g}c~5Fyt;L#rSv{ux6cMT!kvd|(Q+6)!2<3i6v%RTIv@)lj0w zYCZ%xmJWPwT{s0?fzv;)qMLzX6g7I*gi=%47(U*Q$USK?s#eEY8MLG22_}w0yy@YP zJz7m#dMZM1T&-7Q_fcC{@Ji2k-o}(s3Wwq(eN?M!`=DaHu1DW%s67*kNTtOog)1Ic zw|3H9Ix(QbeT3!mkLFO%rh_j}m(yN%oZw`WH{G@7jyE0&5m)x8gHhG&+~XJT-3p3z zsE6H{CNc&6?rK!ZY(LDs@9MU_VX(-dGFa5XIjt2LcbtvT6Nqga{-~K9X_db+lHR~G zP;-2)xK55J7et2kxuhI;UaRx=0D=_5U1fH+ZwfhIY7(}fC)*=$RT)o9!YW`wUsbH% zwgmQ_eXEtI?%@8;sIKCXk3f1{beaC=GL^GsS_!Ci%bFMs5m9%yKLdf@MB`t|g_iF; zvelAx6=?I1tO6jTHSGoG1e@I4&Fnl(|KftmObo$_?cvs=512{jNuIZ|16woH9rQ30 zR)1Y(mcO5i>UQnFke!0-{=B0N<{*wMKP*?e{J@#Omo#!^U+T%HRi3gSY>ac)puW(G z6-&=L^fV*Z$~}Y-`C{0gUE&TF?hjRu7#1tscQtGJz+z)vSu$0(RicFSBxt z1-5k%IJWx+!<%>kJ`H0{|(mo zS>n832tsH7Tnn2=tmNo%uK4Eq-%}Zh+0EE7yAxbm76IOt6>K*XOl;dnSYMT{L z$#>3T`JD3c9NB6a7s)JjiZI5{VQsd951@%VsJE!FPJI=PX#SRH&kX5Y@^rP>Y9iad~Rt*rOK8i6Q*>#e-#n0O1kf_ zZkPQ;wP=ccH?Un2*XEhHq(iS)k#FDs8oy(9-88ChM6v~lN%xX* z&YxWK8k-M`>tV$+Ch&9^&#L*yad5DhSpjKb^M|fjkjURB6BEVdkNiS^BX;DPX!cK~ z5m2B)u?1j2SXc~f2YwLDy0QI@YCS6_yVpvyW}=qJ%))y3>LhGs7F!8s=qw!whIlp$ zJEEzb5n=-t3#A2{!E2T82vp)lIr6sFJBvspU;M`-EQJN|f>85WCEc|I%>AR{Lt2|c zL1CPi3RUSZ_%$}|iU=~JH(`!DNWjG5?xa?tgargMEMQ9mE$@hy6E7G0?$4dI2G%^7 z+d9gEP8NVfWLUh#YU5jN%5deSFfxyLQ^0dSRU=8jSMK2QnaN2{^@8d*Ury8$arQg^ zQM2BagWG7n1EWC%FXf)?8Es)w#W(LnS{Wp>6mrD<3vqTtPa$-8E1!ko5Ic4JH&*%_ zB;dkv*Kj#8Lh{>pb~){+v5Xv{<~K9X*umJ1Sbq&}4-p@;4sV_9_|$g6rZZv7rc;{R zGT`Ibhdp*%($S%gIn_nA5`cp^LuK<2wJ8UWl{n~`d@wtPsyX9>%6){+_FY=jn!6kh zPEEdbVnY;uRtq_nHFmabJoG0sUD$=dWS?X?XJFP=c%CFW+ z&|+$kQPA2bRt$`0(F#nW_T3hkNgy&iQqbY7*+b*Q?1w{96fk3M7Q|Sgu?o~V2aPBd zsm)|+BtE}c2Px?rjT8J_{W|%lFPx}@r@-WB2fjh=*Rf}V7dbJA;ly&uC1M)J)!Z=W znL29pud5n@LKQeV#z@GHG!F~e50xSA%oq@%r!?%~@aKVK?*1Q@b8EiFeFmsYs^m8h zowhWh^@nHeK{n=&oYx@jVz z(k1BlubY0bL>)SGX2T-D$?-F5XrM zg>C_JZpD8*4nMSj$FZar5fnOef_3R%a22Z^Oqz$H7S+OccMdS^b3-%MAKSx!(JEH| zn%7U>&$Yv=k~8Tyk%7HZULrn_73uqTVH2`lq2as+N9qo8;C|f*71{z$QI{51oJb4R zWt4MI_e$&ffA(Tfso1b?bIdEFy+V#*d#&MKj< zTQOqDfi-!|HKD&J$oKY$%*`lSIoE(mt_QJpJqxtHP!j7>gDQs9UhHW~3;=XY}VIpmFP=*uiUjIobL$K3kU$@SCdKlpBfj-7TPx8{;7Uuuk_oO z#bk^)Z?=H1UQNN0f|9^qtV#!c)Lh)jO~IY#bN&$bTfbkhZmZFZ%6jaIKF#Kn4lAuc zLcdJO^PA(o2^HSTEQdi@p{Q}SM_4-Zuy7PfE^db((jstTUb_OyJmUSi zI(TUCqm{3!lb<`aA^6G*C7B_?25%Ad`Lq&+-F=vgJd&n%T8Z{#pzMRCp}*0Io{!MK z?*g?n&5KnunJ!!@`?=;^VLvPziOsipRz_KwmJOKAbVq^|$3?(m`w-mX)3Q~ZT=6PA z8fNk6_LY5eU2#OyY;OF0{5jsYfE3K(Szqqek2ZTEwGSdH4R_<-i|rq#SM>TNk!J2< zscinM+-VzjZwQ5tqid`7(eT-jFa2G&Gl3#)XUMKw)(>S4ZoA@#3T_cR(5G36qPF$d zy%e#GbT(G9BED#wiw=Xab z1mVE9mYl8~R88YC2-?fl3`QgHs=;V77%L}{xYpzmHUZ~)XOdgN#N{s8wg4yJA9Tp2 zAAD|c`a+(grTs%JE~eX4J#*JbqAUyG-+1=DSt`z(i-15;P*W0SYpJ$#cR+7_-nk}$ zxj6u~=+He()}@qL(zQa5?Pi%5SO4>-Caq^uNhU8s?BQ^DoX+0)RPri`2TWGu^vDsx z<-!63&Vp$cFs#!r!lag-_zme(_4&;A-l==;Wfw4q-t z;XCqWb!NH66*~J`Qxz}Nx+5y2FrgJThoIU3UU6+S(34K9!Ft%63#rRbzADy0qx+0D zn9I$onA@(+nmpq5<@s(&2XXM+6Q&cXi&xza<-GIkb$mZHOYS7`uX!Mt=D!6~qt6f! z-w*>*c@W**jjOBwn4j&QEICeKh&S>kEjU9F``W*>V@5JoZNkbnx9Zg71& z<}HEF+6Zg~IxvSuPz)^>Q)%(`k(VR&K^j8F$O^W--TTryMYNK9KRNjzF1`oPiJUoL z8Z9`l9NgB?I3f|=UHJm#fhd7n)&2AX*-nU4giMN%^QeR$nEIUke$~BFmdOUfGQ;pa z)iy8l{kQ7Ayc4zMjTsuN6&-q{17`Grztif+vIocNREYoSPyEs4B^gOfhdaxL?R{vO zgi~y*tvKqZyk?#=dqXKdxE?(a5*HgRGYjA+=wL+UklGThTmMP;D9q988`Q zSoo_-du{tkF~GuOCH}43kH=ocSx#>-%yB%^UNS2j$N5EmFZ9FYz*!R(p9w4d+Ckg` zQ3^LO0N+eUthMxey6c#$FVmPVv0OA@lyU>g1AOe>7#es;<7Sw#w&*}*pDn}Err9WWQO>n;Y+fdtXjSp8@e&{E0LXDy*~Z^pEMscV08 zl`1b*%C1ibv`L_DW)|R&gwBzC%oBYrS{ox>I1vd}zNPK?xJ!7TF?M*^sBg=l8uDQn zdi13MyglCg3hjLej4yAn@94uoGvY8XR2tzxfQ5g zUrawSI{n$v%lO=iX$FdillP9aj&AQ3G53D^OCWYpDt+z)^bPQzVwN=+ zK;%WfhUvAr3w}$ZZGJpZWj6XctO__>(M(Q0Os5};*io#nR++O%7V>oXJ{`HU;~YNE z0!Tfq0Wuep)hWkPV8ItB8IARDGKyX|fH*>8_l-8+#QR`8b}y6R^QBG!>mZ5mFnCS2 zh-df`9?&Y_VHa!kN8&CdCP&>nE71Bb%!BSFV-C+b;ETK%tY8fjA%0-y)2#{?8VI@!`Q zJX{HsT5%-N;&GsI9bCik50XNdRY~r`GR7Xww@A)sr`cPj+1&3D5`E;m>d2B!@J4O7 zOpR7S7Iel@U7v5?ZGM$qEZmBtg6Sa&!Sh<#ujxSPdZB#e`rieYaL2bD(V=0X?_D+^N))1`ie{%djo^iZlaj zSRCK8IUf21;uxdi$-FD3IFN>6Cp|VWOzg!6FE8wc{riolt$$Ln9Ea!fz!q5RHde<-KNCy_qAqO*&F-j4_AfWsHpfymw=K#jd3f z`G2$w3pWyD4el9dN1xM9e|%M^J&jXOp=V;!SKJz;-S6-07p25ZS=e7Q8VUC6 z(2(kPSF}cHjKclshbDL+sA8;c6h?U_JSgxv0&D2?cxJTZt54Q23* zzSJ4G*08H0d`YT{`xq9cxh;CmL!Bx(lUlR*r34i1G~suP3gf`+=bxY2SHu7ZI?+yw*5>Q$sCDeKDX7p>;jy!3Vjr`44_y6CJC`kYX-05 z&gysq&#s{Y+`Rh+dURV{o^d3JL1smx(C_ajk0bfiy5cWB zzBh)^;!Po#2oy~ovjC$j(s`k*Z8Y_?W?s%Z^FIt6&!Sy~q7LiBQkm1oj9fzWeRo2g zqmivA|7lRP1>Q?k%U0vTVKhI{3WJ`moJZ;u$H|_BwEf_EZ>;WTdo9aX*#TiW=1-xJ z<2Lw3-qHTWL@h9D{PE$?-Ay-Eg(}SVajzC2y-7b4rd+0R<~t&U_Bk~L4%#z8K2KR zI}r-3N&hf;rHLPSm1Ar=a#*x7(T=nzX;SOXJl8$cn3p=36C_WJG8# z;hS>vA$&L5z4$NAz6n3XFt!k&h^6Aj1dm6HZMe^S#{4T)%VN67|vhMKe+U8x3X7Qg2hF z2_OkO2>Kv~LJ~&p(Ca1y0#+0^5!);>bB#|lSE-Q(C5G^TREAM z)%M#1legopdrfcmWQQ2b$%y<;U=Qjy59KMnkqtq!jTmp}k)g&;7qZrFNbjL2u%Bz0 z0NYQ9k(nt)asPE^`K2;Xx%G`DBg?^8yB)1N@5yHE93sfc2M@JWDqr9oY7dhS^9f^G zI_7SN#oM|nr~&MN{L-PKR^h;@;)}$q$9eF5`S3MS=$=cON5dwLPlvhq4dj#UC@Yv@_3hMsM3$P^+GF-HY&{XF*M zrJKCYlSY%pIwL6I&&S~wn=}&#&;@Ym7m9}oKz_4cLLPC_fC(M6*!z-_7Z4# z-8?k4Cipjq6Z%#j|6Mhqr^04ElUFsyl%~I5BMc*(wb*=l{xYx&FC0o-;py*W45O;A zH7?T~9HTNxNiS@uAE0M`Lau0EUkNvJ;wK5A}atCAxttg8Yv!d6yW{*M%;Dn#< zHi|9~j-ACElrpzxS(F}RzbiNc1m7?TBhO+0dDiwUo`FD_D~}q`R&@wpuY*RxSj}-U z2xAkXq!GBTCJ_+JeVYyWde#*niVuYc*pb!PjaEIH74+=p0iATWhcT=@W1DOYsu8sz z=!|>zj6gzpoj5CQvl*o#f6(9Cl#`NyU1X}1Z|y$e*QGwYQJJxmU=l<)1}6muU9KN# zYnAE^>cL7B{nGKnq}{ZckE5KB#%3H3g1R(J4Q>AzaT(*;viMobKpjQqw>$+S=R8Djb>p0 z2hwO#`2L|u?}Wop5%1(lML)8ng{i*AWrK+af{&|kvn2D`iI#cK2CFIOH?XCjLL5qO zdT%2|a#re{ct%k%E7}^Zsgw`tj*a3Z$1BNEw}e0ZIkw*+rxn0u1%FrfU;hB(&wrL3 z#!4%ZZpXUiq__)}7=@f?UE+2gQQOs|=v$DlTMa>6@02Q^Z9%2G{S-UHr)#ss5gHXR z)R;qVD~-oB&xTK9IXMCN)(aTvbV*Pi)iG;(F9K$6nIE1j99o`@AJ6pqd8%`srQDQs zM$i3i!@U>bHSFlmr3ZIcxS3ScApixFCjn|b1eir_?r6aTnJB(})&kiLhQ(r-OK>cE zed$z#vwWEGX85@euZywkg{*E>f4M;hHxg@dePwq?T*c{<5QmmFDt*K|D^*+y6ofV^J?UXr$ z0!UrU_rEYev2z#I>|ga6Phz%)$U%Eaa{2||8yDLa>Ii>|Zw?O~v6!T-Wshy#LFsl| zps34we7Fg5Yh7w)F44F=(KJSValh}Lfv1i9GdjKTjAd{1mn0*Vt};+pcy*+Gm9QS)`9%!*NW+0Pe}Z{$X8q|?8u zgmOLO|1tFQWRHRS9;ak_*KpNtfaa<+{xCVV%c4G3v-EE0LBcD4==E`YpyS>;LVEGi z?nH({!8NYrmp%ettcc}xfdIRrw3t~?8AP{5Ewe>xCJGnxe1TO=XEBSJ`?HGFK}0BB z2(#BJ0JLxfLq>$90lvQ~eT-$T{qTCtp$2Wm?*mapDc$!EXF@|4{ijrH_JWfAF>L_z z%Gw4fdhrf8vH|S;vSJ31r z-TX)}*%$uUgjUWTX75*nT^<<=BK}k!Sopx zXE*0vDL%}P%Wt6LTJ{oL=aHF#(J>?XpTeRPLYLA7LR>Awe19fC25r_~AzXP8o4pa4 zOT2O!OTtVg3V;w?gox%g%Yj%R!2`IalZ5){*Mck!8qX^Vnt=4U-P0#htRGrR!mG-xJR6 zJjucHf9_r?Yi7;No1CABhaLM67^+Fy@g32Y@NGPV&x3({408uUaa=&sV%9KNe@CYN z6o#6PV(qXGV$fiEB5S99NIn&Ev$7TnV*Wo1py=VAkKKbwtTuPiTv5(`OFw^hD$lc4b&lo=P|KM&XvH|APo|LgoT&+L zg*ooce>g4ZeHRr8tBDj#mhILArsc@V`)6`5L2EUol6gNaU$Af?kk&pk@vA<|jvv#B zf09j%C*OP`k%9PB{n`O5Kr?KP)upjky>>a~k7^zKrJU)UHT;cP;P(;ZC%*7%f57yy;6v|~?ILT=nXEUG*S0chHOQ$B)DvwawZ_c~jzVcTGd;a_1 ztEGgEc#O!p_IG1(dE7UIO=Gr!{;eKs)Ua;@JFXDVXl4!L^USiHX~=*##*{qXibMDU zILrXVYUi%?L?jBI z%rcMqCw1zAMlFBO7HBe@xsGRa%)*&GNFWiu&jc}CAw*Iqa#@FY6Che+n6?-e$)ARy zvC=R!YCV*hgokvz019B_EI%B^H>G{P+VTy=w2}`$4~KM35O+h(m+idqklNLQ=X!qK zP*4T3e9D$0L9TaJ)`ye%zyGIf7N8>ltb}hs>`!M{92`eC-q{iANM$N5S1RJ+IkU-| zGV166&aJXR&?HV&O&1?a|I8dTJyu)!I%6oe^W1MdGfLq=X64qxZ>?;@XyVVWj${AQ z@uPcF!<9MRHTB7ki3a!6;0`Z`5rJJjEBQ<>3~hxoogqZ$s1!%dj3JI_LQ1v)U`;SA zcFfaP|J-CD%kU7*(IBc)eqd7D{E*W8j7*cy)dI6aXN7K(65`lJ*YcMD}C7vrO}~LZm!)i8e_cuc3Y8@0Xlwn9ZJ!$kER^6FQT_OdEO2pCddJ zxe4}KN{bqNTdrfTaLQABaz8gKzUAhn80Vq*^d%~IFGhui?w_k+2<~xeDV;GY_;A}? zp0bq96xkALY&o=mfbv}M~jhl{f|K;P9CXg@De>Q zAjkVtrLfMp<*vMwt>z8pzNi*gvNrCdgMaG=RPn0cdbPi}3oDiy>3GVw7xgXh7?uxmQU_kJG@T+Zp+-jf4`moyHPO;Du|`S$oBls^lb zP(|Nj0l%rYene^JnB=e1}nXUV(@!yiIQkh4Q;`E>J&$_JsN>zacO+G?YDP`WLf zt#yu*W{C@D$!XCv$ihI}ct|no$W9=uey2z=@eY%X95O%x-Ed|P6374%UJG|ZntL&} zlqS|zHc)v093i4N>`LdeGR>#GWXcYbgP%oQwEF)dP2UHkN5 z>9`~IbBnzC+;|ucTvzr#n$G>7>HmM@BB#hI36Uk|6glN=l~YP4yq%dtnPbjpwxk?# zDwJf*S-o4I*LCNQ(am@Zc6}?Bh)7x@ z9nqdY0Z~(hqDJoJ4gNDf7sHyuDPU?SXO7%v&cioBY$sdPRRm=yTjOLD^`@RM|+A z%8*UHW}z5t)X~IBvPVNmTlRblTtQSavx;x^;eVzlaNH>)0S5eE>^pd>#X1w#F z6#3WdX+vseC#2?>E@KVIn>zmRg9vt0C;Q0ix$$6OcUTpncbkQais--esH`SP0v)Ur%RTsyUpQY=iO9s|i2m*Pqal~{{BIe}H1QO$v9Y(mJy3>t5tc5=Ld z(xgt!Z>>$GY;WxtZ6Kxu%dNCH{nvv-C#beWAL{adKXJP5chg@6<5Lwan7P6wth)Qo zavm_xZ|e{OaqMfmXX4ma0Ms-wa-gnfPiY;gqj&Mu)7ftVf&=y@)QcMx`?=x1x3PT% zu=QsdOm6SXu$VYE2V z1vQIo|H6sp&fljiXY>1h@D5U_*vT6O0D8YMLx+T*kDsZWB(&cq7b|*S`R4QDsGsHD zi4aZp2Cq1#voBiUqeYh11+MCOEPJuW{vSubTQ55N`EQ&RhxqAh8!+dOJ7*7{eQT*_ zdF!CS!cQJ{?w3SaP%oT9`X?`X7iz?VV)X(R3#)c6nM-zT0Sj}eQ}2I&hC-_o2c<(F zPVbT5IJ1W(l+JN^^NlO2^(J=RhwJ@A0ulD`HqtiB(>#yAdsz#6eI2Pm8eMi8*ehe5 z13!PK`JYD3{Y(nFKACJnHvst2d^E4#dYj~5b7x{@w8bpBG0T754KjFA`Oo%76ol#8 zRMVjp;^3GCHV%NFT}7{6{D6Oux$yD>NINNM;Xu`$I*uiMuU$BbMB9@%3tkci3WVQA z_!qtV_F_+O(KZ1lBerOqTm`R?-B1KZ=BF?A5n_Q}n*)!_n{!Zj*AweEiVuD-nswPJZZ3^61G(JG{K^)4rd=CmneoG;&RA zURl3!uD41>E$39fy~gV0565a*~kLf$}Lwo=G?ZiR_Yaf!v{ns9IJ_n2vZm2Q{Y*VEjr2xJ=*oj9}0nd;-!$iIOF~rN}DAAi_KW zGxwr92L37Qx7408)P8mF=j#jici~jQflmeVhuMH`wl3yp@f7j>9u63}& z>CO_(C?)GDJe1kswV1=Nal8n+`%7igvh|MDvEZzf35u*xD@GtIau>)Xh8an;I68I1W4Id(h6rZ*mBqf!1u6Y>~w6YY)0x?h=-tE?^} z)F>xy=AM)dNg6ecz0lgL+F(R)o{)j1#>wz0jCquvn8HQ304szv_WuY^9H_ zRn8cRIWq`7-NI&EJ?#+xfLwz-az4(1*lloT7?SHY?C|PvQ$U&bbD`qbQ^@lb6n+nS zo(?%ZmOJ94UHAneQi({4_=T{MghB8%*nOWwhZ|?2Hb`$uww}zx) z5W&jXZ*AoV0YycvOzE7N!kKDrMOOv)3oIgpr$G@#?I!W&|>^bp=yj%rP1_&i4W`NA<&dR75785$OJ%oeex}V8&>C-uMFS#Ac32CZ8X@JBSpTX;M0_6PcJ5Bd|>|{F>2T! z=JE5BNL?ZNzj>aqxuz(DuR$1DQ%Xi>C`uRJUnK}>(@C$_zYVQ^MaBKOuTz2CR7A4U?bj`UXM!fW!q>Zrta?ON|U=e zL5Ngqzm?8}rpnZ4T)e+<-}MYcty<2o5*hrEsoEiN`qN4*TO#z-+UH!Cyoi^&;K0dn z?k*M%ndL?7{4H44rd|sgU;M`BR+T2}KXtM(&s=qAU3fnry%hIzi1hfoxlQ*zw%o!W z4*iN_bHDlZ!SaKN(5YVZ-;0;Xt~$DFU$J@StfvX-{f!>5-=|tH>O5XutVMBoFIpQ^ zq~t$fzk-@INbYB(U zJCOohUNspy3OV5C`@0Avg9sndpf}p^9R{W82Q(qZ)>YlVDBaO~l}1scBDo>zUVf%w z>r*>b&I)qp@8ind2imJMyp!u;7q87X1&yxG>1u`|C)!Q}acqxP;}>M}xd${`;CLI%FJuDptO#PoA|@N7*rf}+eQ(eIsx{;m`b9xR+m#AWJvcwT>3*UT z52%?Bzn*`G?4cm%-^lt&T%HVdZw!k1&txHn->8V?bn<*LucOge!1sPW_D$_G`fEWb z_?sV?uO0^>%e_fR_(s9&@ZfiQUblq)TA@IfrbR9ffw8$lr+BnJPX^GeR5Hzy+WB7J zEt9+Z-u4Y?Q?Fz**sjoWESEdX;FDp9Nz2MdDaB*Oi8k-p_ut#5AOWpEZx||jCRT`o z2ro0MaAOAKBrr&CjsF);nq9)A4tsk)NWl*9&(}ftfLTd|JJL5(7_~Q$ubLy`_roRz z;P|B&k}(F#?eaWt4tJNiQU()ZgtWgld{_(|FoYc_McP@=%cZc9pWdKSR zob9-lN@~+Pl&vS-$wywDy1O?T`Ndt~fgLFUgQX)yrU{~kN3Jvfk;&%k^+}*ybftBB z2;&caXOJPJJ9z_};J=trHNwcj$C2Wv_dMq3Ky{99p=RjNAR~DA3W%&NKUj)sR8U{n zY0s#fM4pP|Og6xGzxR=)yp7L;3j#C-0o>~y8!uEh20XI7vG9TEU5LsCuO;UUp}STU zi$~wx0LR*)8d-5B}JvhxhVxTsL&|7isns;>Dvh; zg;5tY5I6+j?)A-~{W}VM%$_Vjj)iTD-W*@`7>Gm^uIQT{n9ihcINXrW>08HB1pd+9 zWRKCkR#}+bp8A|5B1sS~*+X>mr}KWN(B6%}{P_G+|Jca@b^}p#*_E@s9FVr6sQ(ec zEX1$V39C%JdN1w?>syn{6H*PelLPI4C{Zp+#P~aIYkMeC415TEPLm%mS8zT?%|ow7 zhxKdJt%LTMMCUxd_Cg8r64}LXQ$NYr>Y4W`1)%Q&XBRrsGnBLdn$Szs z?Z^fCqgJ|6?TLQC%XW>Iu!CEmS+P|0+;+92(~p$p|77czu%tO%jU7#3XLOmb`Y{RJO#q%X%DQJ%e*{MqWxb5F7; zg5A22t!1lgtZHTL%Fmmj$|)f$1={BiLV#0*+X=9hqob9P_p#c%o_B^>`X0Q!KMKI% zccNr&<)~ngZ=RdkOs%ejHyr)L{DWEmX0EdS$RTVqqg^gvQsF|jg7=$ZC&EK_lHTi; zj`CVWPEanD!P;lgEiozAKMy{0wmELkv8HQqse^s~^1vl3q(^|90P_?Sz)S>Q1`F68 zC7tewpPVpF)ds!qx6}*dZ`lby1`rHgJX}X{pW9jzU?lX1Vs!l`zVwTabSZ?dZZAD( zz?^5lk&#X)WQ!4rh--jC%`yo*zI?zF(1&_J81aXuvYaVJnI`>HutEHhM0DG}*^iNwLO2?p-b@^!$*3l%Kb zVNA_NpD+eUu~c6tso-1Ql3O8sb}^k1iL29yNMLk4q@ne2eeN#hPxOiq-}ECD2L|df z`mj_yAY>7|Fs_n0hJDc;m+1LRuy7o7^$|nLDCGyexlYHz@|gSI03bS%Ie)NW%g}h}IXssvKT(%M9VYJr zjlSb@Twi=Lw3RjT_tzf>*;N!TJTaQ&wCWH$i3YoQi@~QO6&I#e;P}?+V2?8Qq_S+8 zuhA<)dtNQb@rf*h3hlXfXD2k{fEJ+q;v$pG!b>Qbr>pP>w)+Ss4b(W-T>;L!8 z8JD`*Z~fNXs0$>ve0lPps@HYlpLccmJ$gi|H)`(iO8la28UA~qR?7x+Mev)FlT*`1|^$l&$6eas-rl&5aWCthu0(J0|gzoRMvo$5s;^JGgP8O z^8RaYtF~Zh@(%`bxB+=_2;LrA0gR>do6;^w1)ZWRU82<2<7LjUW_#{lp;w8^Q0vbSgc7tZM_tn)&381$T+8c)j9(6;OXab85 zN&YKFP?3*|{(C-UOJeT+KB;FLMcUE73^|z8wKy zmy~U+l1s|OKrp;^l4#R3l#RVM=t~cQ-Kb;5YQDXJW-}mhf}&k)R}5hu0%!%sZ2YKS zoS_s7_0?aTTV1Tz-iA$ZCYQC80jf(`W-5zbS(aHvYs1|J*Gtu8xpAX_A};)d!-Z>v z%=mqhk-1~pT&6nd@$G1Lba(paA-5#`_#d)}zps)xzVC&0#Tzwz>l$sV;_UI&_Cw=~ z-(3~CxP(=#W>BLXB<*l#sb4-?xX!`u$f0fdBQ%x=*xT~MC()J1Fw{W*aW{v&Xg#32 zQuuzSs6s$O$tKTJffV3rX6MA5o6w)iulX|F3`LmTh+gauHUi+2i%kLC;$3y9Ioe|@ zCnc@1!>i}z-1FiF6k_hEN49u=ckipCZ$~7>W_cnEi~Gi_S{S?(rkJui1T6aJwXB)= zwlOv^G?5PW2r5Cm{XBVNympN>u))M+pcr09zK3aYqngAB#DitquO5kw~zH3y7J_&Gk}9lz-n(nz*M%j{CO8K}x+Y z=cNl5XC-|pQgA~9)Jft*r)P<5spP14C=5BDGd)5{wXSb0Ji)K1e9S!z+=MT7tDS~| zUzIjakb3oE!_WL{fO}Q9yOwu|;3lKc8>VV~0?dU!n&6Q|@MA%Z*7dE;o<%}5Fdxx$ zRJSqKn9E&DQhsv4Vol3h&Z)GKh^3@o?|4(Iucu;C@T_=$OP} zijkJ(#gfF3k9sWAMRM~LoUWcUMkZ9)^g+R_%i;V}&~Uln$`AmhgiC8?E;umFRb-v6 zpW-ZUd}K|N3fUic8p7YkNOPxzL%P}1(9PTGt8sal`}|^sk$0ybs-?}$CL zj>p2uNpn(L?O94z6X_Y(J~45}C`HM?sO!PfHv|Q@ZWUAj%B#VSfNro!zA0YFW%o}R z)mckNADX~>EMnwdm8Z9>796ekFG1f2HVi$D3FX|}EdnWZnUe9pBv;BalvrGD4R@>-x~TrBP;;)#gd8u{ z_&Yf&^u#&t=J!gX=vVRCv9WqzHc`KU=Z@*M;A1d36(m=(HVjf@`It7(BvS2gELO8= zfu^Tx!a?6*@k!;QC*)qFvrO#{#RyJeqtOu^?W9Y~zBB(N<@21&w;c^V+;QvIj}(Iq zg#P&w7m!$Xb%8j^Lp268}UgI?!BGQslSi* zRAW;xbh(ej>r}X1cEk-{nNaG40i%m)7FLJuWjm{$+YgeT6@1?Th+JzgaeP0YT!j+z za*l%u4g5(CKD*l<4yd{2zMq>|jYx4HUg&p%t_9PF{&26O+eG&mZj35Tjpt6{xc2I zC?gmH*3q;x(9xw@;=tNKZBw?7-HSa@U$K*4fc)EoJ%OATM}TJRm;mu#MwCG+ExBVS zSN03$tV(8#!LcUmas#Pg|KqHgTN1cs<>kpE4V+3r{I9RF)lhWkXMF3b!r(X9yvrj z$SP?635{UtN}@6BhJ{$%n6~QL{0nz1f$$bXbTWV+87?Km-2KXoLtae)xqh@ zYZ5M?W$J-~V#J)CGrGl_A_ohR$ zD;#fkRi2^M{seS`twD{T$pf)hfnV)NOA37&le$h!fJyz!}adNHvcKQ3ZzVGsP z`xYhCIvL&d6iqjW#!rJDk1P0Ue<6swD1uKHwHrn_l$Ce!4;(^Kjgm<+!i!G#F;a&O z=DDti!CVsy`_C$HQQk80m)^gO(-lpZLiqbJs&ePtYsEa}R!`x*EkG-tw;poJBqZWw zVSh#DW|HtOe@k`mg-P>J4YVxts;2-}X20b4GHz2~(Io^Aq#aR$wS@X{NQtR0toPpR z%93u+e*65c#KF^5rIo0#5_&mYqgS&Tkofqnb5B#-v`3MD97TYv06HoDG z-WsV6C{E(+`$ki{)01+xp$Xb-^B9Dmvn2_3x|XGyOA@RSEM<}dg0iWolYE(k(*k1! zLl?_+kN5{GnlNnK*Gc8d|7eB2B)AiHCKccBZ0jnVP%6De7A48!Pm{mdDTBCIUh9q- zs{KvPL0P_Rm~gZrchrWxq#8-zQ4!|5u-+T+JB$ZYAjN1NKKgfIp9#_Pu>kTFEq^68 zcjg|a!-Ng_*r!IEr%NlTX7;LQ7*`4Ozp*QjDjD|lk@xrdAb%^h(4Caezz?GZ(+a|X zzq34JrYSIFfDw9-PNd4WzOdJgyUmQy(9tU6yY>>8RM<~eFM3-Ziof3)-`2IlbqBZP zS+44QGUum7NJ*MU$Xq(_FLdz+FBhf-?pYR6KTjx&T5 zHub&DnD_06(tz|7~8{pZ7UZQ*Vts z^1Wm9Q((Ta;~lx2=nY$-zH~jifjzsyM<10xB^CPSu@ceJ>dl0(Kn$l~jM8tyJy{o8 z;JZ!6paYRT#WZ6j&j*VoW5^t2OF|jyhlaC@q|3n&Sr2scmF!Vq^3tY=lw5Q>NjB`P z;7pLu&I~z;Me|f(;)IlB`2FE4Izh9?a%%Y+T0V(=p2rs6yKjV<&rHkMC)BWK;%*;QH3d*( zaOS#T)SkHJ`b7qUKkYv4lu@7gGFwP+tW4nV1X*vm4;(6y16f?3yIi|IY_MmWlnP zvz5Tl{}ksM^v~iyB|WI;G+4OPPrAy14_91X z{wt|-oRcIG;vk%`dkQEx^GABt!R$9DQc@Hq=IfdI5%OSeM~~2R zZ4q7pmwhl>JLiJ5QL4r#i#q|k<7pwzxv(1^wQNmy`WNXgGPj_OH?DjnvA)FDGk@1T z@SS1DV*u1$QP+ZeHUco@Vm^PgOPL@mG9oVLd zL=jEUXN-kHR_Sq<`N-Msk{O(&Z}WULuQpPnMSZH;l#UzV49#9fuADVh)iVQHje-N8 zDuBf;7O}L)xB$wxN#(?tWY?9+@^6(^l2`ic6?CFJqiR!LXUVE(wRE`OXOk|qmykWkSKtEsf9F!6 zGHI=h;?&zU26{emC4>YoU*H&=1KbTgfI4LD&rU~L_@?hrW*NFf!TX!?_6kl?I3()W7Tu&(7Qig(JhWD$E9!s z)tGJYZvjR+34iYMzS&onBrBE=C6TbgyG`V3s5I`R%8}!LwGdMYTE=l0~A|G>YjFs=~3J==TI$NCS8x4wSw#L)>so;XkUtHcJtwLF;A7h|r z`zhhcPhQ&S5Am|4fJc${*h?ugkwYMy7!UbSby3Zv#qkTCayOSc!m>EdGPK!=4GMY> zpKR|;?d!kB_69ep+*I9qN0-FPiP_$Y$NmQolCXAte7YucWW(5%ctLiwNKdi=M`n4t zo^=Lp0cU0xtEa{ML=6=&0fs>EzI5faS+aMns=s`=q$>U5p=*c#Oc_8QIN_n={4a4n zeI?v#-OIGz=O=~z$j}C5I%<7RwEXw_K$}|E=u2_9!6?`Bky6nLX&jRjhusSH-Zh+# z(+=BsUDsYXd~KGr<9%f{Ja7S2a{bzQ0Y38X$>TSU>&2uwx`1=DA^43FxLuT2e}v{` zPPr0D!(RVJEW5Vf;ld6e!`hoOmq&JRvf?uE#hYf$n`z`M_(1e#ChK9TSwS^@$f9D! zA0#EGg+Oq`;P*%?FGHKV{m-QKq^;XpG{S@PzrT)uvN)ECf77od7-M*=0Vh)%G!%~P zv@0^(oafPP5wFc{W{|&`iawyQyuTBl#t5^0_~yz30V}qgejD9*F{H&anm~k%ltD-h zu&`9eve*30nMU7yOTu31rAHzzuJ}ayjJ6X}E%adRM*guVmnwFL2Rm=nnhD+n?oUvV zgl|Sot*Q2ei)ieZ7s*(2dd)v?g2^PpqOH(>@AQ#kBVjs zdK0L2KpOa|H16J@nUfc|TMPU1od%8bYI~8Z_W7%u>a-OyxfilO1~NB~oFn(514QvH zm`?bm#U*{SUccBkb!pw-K~6#c64~5p(((&>y18v0Cz*b8dGcT0t%TpW(hS@yL0sU# zuMnl0ndI2r;@QjfMA@&8i!dbH+mi*`7FA~(^kdJE16x34)Edu{p|!0LSC?=rv6l~m z2cR_~9GneIdh_fedQo4>DfhIseexvyth2W{3v`r4u9s1`iZ5hi9A_7yLmO;?q#W+e zZU0g>XRhj-u|j2lH}sN_d4Ea2DbhlZa9irB>Y&hNlWaB0CH2S)6v|Q|EQ}qbB4N*a z|1ex@2oG7>V(Y-sXL4DlYs} zZL&DYNAf_Dq`0GOIbTmjr~>JV!)A|>a{DxS$SVD;;2vz(A@?qW>OCf$%#!OiWxVir#jdv&iZB zI51f6VZwRorC&1c;GuDnB&k4HapFR9S=SUtHNAe;e3f5D?8*hV;&E&M^={`@_sOU4 zobm0;Zp~s$ zuMhDc-1hTndgt;b8A>=J`t}4fn~w8U>T;TFRe8vxtN$f#JiSDJYoTPT05;p9{Y7Z? zJd6^98ER2j=u-K?A*tL~nuH*+KU`Iv7w`|jd3P(az^~p77;C^Qls!4R5Py!~qO_B_ zX-?W5d1?&IX_EAd=V~E(O%?OiYYc3xv@sPMZVgZpL6JmC-TY6!c!irh%#Gd~b_u{- z$-AX!d*R+$t}T!fZs#n#SHIR+P;a?sUxrOz7@L{V=+4`(k32eK0ZnI@e;!c}*~F-R zi~RMHwOo-_5gZ`$)w1q)Z3MSLn!?o8tG&lcI7T&(mG`Q6ch{)(47IkL5oC8$jYT{m z=KbvEsO^B~+!T|Nlm3U`QKV9O`jF(!r0j>FjV8u3ejbqbGdhmWe%s5AwR-MD*u_Q@ z+I;FwY_M;$UOenb)ZJ%2bP!i(0MF3-bGPJ-%u;aX`SI6ypXks=l7dkNh@S&HwaNMf zCB~GaLx(@-Fb|oXfY97z4D43AI#8n>;mXdP!?-Y+5Yk{9b8VSU1oVuiM-uUgr(AFX zX{^N}Yi&qZglM>$k`O)L8WnmO@$_!yuzl^DnWweXD`lD-&#B$Du*6fQ*~tp4bHgqt zOo|*?SuOzlQ3G{!IIDqM()o5)FD-K1e6fo^0Qpw*m-*lD)Wx`02C1$RkYx6ueikIa zbl3GA|54Sfp@5Kg{2f#}h@|O@Z*@t;y$V&7yw#~EHkbn+?$%t{Le@1pDUg;}3kTRE z9nX)i0^4Ol1d-LGD%EgzmMo@=w`H>XdS39}ZCQ|#`rU4#8nFxVhM&me#>L<3LDYmyYPlzo* zzA=KQ8lzkPIbjM;RU2A1Uw?44^1QMpDJ5U;h*iNqL$- zo(x8X;2xX&Bz&7aBMZEQT&Xa0fvQXY+6H#urS(E=2o+lO#Xwn>fk@a|>(<)`)ve*d zJ1d{SkfHo^CDOkM4ou;Y8V@uQ_;JUkR_tuG=|XKFKRmcJljhRxPM!vxE7J(_`1_ls z=?`^#U1IrHUWb#uzWY`eFwI=2kyIzRd-(%e`*Mw^?x90mAaq1%gdL0&{faSc5`U3@7G7iEeY!8{l-`I`g*PPWxgiuNAqGU=V+XO z_<{>K2GD28iSeV|{Cmi*hnN|#LK{7jI8zrk%1t(Gv*-cvQLb>!-~<*m(s}{Yj2DqF z>3Gf*<=P8&QsKPD35K7{nR1{~{S^~3440cb|Gd>3pukM8CwG=Wi8vT#fbZIKyRi`= zSj-RZN2IRfpI6fkl*t{r$m5W@3R7Ew0Y>cB0`rjW!_W^R(OjCXi)^t8Fr?1w3@O2F z_OxcRczuY(@@RQNNqW7#-Rm-$spM`ccy~e?SXaRlYq+* zLDZ;JWV~awbxx}XGS0t*)_@WA8@C?6B*EwH>L61i{kypbO1^h+UWOPD;E1SMd-+;r z8{+(B`)6w^NnIbPNLqY_mfnrVW~^6scmIa1@qj}soTgB2+RQHUOG8x70%U5ye`v*v zt{{=ymvZNWDp=$@+jc-?dGmaPtsIQ!xI|oF zl>4xrbgRDW^0+3wP3gzL8RNv;vb!qw!_O11hde0*Xs88?$qqhRV{SDQCV1-GR|!c9 zb;-C)e>O9DB*^CL@cLX(xoo?xlcEn7|Hk4c8qq-#xUW*|edDK6xSDm;e4MJDstnq~ zdoMpq2f?>vg;rYQk#QS?EJfsGKysJhHfC<5B!#V-Yo)0KM< zMgtqB3SR+Ue+xZYJbC*1sc~NdD~cD*~$ZfeEZ4?dtrS z?wF#-C40;aL2?NvOvE~P;3bI+O0Tb9XSqX2iZ@)sH6%#dy8Z+e!1~Z7t^~2EgTybO zFl>vVRHjd2EG#{EV1t_UF#NNFnn3f-fFG$S-Qab`?`sC1m`09^vq;Q7YQ<9loYI^L zvsB_AZmcMA2>$e+9PQv=+F=WV`lZ-D7VFfHRwcTZyJQ#!wmHLH^p-2X6}oR@K$jg~ zsDA&=#<|V^L8Nn;|EHFL>ixy~!YYR z$FJ(H*(aL1_Z02tIrdpBf=L(;`rpC_{Le$rSGFJMryT0N;?sMm?jACCn3|HpqnG2# z2)sbHBuWv7R&Tgmke>bs&4q%Wm%BB812#y zoUyuS5@g3bh;X{5(M$8#Z_{1*-0>B)&|af@l3v*R1$DB{=s()<3GM=(&rYK8%{f~& z9KSD2U0wq$fP5S&tZ2`Y=(X5iaki|!+c$XE=Ec`i1(=^{m3+KpV|=3NLpr;6`yl2P z@%2B?VqWW!#|sq1T6&)`^7R~S1}&~&WUTC4Tend}rJFf=b%;x>xR_Q}w?gSY| zg`ouBom!x-2Ti5Us|G`O$P4%~Zij_(U{`Q#RQ282pv%)was`K@H*xhl(v#GH-YY;Q zE>B1txbc8_Rmg^&r?~pZ)sH3c(>ZA3dLW}A{$P0{=C-|Il{ddK8G}fk@pb8NQBjiM zS&DueSu9T6tgHVON)e%f_R@=Ci~>t%$(C6 z^0{5JnNt7q@YV=-ysWWoG~ue~?G~p}m`F6VZ5%tyE8?EC_Sng`I1I6u!8o@{w>>B<6pR zd3HHA8$2YihcTjJ3;D|E&A^3`k8o<+>vz8 zBv0F{?#}7Ts-ljOVI|GFHv9f{vB0#RH9Dg^zBg99N!o_jUaNx`P16q@zorGOv$r){ ze0Z>y@3q4^TOGAP(e}N*vFl}9sSbdr%!Pl>fBImcJ)jRki?AH|MG?+}Xm@I>CmoCy zPJb2(VH$?}+!;5`udG;%HQD^;FEszJ2t@yUDxnzMT9rOLkbnxMe*5^(pYT6tc4<*% z?@sy_c7^il5dmZQ>j#CsARaUb3H>=GCJ){$fzOU(CBA5 zzC7O^gWYqXJ}=q)u_5-E?)Sq%mC0#@+YLgYB%?C;eqOVji82dfqF=0YRq7hx-$r*> zq-N7Jj(0&E<%wO?vALSEH^ccMXMs7e@n8D=jbl(ft@7BPpyWPf#g#OCq@Z{9{yzH4 z%t`h_-4z6B4cRnQa3)ZG;8jq1UGQt5$dd%hyWH8A7cA{Bs`Fx39C5QJC7RVU;D2IB zlEUuGjw&{qaS?nku<-m|{9Iy7`i6S3msrr5QcQTcehQG{EO)NTr{{wwJB=h?AXWw>ZM_acJjA&E(1pYO_vf9A7ACFwcLsTa zvp7~fev(;t@Ib9YqMk+Q#0;Zi<&fv$hc&0S!xO=w7bR%_37nRWq%4P0yQMHWR3-&- zt5&FZ#j6LQ+7qhmBAc%vn6ZQkqpsQEPBP${Vo8(S$*BDjn*qAs*Hql3-_`URREHm_!PEA!4EP2`5ac8tN)Ls?Q5T?26i z>~N}saK8DJHY)-OAuBa}_cLhPO?vxP{gH36i}$z3$&Bs`B8{~$PWJi2^Cz(apF=9j zjiwpPW?$)rw(pvye-Q5sByJ-^hd%y#a~`lWQdu65R6{M5#9VtI(tX^|G^fKCI$^?@ zm$s9vIH?6<<~4wN*P;XlD#t}sNS97|;sw9Byxa(IfQhw_T#!?)7`2s?s!c|08Wr8} z39ZukTx3oYz8$n;viExK16vdH$4}0Kt--B<{Ieob6(RMT=l?z@L2ooJINbaen*N(fdTi9^84i z_@vQFm&~$8`|S1Zg|?11%d$QDtd#XE@o~f;SzE@oG2J(yQ>Q;nLnwaKlsx*)9XBZ$ zIQBC&KE>~rBu~U467^Be5^(8XasiI$h2JOTlB`7@5hjZOX}qD5@w;Abp@y4FG=sW}GM0Qd*7hqa6go0o4D}(pTFV0q=NSKE431YvXd)kc7B<$lyf9)#6 ze~MM7At4g-(jiXCkVe<%oDjFQbLQV8p6HxDhuE35pZ)(4Lm9QXLcBmRWI-<0C;u!qtWlRTiwB zVgWqlmJ^hOrpJXNbkff)z`fEzo7d;q(4-VdPNOr*dQt2=+2Pz-unT6P7sxR2WV> zyJNj0Ils~q8~Y$lk1 zmx|*?lD&vQt^+x+8{cQVdgN`|Y8Y14bU^E+psWHIlyUHO;Fk3nly+@EWk2|}ui+~oXaE=eSn zG+f$yU3CBV4qJw|>MHkED7oyvPm%$H5j*_-Pwm$!7o!|=^{;r+XK+PbnA^}M<|u5! z*7tre?)Ly0U}sqXWSFik6#8rF-@adRo`iaRySU6J_zT>v?DvW&<& zP2KrG_agi-wk=t=e9`moHXiPyOY#|??SJgdq-$UQ!ouy^An+7YJYyYf2a>>?A^YH7*qplPa2GQBk84R&0k-&c? z(B1U3+$6iWx?9v6GU9>{H4dwvL#s)AH@h0ryWAFNUo;RTJ==!7yFr7z2(sy;D~%1k40c({yr?TL007te@z z6Wl))ceV#)j7~cDp!53tocv)&Wj*7i<^GtWKGUEq=q|5bC{YGq>W)$oK0NRUIEIf& z`Hy_!dv*OTTrQjbmG|+>O7cb|{jEqUa$ugJzs6FrOn~4SayaOg@I0OaMC13LZ_KUj zj5rMkjW>eFg)^nvhfep7u&2s&?-Rc1g#3&*7PyVp!KrFTx^5>7Y6vD? zHNFSts4KO{>F~dgZWQ>hM{2sOs`?mV&sRsn{XR{eEwxrh(4H;3nqlKl_YbVgjP~oH zhdW0D09$`ss&O5~FqquXKlBXt*FDx>tsxDcpnS1-U`Ty(Lt4*+5U?K+9Peo^tmPB9 zM^QZS%%{n{jgW}Wm^?+qQ;oMm=NCd$tACV1wm!l!K2uhUdwYM0P31E{!>2#W=Ydp1 zrBl)dg9>~=CFfbjk31-ah~I{h!rSOlG6|&BS-c3Gm{qsnp*tw+O3ggl=2Lqf)Bpfm z{`>2K>jWK9)q#T9Tv-P(6PNDni^8V0Pgjt^s*H#KIl|_#Duq0e6~@v-e!H;l0uXkK zoUw35O~N{PUyNxxgE3&t1o|gdB7-W^JY`>NiBNhsS$AaVyNFr)2;%0j8ew}arEcDH zdaSy~@e3h0Tl|UjJ?aat&Kf`d(zlnlYV6=UL*(c24MHvqc~?=+us@Xfc|++n>o8xv z&#zhyd8r;{;UAUQn2F}-r?WhQ-bQEjs;XLarejv>w{ zi*+XFd{n+iSMZ=cByk1V2pf2i0nxqQI#E@RQ-W!|I@>|MZqSc(42r?bi>D zzmN3Y*0nol)JEHw=)k>BL&%(YdyAWr71)T3YdGx^^ZCGl@q4o94$m8(ZEG<(sjT}pW8QgjsE-1@vRl?o$EMlB^CTQJ)+R$!JU^TsMo)I_ndNP z&BS8xB$JSHI)=PER6i`2gRv6a-m91$=S_?$4n2?Y*npxQ59;a0jn306e>#{!(;mBD z;#$!-I_w;p=WpElYP~c}I61Fpxp`*Xc!jZ>Wm`&Mr#x9mhtM6MrM1*xE`azoc2 zrZBvXz^a^o;4y2cKTU5NnCadiD|&^XWwseA4qcb}KXo_KqNP3B_7`S@sYJXPjeS6X zLvu_gc%aV6%|0D{dico$P~6$PheYegQw8pJQ=*s_@{^PhviXy2f$10Udnk%%M%~`#%fl} z+~ya!^>jaQuC4Cs*%g=b7F-Un#-gcspTiE5!yD9*7o&q_1kTdeHoX`Y%UzR zH^3~=LRbV_G+@8g2oKFzD9UYsodj|X2l3vOeojs*J5X*BW%vQfvY@FeyC+eXKH7q!NukW@-lK|^#dJ0eW z&A!#2!OA-}l)HizNlo4Oz}-tgJfUxO#j)<>>dZo>RwHxB z?}l~+UvzuV-9c(7YZY~;(QBSDvAAgIg38;Gzer_cihdnjjuKlyv_`$@1>SeT?Djlj_@J^fqFl|s9QgCyZMNU=!(2=u+qmQ0I zE;w-#v_x8)-BG9gmJ#PV#q+~eU`OhdlfY47(t(oKbUJ2`_pVSTK@XnL!Jth zU`cQ*Q_cmi$X|u*d8Poc0^D}|xp7@Z7eqwO#ifJ4eK~%u?omwmnvXwrIC;0m(!Q^k^hAXxBl(L0Wv3al)3AbH zgeY#bgi!&u3?r;2XrF7~I`iims};Vw59yz{R*?0)DZVjDKa*TSKjSx6C%bC2xgO9T z+sFw3HwJaS#~SwPzwi2H9&Ay19;;&8$#Q6{r3_O_30gaNyKUQ~;rlgnwyHOjMr@kE zoi${Mh>as0E=TK45L>{#K@_a==u{-QaHQ-y0P849X?;?cpIgRzv#K7z5PUT*c+06wgTyQ$3+m>lX?a z9xk?5>GRe9jnt;&`)bw-T%kic(0CJYl1ayIy?AM=U#;YCOA%0oPh!9hdrCt&d#bWk zYU+GX;i0kO?csa}5VJn}Rh*SIyh3x+%!&^FTbig zuPs}pKfVd|JcCZ-PjA#B?>5`@GwdF)@a@c1B75_BV9Y;nl1}1Ay4$@9(n0oaBb_$pg7#5$(xQ7 z+Mjd|Rem(QM~w;W+0g!Dsbj0Iy?BxyiQ~MSz9cUmW~?NkhfLD6`8LDB&TYPcs&OCt zppH4En|^0Mn&#z7iM}B?Vtz?}>(q z>;a}8w%7EB4bSx^zuxF~q$%K{bcc!p5+V@nM&Sx%wPU1T1i{kZE%ur9B|craY9wBV zh$5m>51s#gJkSW!$gG&#$u(@^9AFOws;{ghHN$lN^C>5wg02>Xv;L0MmV6-IIJ&7d zJuvDJ0Sp7yUODF!w}hGRQcFQya;Nvy)`7b<4n2!dQhiaXQPt#VpQU97E==b6>{iQ8 zr00GtMp~T3WrmM`oI_G%kDWr=8bfURoS}%j1IsLJLMStRJI;D&2@s~E46pnO1$TMJ zOmo7PUglgmozHigrSDnzYzS#sC)^y?@lT%%wUEM)x%jHi%Ojw{2L(X#r-4U&>g2g% zB^@_LD(vEBVs~xe`<@A|KGjyY)vffP+13#p)s#Dp9%@F$7H&kHV7*v>g_}l+jT}0& z(hlE+Dd+~2^#XCnUd~b0ecG4jI2L-zDs(?<9b4^bHrAXIC&~$z*@{^8b0xq>itfo+ zcl#A^rC^5N)A;w=1dDI334Oa6J(&polUDnTxlI{`r!y4Dud->H~Wn+DF z-fE;#II08Jy@B+Y$PoDcjN;wTfWii=Y~|M}?N14PM$IDNsEXgKhUtCnoM_s>dnGh7 zyVPPwRK(p`614WMg>19Yo5U?wjOE?x=luQ*opo_@Q%f^5Y;0tFO=IL4s~@ZVc3s%H z@p{bF!LWTr*C9A;^U+>S)Z3~M2xG{$@nwn{>}j% z(9HF#RyX)9ufN8oI$k&))11lW{>qlE4BZ@2V!Qr_B;c88>qxMwEl&uBV9u1#87efsMgyp4z%%8n-zLrS*iZ-`p2 zzbP&iFl}z0P3CxXra#;nju^|jaNFjh%EGU;yB1lCXvnUt-DqK%uv7Ncit=k=P8Mb| z75Mz{nDNdAGH8ODX?OW-opMf9uowz4lRQ%|4BvjHXWq5!*s;lId(=Q&FG=ZcMv6rROX(9^_4IJ; z&F6aRYk2f4Z48-Bh&{ZUBN#n-yMmyYi^cujOQukEz~)=F#Eg{ZCm-Vb9-R*tm#=Nf z782l!0*Cma{)5k=%T8Bblsb>=-eoGhJ^Fa1LGPoN^q9xi8Ius+B-cTilZht?z@yPFtL!@x=2)k~8;M3P(bW!^qdaT9WQA zd>FA6ShyUZH<0^E=-+607t4gX0aWy5HE1T|N|~ugeGUbjY`0t)*>HH=fOi=l3hR(jbyf`@eUhW|W1T_fZ(kQm7cTjCoglEbgeL-{0EP=;Ztw%GITp z5sw{qkK~ZH58j;&eUjsu%B#v=ijIB7YXhGLdbKnzqhMQw(D{BcClNxNITWeY8$#E{|ujKy*H&Qxku zOx5k(W|GW8=55_BjZP)~7RAV3Y>C4xp39^7c$Mpkf0`47285am!4%Ufz0Az#qR{)> zI!Hk0go)m#!{WiQG)-jP>V;;lKxE#tX-e102Mx6XlG(XygV^*js*jIwS~y(9ub58L zv=x(CM$d#pJ>->K+WdzOhj9TBh#L|%-co+BK(ChnFD;_ONPcpu`D(ZeH>-=)^;w&&QVQ!PcWLvGX|z{^~57a4I;6-E<`k41kS>xCh1cJ~FX zsYq?$uGV`>p-jS=-12InYswWY7>l&%S~xIudalr(-Z%WY*W7j<_E6+F?FTpD(6pgp+^R< zck3!}`TWi`()A z`v_ejr3)e>s*bbcJQu!bbJ7OAQdiOMu5vS6Ab=FF-s##wtQO8~Q$z~8nYqD}q;+H- zu_-jX@>QW7a1HzA`SLIV8fva0ZSO~b^Z%V%Mh(Y{NNWOHVNjE$DtsD-O2XmBxxO3L`M9cT7k}Yp@`TPb*FoG?euAF5&C`3Lc z9m(A@D*d3JL#O{cxqqBDl2VZH>8I6y3t4-z0syhXoyl|Qv|CY`cm;VFv=_WFY5Rf9 zK@g|RRIpoiY!-M_KyYG?{)0B{vR!&76OXDE2 zU+#BjsetyZl*E&j78=MJ4XU1L!LvP zUh3^=a;=)o(``LFTmEU`>b?s^D0e^A@-O1);o52~sz#&evOWyO&4E;2ci)q&Ot#kq z(|VOI#I591@?3k><#%J3Rzl|3k$#Y0{A+?(Fb{D}5k*%aCcvki&xNKTJOJ3%y*UF! zsAmv94*hh>WD7X=`o?u;j`H|UEM(^{7%oz$0BbxFGJA1NFcGNz;$WkchlD{NTPTh- z(uSmp=?U24SV7bIy=4|vrz5~=zxLkQV~Um5QRqtPeDUKm#W}wZmZc7IUQTd7gw+~0 z_ae_0c9?dJzQ7v!KI9&*zq|9-z2%Jl`VF6vP6Xi~ENkec_ z^Q40Eo~9YC))|yT{vQ*hclVr(Z*N5U$YnmDV)wU%Zo2%ugpdoeocY;bC=o_(veBuekhtEPamRRiL{WUrD>e`VaL4!S2#y8S6Bx|R>V?VUh_`$47O(?u6R`sqw`B(5pIW; zZyLFFW*~$23QyK7ynn$vrUGuB4W%O;UR0*r{k*ruhQHJWMIQ`Q%)IjFL`mpbi6j?@ z-WFJ>k%Y`?Lu3s6N8)D1vQX1pc$}Ab@~GKU#^tsAK&sj(Y?o!Fq%ghnkrhhHML+qC z{W#tpOj^_}VzEi<0~dlDcbj8Hz1wTMQXBB$BZS2uI3Uf7u^+0?u|fWdtu5a{Zg=_X zgY+S<<%XklPS*qethh?g^agoY3m<#>FqlmhW+FOiHRPJh&hFYrwW36e+eA+ns zTNF*gc-m0oc4VwducV!WW4W#INplXm-8pq8l3N6G3${)Sw4sV1>lV2av z#m)K0QuE76$PbIP!7B$0<4ir+Ktaz*+MZ%$b-#*hKBgTVt;tugG(2llj^Z{=PibvadPnYE5|@ z_eBad2ip0SmRSZprUDKrhrl?P)t>qTGMidI>9||6o+{LUjc;GOd>{D}2-T1lX_9Q= z9PFd&r%m0xA^$Q9prhtaRwSTjVb$(4TRxwtODxB>4Z{#Rw!n&APbk*g``fD$)2^!g zX74bL_9}%1LoF=JCi^>43ZFm%u z1H9~Z1G~-`b0_7FBKyn#ndx-cS953?pLWOL^bXlmVJ%dOIFlfLOMT1?|H>y@ zZ$#>h+3Sh47S`?G9!j7KyHxN^JMaDiBI2FR@JqAib8c~zmap5Z{cXh@pi_}rqsxVu6UAisO(h~ zllaX#St)EFWoLZx@CFC^qfH;+JHqb2lgE@LuBhRUW{L53hM%>ep2UjP9@pw#(`n+` z>V8YLe!F+v_1lVyR)O(%kj(In!9`}pePy|h*ZiAHzj9u>2=S00337cCUtc*^ZS{P>_td-y;VYYB%~K6(oOTvp z|ACeW{Y2LZ%mz%_sr5NMTwcuAgUjCPF)c?x%QT94gK@jWY& zemM2Gl=I|j%4@1Mbvl<)K75X;GSkHU?@lZ`_s547E`2|1+p#%$NpuAxF-m%lu~0JN zdsy0i8cm>EK$m~iT{Lgn=e_-wtJle$K9}3MEehEbuhXgSKUET_zRCPx5q!f}uil;4 z;|%7^lN!+e^W~h$ee!SLQUp5R!lM;KPt~<^>IxO2J={g2z1khA?{@a3m@_BQ)rZ&f zGmviJcS|_lOZmufLo#G+7UljIJbk%=mv-fyEv#>pBDIz?_LEwS6{Q}k`m%rrXkOKw zcK-mwODngZk??4rV%1{dJJgp@Gq*Zo%0^bhcU6p-7=(|v=I%z9mw=1qqzuBUA>d4DV?DHyTqPi&QA zVr{HQGyRZG-qYgo=fB~P=oVlP5kq%Y`R6e<)`t|B0K(SiW|QrxlDn5gZpH5YT7-dL~ndzjD2+_{UP;o+?o zWs2jq3fJYAaOA|}4e)-q8}{mmXr|nIEaF$Qo1t{3b|8Ow)5q32@=Utr+_xmOwJn@J3&+Takhm&lXp_qt| zjIKZGp%-6=LR;#lji9a_IdvsMD?v!RUqWA|RSjILF{{+Fv+zUcOjmSrD5?j+1E6`2 zL+j8*`lAtfiEcnd9J{UYT-4mY0lkvY{AB!j3I0?uFGZRd66Wb8~rpGe19DC>tl3dUa<_f@SI9 zJcVzCStRI+#rzRF;#s6c!NCFdi9baPnl&iR`iG1fBfWe)z%ST-Uyy`+dLcF<_k}F3 z@VZJfg+-BVieo;(ZmvkHvFqH0Uoz;bwbNtO$x*kMk<}%ce%#_kKz>x|T*wi8#X3K){VEO3*Nz`3D_flO zk!BZ-N6QDqzM~GejYYzLoBKRiKR>CBSe)hb*&%J31}d5!Mn)CHhYI&R()jN)>(WcL z;(K#aw+z5nNNLL23P|?_{d`%?bG=Syk+>#|a(Q9|ZK4ADBCHHuT_EJaC*)kUUb|3%K3hZdeh=oj0)+uL7-@ zVmrn{%jJUlf!M{fBSN)i5221li4Z|Up7kcijt@T<2l)(R2jAOfDx>2&{&v1^wOy)J z4Vo}0PFmpMQ)4caAB<(g7yAt1_qLWbIcXPo^ zI$yLA;BpB|F$Y`~=7d`YY}+NNHk+MD|THbMGEE9}KRQZt=1`@GA@puBTFj?eMzBITqOBow&C^ zoJ2|#<_H&d6e`&wJe&TQga9JsRSjXl;mIc~2rW{VDQM3*`_}ydc2nnrW3_$Atrjjz zKIz(i7q=NxBPg;M!@ZdiNJ5$g>r7{JovAy&Oo0%HKO69%c1_- zx?;@V#@Qe5UmxH*e8EUa5iAAzy@VS45WN=~;~PiH^F8-0aOorWCY%RzPR3ZCu)TiT z2gaM{O_Q_~DS}*wS1{T3aDnd);bf)t>*cZ}l-+l*KzpO@BlvIJC_>U{O;JudAxH;K z{H5e>&S%w%sM8nG{;K}(Iwx62xY83JZLia7Eic80Ge4;oO6#eHjEdf$AG{jTr?r*?jBVEnFF&n*IGXr}Zm^2~+m$HZEOUex=-C zgx@L-jsVA$ByB)U4O3X(zs*sipu8&eTa#U}x%@zJOi1>RZP6=M6!Vb9vblgFDd@dH?gnQ1)W)eNjQS|<~i@o6F*Nvi8?lAh&t~gZx z%P{SHLF6>mQt?l_Aq{EvI@Z|${g>K5TH82>x`X>YnPH}{I@Z=K?0bH$soR37- zg)K^{X)KZxaY#x8*a|AA-yfZB#1NBbPloCGlQ|ZiITyiM`qIdC@7xU7J*Q@f*_>5A z2=n@Zp-T=FhGFg#5XGz6Lbv7po_!fg#x0PHJ#uCS!%##xs2 z@ru?1*f>36sRk4EzNV!3V*uo}Tmr~v=vJBLp6&!aW12cplvt!OAg-X9J&1D+Fr!WE zj7fK*0W{cQ@?tTG$yK2*SMvAj>;`!Rnf15KFDkwms)=9+Y{~-clE%L%>ixMI#k)D< zxoqkw2^r8!Y87#mF4i%i_z$wdT?&n`Vumc^wZlKlZ21Npm&4*}D$H!xJe?O(VRF{j-@Un# z3tl}REa<2-gF(bMcX3t8e5v!qemrm@1(~BJQ%=6WdVW|>YJ$$(N2bbIFEi+GW_H#! z!{ujB0w7;KojRW-oZY>SYJK}39w@gua&XE7&2KZA+?A)Ywbp^pn3&DG8C9crVPQpJ zKF&oysz7*s4U!?0FnMI}3tyBs*3UA{fE5{z#K+UaJOP!O^iLFk{U$GoH(pFi{tB%i zH}wgxG1-i?-QW^OJ{v?vXYtm@$FPX0Z-qa#4>(>8bh6{sD7+=E?LuOMPAZSDom$1> z?6bsjk@GMI?!loX(h2IZ0LcPxDWyD7L_!m%jzW7dpw#nonhyr6;4$iJj>gv4x8~d(dpK$ENP8aWNcdVI;4Y?}P@eVe$xQnM! zr}4`FV`eSG~W{Bq$Lqdf~m3ibKpc(MMHFX(KQ7|YVzwEL~2&Dps*myIU2{EJ^q zGGJ9Vu+V{h01NxG2wgj`VPL-GW-|XPgK$Eq_)`J0xFX0t|LBF5yLrYFl?=kA$JuJA z={m@3A#8!ox$;M?1AOPF4LOCRV#Tbu@X4JrORS8TJfUuJahuy>(0S z`K{Q}A#d@aPfSdk#K?=(;D<6Ghn&5Ki)qYLw&iGtfK=KJ1U=(z$&Pm0X5_?J#zBxR zsWMqig8$|MOcq$v{qlQuohB+9yY{i0X@~>y%5w%ZIv(uU?%)cG&aR1)9`Hn9*BG~9 ziJejN4IkW~o>P~AC%aoIGV#A|XS@9l-n!@>chyA;8Do26uiyUROCm?QUGlzuH9V@V zbR@Dx?m{rwJs6vVvQ$gC{M`A?<}lN+?Pjs5jcM-MirhPeuS=eyHXN=iFTUdBl&f$# zy3vm5dT0K##lMEqmzC8kNGZzhkvkf>I1$TED~c5N3YTM-uDhm zm$;lg`uL5?aywd@OS}Uf`_gx0h13^IvAg{DqiwzMUA`SD4>LHGd}vE^a}pv3m{l+}(fuk+zIgk? zhLL%+ujzlT=Kz_BXmxb;*?ARN+>gyeEiQzQtz&mOdNXPelGi9;!Ai@&S5DLgJc?uz zF;X>zJ_pHb)wcwGpl>7c+9;gYgl zq*Vjoc31#nowt7Qlh?eAhwfJ8{&r`Fg|Cb;l@{_-zn>1|*e^fTS?Ui3z8u2*!+?vS zF|!MElLs0y?~U_c^7%ZcCux+Sr-BPi=p5E+*{oGCK7^iQP&R9R!;$M)HQw*y*JI5C zT8`B(pS4A{Y}=)g;$CFebvDl6bqo5zx~;vHphZ6#t{eMg0UzKms3h%TwV4_}o|K9veQc&E*1#Ap zAy|u4%NqdMJ>o@s4gm~$+;F}ZPUbC>B2P1#qJ+SVaa#4~9BBbbOpFUvJ@I8F5Y7s! z5ki{azFVZ|tImsMx-9LEGMiirkq4Pyk=?lhw-5W4bt18NIhw~l7(#{?%mA$kr#)U| zG1R%$)MoxM!2i4*^bUJ%19RUjE@nzASjR@hQTi(?=<2aR_>57SVb0o>R*B#ad&s(G zlkXr_Hi)dgn*RO397Aqp)_}iu3vi2cxrLGLoi7I6wDt6^1kWU0>`O=y8mN<{ zN7venM}!n+tMq(MX-Tn9>X9l%jC`Ja$3O$w_nGeerYnB2l7=;NLh;Eh2FGc|hOui~ z&uEZKh3^&mPnm7;Dz1zT3_KiO`z$06!^u9UpyyT3qPDYAzMOm-ukBcr=9(?cahmgH zvXqeXbE{GV)idP9?I(IGPZPB2$=((v7){-Xo^^bsn_b)PW)=9w?5V$A7)LQ}Ms7Ok z1V2H^MMDewSm5@?wAX`uUZSj0zLs2pCoXxrg@NC$mMG(Ih*dh>=viwoCBy5L+2qq# zZI!~5Ls+@Z_q24#YZt*ZzVk#_f#a%5?TB@B%X4jlHg3c(lEHvAU9m(3fT@5&yYmOv zcn!@3b={{BvqQ0cyX|a69Se=_9b=%8V@R-hS%(p%c0>112Aw>Xx7U64`3`9D3Ia5D zm_n1M>My%0SpNyA{56teok{D*AC_2dacHrI*kilo3hkC`uNg;PdKp{xjM*-t(h-7h zYse<8W9YRZoYbg>Jtw=mGpZfmEC`ZFu-Q>N42~KyY}~lFNgw7{&)G3DCfDgJQb^Im z4Hbj+JK#;eKseW7#Fo2vK`14j3$5Q)S?jSeF>gr(;$_x;v(b*Vj$qx>32#ogQ?S`f z1sTTT94HAWQ|)EF)58SlEPV~swxukrJb+fJ z`B=X@8267(1QWnM6-raM6W?sh96*8Y<}zE_xJiQ2XhwJbcJv^s<1!%|nmkG&)# zS!mL5fx{EPw>l}TUxmKnQBrr3!}rdi9qF7@J9y*$=4}?>!J}BLq{@SJzP$^(Z!`v; zq%gV+ANEvxbu%#{9q)I3A?a}g2|9YME0<5j!Zv2Jy~_?Da4V2SRUmUI1Q z`wJ9f)$hv(F;bL5Y4~2Qd~Rpm~mYt;gh^_{MGGG&vJ+ z(SU%)5tbK}HJ;wv4NKVX-2P#$&2&jEiXr>z%HlHJVsJgab(TJKI^F`UNbC%M-R!|O zfJ}Wz^EutM=p2KJxnpP3-4_X<(=^wv%PCop-^oB;I%{4@P2)8w9T#Yqp2 zK0WyLZ3Dr`bU2-|wz`{>G;~N0iouO+`1)CO_BwN~Wt^akAM`ir#?sWq=efdM*xOib zkom=R)j3cyFH83Ebh43pocZ>_n?58oc>XJ^*){yLWGU<%O;W!@|8u+i!btf(%%d&f znHOEngI#Uy%!5r^J{@_U)^)9R!8y-(Ld#}q!}{<|4`AELTarEA@tqDWv?t4dy%LP$ zn`wuBnbd$Pq;&9M1;DeKHyw>}vdb{04fS{pvMgi1jltC*mq;LqWrzPxvEow1H~1_) z*gn5cU$s?fbl^<~!1rjYr_?seYD29?F*vXuX-=PW zMs7n(2mh4PR@0D&w)8K;Wk@{V7o}e!9{hLXt6Jr$fTQY{yNn;z!B~-BThRg&ELFF( z^czalQU1~;$qY9dW&dy}L(WlB+K5EKL&W)8KOJp5IBduNFO2E>=c=90ZRA##`^7Bp zFdOvauGlAY{I(xamSN)(ZRj@@xly=~o{#flI<=yH{-qLD6g8}~X8lP;X9D97ZyA=` z(}J|%H%{L3A_SS89CraGnDoL$+jG(w=Uilt5h>#8bi&vh7Z!^4XNM^d;G_(taC7X6 zn4fo3z;)%6Or|!?j(tm(+1p1}Z)qfmDal=WEp9y4r?U*~!Y=n7=()-{F2X>a}9q_X30e{+Y z6@!Y9rMMvO5uE76l*d*wyfr-s{-plGXe6isNOLo19b5$a*Z%OGik^wBeOE*{))p~% zWAxikS3}73%0E8gi=Wp9a6*&lW_R+GxI-IpmaW@<^M_H?C+kcf z^YFbc4f5{?46Ev9(lHrjDiMlAsi5rKH_8jM&6-OdBq3g2gCenBqYIi>kdBqRxRb%@ zT%`jjP4Yu?T5|`mjVCTZzd5f*D^v!Zz^ZhjFJCDZ=AEP*J5)~v6CjVBe?wO=2po(E$*-1$sW%6 zTBNhY&?(1%ei;f{^E27?)TbXU!5m>-`2nf+Q1c`Sxzl@%y#hHr?RViA@p^PN`yI@a zw6eKyHfeI%tEo~H1uY$grFmMyBd(})l`AxwfVP@ohpS5TG{#Rb1mo$Q$jPQZ9naFH z;0#_4m4#ex^Cix2ol+^^-u|bduG396oejiQZgWwZ`*qqU;MD3Mk^8#Ml{ofyWJiQI z(Aavcq7S7dFp!}I`$lZ_X1ay^6g&s7jg~LZxLt3I+2K>v>>chpH5VoMB+b8CdFj3m z1hgaf(Z^z7K4A|j``hk7|57s_r$8!Fr2mRMD9tUXi^%|1qtjUqREjCk9e~^0nUY~R zOVSij(W7{O$}d{rIOIHf7tbTNYNf&sT*FKMCv*cZ zwo4Ay2Cphr_Q0S=`zo-p*@vZrWWhxNUkG(j1l8~g=p7XOk#@2pL!ZamTae;#(qbu@hi)eV@KJy-hUf(JI^1fh z9D#)&!$XAFV>tRryK)j%?O}@ZQm=LCzmorqG%X}5NdS0}Ek36NI>wpwBoT3w0OPX1n$4m3g?@iqB`*6 zTXRkP!1+&Me){px+p)N=pR9mrdy?_y*UP!Wz(c>^<~_4`{qg=Oqbco-nb$k0cc2o@H% zh-nYY`jv*1{9CB;j4TE8kwyD#tg8@5+|KQcxL5?IzZy+F@}K184l<#YQMyrN5K*9$ z3MIx!kfE}8%sv+K2AdSEULi)+M```1hS*`lWW-#OV?DMvi2? zecL+R5On9JdX3|sk(&J8goSsOOnjvm;BB3fxYw{q4$9aF|S1`!(?N?XXV<7kxKR0%dd`b z`i=Dwp~o#u2(e{9&DAc@4VYj(CE6H_&Z4xvYfFa4#gz4*?nOs0*Xp)*|E)K21jE)+ z16<74y%|rZzx+P&IqK-bBXB!cH7FbGZb@(cGiym}IBS~~h;FPtS_-`)kSF5*#nFl|0ITwcSXtVu#Wl>jmGBkAW1=zb3M*D4Ua~9+cjN2I)P`(9Iut1aKK1yVwr% zt+^XJH7eLa&zsu4+BnE;>0=_^PYmn(E7SOU{*9mo$W0r=yXO@Sn&#vH+W)>6)sCg1 z*h19d<#x@wja}@jgLwroo)Z7C&-(M5A_VOjx5@f0(?hk4oTiOUaZmOt z>PvkmAN4Yp;(d1z`@ZHr7YX!8w6ZzEJKLLIvA8wm2&sP!$2ek!=grHV^vtwl@7=E% zsXOCQ!wNmT+M0N+(L^^#fOFdA$&pm0tb)cLl>S2we5O6Hb+PdG!I;m@?=w2iN zX~+DnE7nX31q zCIbQUq{yOwg4FR&M!}liypd>&_My!0KM4Sl^a6Unf~Ie+WmM>G?j|&ckc` zR*Itqf&%qjMm<9fRlx7FlZVwEMwf>Ke-r7i&i;t)aWq$UTC;-EEn+&s)Q*rGVnmj} zh*74!f^Yv+U4Jt$NPg1p_a%-UyQ+5(rxtf|QD+Hsp2>_gKrabhRkh#VlQQ%ffjxzn zOdfdYDmeu$x$Xm_$UHqKgKRwTfi0<9Gl>8vo3$kR8o8+ls`r2Cw;8Ru@8sPp;}G^}PU>j8FbA z@zbRC{(yle?dWgMvMEYVTeoM>v!%!f<+&T%+iC5|Xj&@fLbw=K6?tmkL#JkHv3r>d ze>~vKibbkbnlW1J9nt)-G#Hm|iznp};xK&_X=6pa&Nkx{d$hO#kkvh9p6~cJ=)->9 zU1Tv*_Un_rFI8%bhi$Ka`xY8EcU&1Gs5AruF6?36e6w{`k{ zoBJw7{{(BI>oH>82g=x$E4jxXqO@T9Vv=0Dt2 zU|wIb5K)B(8kS*c-*_3RWM>R`?Dsedj<;q;%Gn6gJpyr4`+NVZdR z#9w21qG`Y%rs@)|fzL5HrH!=K3TaA}dwtw|Deh74Ttq5k3qgK2{6M~9ZuMGRKJ_-| z_=nlHavbHrFOJq!ImN&cIp1@9jlBOKS-au^tuYqr>m&Pk){=JUoehws^-10B+Yn2m z=T|#%Z{&i-1hcURVC3ujtW#$jg$`^JO4uvKN5w_@U(*K>(Zz{lc$kH)mmms$EAIYS zuBG_dOTv8BRmY-&Qt9mKv>kR|e)7k9wotq0E)T#8xf0*+e+tp#Tv8~T#4i;my1h5I zHB-loGtfq(-5hfCtuKK(CNNW8Kkd6bxzoFQHYMQ)eo`Py8L%KRVhh-KK7w&JxzN(2(5SK;>HNtW!Ug}E0u z!%i>g1xJVy+ifkU8e+fVdLxVBUqu(_%KI+gAJ3oF`SQ!5Gk3a0>IWQ69%7)qC#b`H zl%aCCr%WGE;usG43;^LyIirQM1Q+%g(->gmTOyE(-_~ALIkJnxogK8iE2#zbU+mCL z&lrPNIH1hG8Zmq4!zE~p&bNnrK6d4L>0iTV6VLQu$9Gy3ynK7ri+4u8`v$HSjsuUsovT>)u`=r2ipuN?R`*>KnxqOLVBoo= z#UH%ex)ryiyoV7-&@&9c(0*BR#5n zT-RS-N1XCZumo?yNGdA0Hdpbm-o_nNXW`*zko%f*dh^!fwdBF4Q9QQ#Q)gY`NzyQ9 zOtVbV3mpkctt=3dC1KnhB)R+#gd|eh?7ua0B@#n}&q>$jW#{R3LVv=p2zD6KCRTJ# zn>xl)>WsMozEjHo>%ydQ=7Og2BhpA2^-DzsUHn+Qz2af8=*A*r4QXQS+H-SpAK!aH zOTi@7(S6QWoL~rrqU90y7G_k)&n+C^`VH`2H*u(uR>D=qL^UsduI9N(M zp=X4d^Pz)jt$+2C$lTt;x%1~Nm14BJzm-o+*~>r_Y1D%w;+<1F{;8ZpFdb2qtm9wD zc{?0|9gigNFlI~As)(rd?k1fceZnTe>>nZ|6I0Y=?PI9oPV*7^PJd*L*>_|9-OB@x zb$=no%=|HT{2#AE#aiHskAFI<_{+a@6zjiQ5gH#Z7&ZlsGkEGT5-wQ~~$NzLCWqu{WaX-7GkAHD8KO~xiYYshgy*W?He3!zx^Gg7{e(wA^ zD}DQ`>N8?Sn&$RosAYblobBJ0Tf--7{&TO7|JK3nLzCSNKF!CE0C)!ISfZ}_d_AP! z;~zaG!Lk!124e;9#JPXEq?W3?8e_hr&4l3X9en+hXDF@AlkW`Zsum z%thKUhtK~KAolnK;5bL;Qi7!8o2SYdb#{r;z~=+(84Sx#5Nhn5n7|T^n{ZaKkAE2e zKr#4x#U?(F)KB6)+M5#dFRj_l%z&x05!Zivj!ruBg9g_Hz()ayzjURj8xa1N9g&o$ zsQo6=i*xTE{w+uU;9KyP*jU2(M|}deTn@gMSGaZjk1u!`h8%rP?PJdd5kW}7_m|A% zdos8JKlBf>_Ydm$+%Lb9_@|eGl%x5l|F!e{@E*Eidv;b4V*2pi+xLHTAh&L!g)8`ll9pD)n*vLsX6xKa1lv zs#NP?{?#mZL@mqx(F)Xa+7t=rGJEFF_vgLiojh}s|0!G+xhpBBo+|I_y3&_Y@(vNEd z4qx!k`{!Iq^DWq9Sh_<+QE|2Y7H=?nHNO7UWd5^9?6?2sEvN_n-}y;- zegqTSKQWwMv}4X%)cGvOZjN)zms9Sd@xWsF_Lbgu<#T7k(UOI&8y|uASK*!CzYpf* ze$Ce-`F290ntA`A+Q$JgAe*y4^OYi-XZ`!M?6;?(MR%0yrjq&VU%kHbOY<{-4z`6R zWu@?StX|5ek9B*2b}GWYAEwoz#(q22v>80B1KR$GS^Ese! z<~MIi94Q(8>fHlyz2Wtr;Tb6W`9}-?-eE=m@^jBS2f_TwXMVG+<2t6h2+{Xy+8m{r zA@%GBW_~!C_5Ak$kVNI#eK~6wQiR_4H_bA*dN23LDwsROFyyw={IR3MTAHA-1KYEJ zK-!OIKnHl@SZe+$?-xR=n&MW{Y$$HS2)Ch6k+f*$|w5rOa% z`&nO(U948b=+^b{YaqV}{=^c{KcdI(oii>RWdPY~t zJAZWf^4jLvjjkBrG#b-nsi%bW4#pc}~c67n%XKOJE;af?k?5kJUG<&W_;=V5W z+d#*s*8poMfOF&>tAKy;KK|2Qxe*adjHl)8Pgwg`-tATVZPjV9u;hf1QKFR}6So6#Oow4_`HQls^3CsXGrA&)V@jt?d}0einSIwnt-nFYjFx zcGiclbLwQ#x;;-UKx$>b?afJV{7;L$|4V=&@pRQYWi3yvougBFwB*ZLqwQ}I-1y{k zyGoKjGRfOnF61XJgg)Dw)4e`U5lorqfX5ght>x1;IXw&GrFd|K5$%0rkDlXGXmDusqE+ z{u-k<{oRxM8cS6F_2^n--Zy8rSFqg}J-oY~_fIc=Qb?6&?bnA8E*a@gc`Z2~|E@64 z_4n~lC&2pi=6`B``eVeD@b+E#@MAvDjMce&k29zpZIfC>j=YFQ}>2KmH z%+Gx7)4c-!Fn>Dd`Mf`Tz(4*If}1|Ed3tykKK|Pu|1NN*5}%&0oRpI{>fpJj4p0x= zKR;)FG)@h6|Nb(q{)OKH%IEM^IQ-%+y0|6i4WBN#7|XicJc&CAZr=G*v;WOyyk*+` zi{CxB_@h5$JlVn7heh?{bN&VY=AZt(H=LtmT;$su_r3q+p*J=!a(*7ULc#{+inHWC z>p32u%9-mOUodj$_}2q-{>T6Pzt= z-sI}qM|s;j2?q(VZ_zo5tcU+%z5;>%!}H^RZsyM|)PFta|G~}O#b680JAVaEP8g>i z|J26w%wU?s$5y3w{L}yZ8H7WvUq+i&uk-$rx<1V7Ec0ee#gx0U^Dn?jHa15Oyt7u< zj)H#?JXZ?I3HR6h=c28oEBo#LXc~g%M*3~wj&=Rt97NOObMG%Hi^j1M^QzsRz$~ZG zxZmxg%S_bXUMH7i`urC7c%`S-n+uQJ?|Y)*#Y($}U{SiWSZD1iv6MV_ZU0J~`IWFF z@7GZ6$*m9j{8_J$H#hz5FV5Z5Qt8{}mw)@PwJ%+#&%J!+N3V5ueR!Q6Du>tqZ6D3f zYJJ7W|AYI^e+$8;*W&k1sBFFd*W;6u$9)Zte@_#IFLqN{29ByXLQPa;@eb+zypZQ0Jou21= zzk6^YcVf`NDG03_zaRBl%(*K=3wG`69~^&v*gCK0{$BxzT$_%}Vp`VN#7?2e;ay6Q zE5$o8Q25K=K)I7l595?uzWZr4_;%VaHmFGFc%-T(@Dx%DgmFaHlPZSeo)^|C(ik6)Io10L+x z{KL9^`^A4HD&=i*9F}jd%&%nr7%=$Foy-xaO#H9qM>EOIfBr4Hq96Yj+xO?Ws&!NQ z&-YxSmw&xQhF|EroYN&a$H9s7`jszQ`8haz=gu~|L`xf=AlXC!;cAc#Q*B-cmBt}n^~Y=?+&9{3z}7klQEaE@ysK=f8k=E_0hl+YPkg@!nI?u2b8+fFI$F z1OyWttoT0PAN)gQogiWV1%TFFXDUympA~7e1GiwOWtcM(4bqo~?Xx=UlCmn+@eDk!N>%1=@MhUTi;V zAEv?BMOsByQlFe|;Jm-#{GN^KtQ5GZ?5PCYmiD^G zy=P`CTEIKMauB6X?%aoSD?R-MrR1{qjZZ)GaXA1XJ`JLSL2+Jva`Px;X9Sp)ITM?& zkX-V)uVIdV!%>Ts@j8C(scvhTJ6PuLx&ESQm)Ad3&$#*53qR?JHV3@9Zy>aBE;HBv zdVzI4s6ENO3#r`z-|goJ`{VnA4nGyZM}F_-i2D67(kk%wK2MJRz7>S@L7ON2!P`>Mv^@TW`TqbGm@tQs Sr?STY0000s)iq_5FUI=YH#7N@4eKZspF}2W@8Q_uYp8ClI!J?yM$Zque z{f|fZ$8Qzxv)S!i8CZOEDB)=c-?HW^iT}h6*FVMxZ&>{4eqwAdT62WnPSa>=S51@I9E+8JqAnJfBOopaiUPXr zf*>*QV1XcZ2+{^`z{>xA>K~Fv0iM5vXoI)^k^5idf9dq^#{M3se_$3YWLt5c)jOY8 zg&x=1y&rR@7q}j-zbBDu{kCCwQX=jUMpxI;39V(2e=z;RvHVoa{PN|k8|HC0%##gM zbycPGrKI##i*<_^H>xqr$iDKk&^^5(6AkHY+GuWd?2zU0;O!&Vq;9GNYOX(< z!sg?bR1`*R#pcPHEza)Zg0$<#Z`7`~WfrwMcd9GZ>94zPx%h0(+^wg?_iDv|A2r#J zV1Wu80|G5hTPx$=;x<}6nPVEbmu^=aw%b$4IkG9O>&;gCbs)&SS;%ezFU0S}>xdt= z>8(G}xUjUmP{9#w_ZtEd+uz!o7^lZGLCD`rcYCeQ>m0VJw(uda8ei9Z=w!^rpAAq{ z`-?6j9#7X2ZZq9Jds20!L?!4O!ssmEtY?vTH2HCe|6pW0-@=r^NQwovQ6Xy|2mfsd z`WPST#z=A&UNc=F84w9~FpEU~TVOc#Fh?3?KigZaI@2b_;p(nwg*(TN5;@hg%#I zA`jYn+OY#$j8>lK@@5=aEVijy%~Giet-2!yLFMkvtFo|)X1@>ckhxLo;DBkdCYMqg`O>@z_B)LEtJ)KiiiU(eJ5-ua z!Mf6KE2EEJ*H|zAxFN%`3(~GSnS&0wc4lrur64r<%8poc2gxREvv;AdmWKNckUZ=e z_o3v6iYAtIGA~$8?)$YHrX8x14~2t~J;+|WJ+KY1C_YJNQ44STldQ0%ib6$raY@HR z8!4GyhuNX1lbM!knpTg|w>{ZmIaBkLCcXr_3Hx2}YAQVt#aOT=+(K>G!|Z&nYfbEP z=7|CupQX^7wD2>I*qXC!@r$z(vUeey(ll4U_ZVR0VYk%+Hcn8U|6ee*h7h(zgy1xK zewyQZJ;2 z^lr#EG8Q-F8yc1wO^aI@%y#DL< z%R=gTxw&gU>d9p!#Kuw`j#3wu%yP?;>d?MDmPq};Qm#8Cn>0-4_%m$D8Sj#PX4z$N z{Uy}rZQ(iVm};K`%n_9S3@gs-Y63XCAC*h~#5C0BZz<=`HO}*8P%~J6j3+Rv5I^`1 zP5SQoP$fS$(D;QWU6M~NWPe^Nt?P?iHe$JCkK~b6a&xolPJGm>BJQC)cbM(Zu6R|q zl|}6^k*&=nCYQeanl`VD_`_%@ifD*Cb+Dl@dP&}>C2>lt;BNcE_cknRWK^*&zQ`7kG_e&B9iqt(iU{D%cn&0s+Z{;U zIAdkAN3Ek9igq;d^AgLU+6#?1Y^6O4pRq;#C>!P`YA|WIAeV~qYv;f@GJ{tS!5&u7 zme$Zk2p8Qn7e)`LqI#at5}dC)cuSR)`*km^l37Gwv+siLX+{)A+kD5J8V;j{;<`GD z2uVT@b**tk*Qzk4W)b0ESsn?%MW-txUn>nqj_gK#@%1E3TA{mjj=A1&GxNA! z7y%i0p;EFeNlLjv>Uw|8X}Wwoy?0GCE~~_w6NK?S4iS7#$Uws+{%G|Ro+e-CoM}fi z{YzJfwipz3Ews+hQMi8WHmcdpT9`kr)(#u zXJ$v}_y#;yrR?HF0KUdPY=08&2RF($u8dWh8AxYlZIRa|!yuUL#$dc(t3fbE!a|Wa zIYUmX=n7f|ch$iB@ZyEp2zoAlJ^Fil`(|{+2=y=vND&;jeQ`flf(dRzUGA>K5rT}^ zKI8B+As`7&UNAkmzC7Z73{i3exq$1V`A-4**w|4+h2%Q=7A`+Lkm?yh8}^Gs%hA4o z$mix}9nVt<`o!io^lpD~?s8hi$n-lfE>D$OCXnkjAFQi(4bOC#`5yvl`DeKjYc_fH zI3}A)+)FA#rI21+F8OdCg5G}9m}^Rqv1XpGq$}}LDI|ma!lx0?k6z8~dL!~^r=+>s zi2h1={W(B;t*x(3Qxu%0WoOVOdSv5SE(GLaZ^bA}d6Byn>OC0_v_pLaTKlfGyD^1L z8Kp+!*D#t}Dr|`mM9N`kEu~v_R(fu^V>m$CBjtn-ShqZLG%dVd`YBLWq0j{EC{$H^ zFnx7t(a1t|AiGHJ#yh}muJ*;;XiZs!9(Cf3+q!E^0$6*$OMRuQINJ-{AP4wapx5GU z^|^7Ctl1XIMDnK?HHTf$yOpK%J>-WS4D9vNZmh_-KiC?z#kF+n!s}RYtE$-mlH{Ab z;pFIKf`w9dOBU*@bW)x zr4&yDfWSfT#h;jBy>lXlSin`6So(^@C!TH|k8ruWLy9ds&DBu2-P2xS{ed?|8X)-& zZ7-EQh98PEJ-vr?e~q|=s_bc--W#RRDE<^EaPwUHsKCntNvPz-zWZI{A}?X3SPS!( zc4?P#?PdWdBH*ZxQ&XxK3qT^YVg35=)|)FDfSeMCbQrVGIzWj9)C1Z`6Bx^%N5)AgRY=?~xGD&aVdi;N$r%lndlL60 z4#FaMST1Fkt3e3x{zn)z)*Zn1G*!Y3#Rh-ZVRa(BJ)|VZf0w-S%b*AS4}03p4L_S5 z1h6~m342uCUIYw8lsMMvdfHE!OW`jRj$ML}%lwH<0o5x?+~89MjQXENt~ftWXyuLI zcImp%^lJwdoO4T>5H`v`Z|!_XvOWD%a6hnOlI>45OBU$e)mCImTpc>hX|Mki*sR3) z*9!p_N?p`#4*2x`$kO9EVA!h;@GA`#`Aqf3`{v>-&+ae$u(G84{Uuo-=i7Nt^qm?U z|3z_yns3=bze=?D#XxI-BES*_!!aE>(f#%lh2Geh`W*k)uw!OgDJx*HQ}xh!UG=XO zt-m}IB>!i%by7<7n<-0r!JingYs`MxV64$CJGzmxNu{o3w}B&FGu6yUsqRqGL@HJu zs((fhfmncd4lq9T(Jl^MWCW|vyMPE8U_!Qhw46|H>O@_`a^y~|?okFbrLKN2MM$$zFvG>TK2HLP+S=1m8TwqqNr37PhrYGiwZXyFmdC-^ z8&z;EaKB*e9-uCJ&^1m`i@|7C(j6-%PQ-xp5>i>+rJ^vAG^Ctfv7E7o5^Yij#X`_3 zYnl&%z~GNm0Hn*aUBah8ZcE=+i{2!EG%{CnPL+Tmwdz&ig)$M|ovJ9(YHjfoKguP0 zbz;-kv*Hlsv}CPlnp7sdnrGP+;=B<*B{&^O0;EsEKy#q_=#g26aX2jl#7ZdVDUDN*LRU6fZ!DJ3RQ$mTfYaxlH-{cM zX{jz(1o75Wh_5)$+yu9dFQ`ZG@o8l#0F^5$&}elz$w}vY4;*2E+=)j!0?7Ay%Gz2y zx8=_;acv^%1IX71vAN{jr6LsF%sB-umz$X>N<|+#G9UJBR2*DX>#fbXiA_(It1dcj ziXZE=kIej_4?%s3I1dMh${(955$m|j?5)hkz?}6M068byrzKm)W40qMKKJb$+q71v zQv`nJ3>Aa#JfN%8_UO|T7~-_g9;o^)7(f03GusTDwtO_7Ra0QH^NctsUOp`y*utATRQs(ExHk;GjSj5W>}S3hBsLz4tQs}hK@D|3F}~X)_-yQv`^DCa1GXQ= zT_11!w1&~i?DmP?*c3GjY>NvKiQa3Q2Q;*5V8K}vv?Capo%?|y*IJq@y3hob7sH#(dQ^@Ts zPq?n5tQy4N+grW(mjusHhUv%RT8?o+KkZ5+_>4$*vr4Fs9Mm+iy~c@|9Nn%Slpa$3 zvte>m9QqNc(CBgPZ7B6vf=kA! zOM>y;^J}`6??t4yRghND>+t6i=_;Yq`sf$Vz_ubgqV0sBvm;-74h(&~vlnwsSH~15*Lc3@Uf2aY!`XoS%`~266myN`|9_ zT;G|bUzhU+R)zoNyuBz~4}eC3-5%yMG`HA0Oxg@_STJUITka4BR_E5}4Fx%e&?{)l zw9rn)w!4er7jH4YW}`k--3iyBUe*TE3kgZV1Y3rq%T949aFP>mA4xB+c`nud2IRhLipt*$=HXi2S#*-<7)h;4wZ!HJfN9B5R(E2FpnERfd6qv1o&%~_8` zGuE7U3fei4n)q_jOEU`>h!zT#hpV`N^>%@L;clS}#RWW}EE%NSN_a4C9S_^DN~5t%3*X8?g@A(`>h*0thaNzU13l|F4uh6F)B^M0PY`E z-j;9SByLHU+HEP8b#o%13on$mvdQ6HO#8t%D6f~I*OFxMaz-ow>l=9DBtc#w@HF_q zyvc{M=ac8}YbNZ>ZTJUo*3CuNR4nI(ybeIZ{hk?rPq=ym5QOB2Of_73xF_GUKr76M z6^D8)J5LKye;D{OR?SZI=c%`FU=LxNWpO2pTASXA6FF0kJShzILv#4~47qI$@h!8s zc)e)I#X2M5@o{%;&^jwNPc~oNzyKE(D1MM{b2!emkQeVR0T;=#94#;_4;Kq?k@5Pl zImH4D>SoNuPRVoHNw7oT$bH)!3zX$*eH^*is{WRtd7NQtOM;fP!SU2rO`0$^G4Bss zkkgk6T|1@;pkwsnrQaUJR5fY{pR;F%F5Gf9ksjcT`W)Lec-eZ}{ngJj@q<1f1^@r) zkw!V%H}4D4xcDlYE4`pRoQNuw{*$v(?V>jHuG22!bJADa*`ZgS0?vvj1pU-oFC!38 zkd-kq@M5tKjy=lpK^e0|NV~L^J0qHRI%xZ 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 098/105] 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 099/105] 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 100/105] 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 101/105] 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 102/105] 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 103/105] 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 104/105] 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 105/105] 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;