From 82212af643a2710a894613e55aa760db767de341 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 17:47:08 -0700 Subject: [PATCH 1/3] Add resource degraded --- messages/en-US.json | 1 + server/db/pg/schema/privateSchema.ts | 1 + server/db/sqlite/schema/privateSchema.ts | 43 ++++++++++++++----- server/emails/templates/AlertNotification.tsx | 15 ++++++- .../lib/alerts/events/healthCheckEvents.ts | 9 ++++ .../lib/alerts/events/resourceEvents.ts | 29 ++++++++++--- server/private/lib/alerts/sendAlertEmail.ts | 2 + server/private/lib/alerts/sendAlertWebhook.ts | 31 ++++++++++--- .../alertEvents/triggerResourceAlert.ts | 14 ++++-- .../routers/alertRule/createAlertRule.ts | 41 ++++++++++++------ .../routers/alertRule/listAlertRules.ts | 1 + server/routers/alertRule/types.ts | 2 + src/components/AlertingRulesTable.tsx | 2 + src/components/ProxyResourcesTable.tsx | 1 - .../alert-rule-editor/AlertRuleFields.tsx | 4 ++ src/lib/alertRuleForm.ts | 8 +++- 16 files changed, 162 insertions(+), 42 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 13b47d135..c566d500e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1432,6 +1432,7 @@ "alertingTriggerHcToggle": "Health check status changes", "alertingTriggerResourceHealthy": "Resource healthy", "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingTriggerResourceDegraded": "Resource degraded", "alertingSearchHealthChecks": "Search health checks…", "alertingHealthChecksEmpty": "No health checks available.", "alertingTriggerResourceToggle": "Resource status changes", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d930b69d0..1aa2a1ef7 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -484,6 +484,7 @@ export const alertRules = pgTable("alertRules", { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle" >() .notNull(), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 9a33e2049..25a7b5bf5 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -425,10 +425,18 @@ export const eventStreamingDestinations = sqliteTable( orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), - sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), - sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), - sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false), + sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendActionLogs: integer("sendActionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }) + .notNull() + .default(false), type: text("type").notNull(), // e.g. "http", "kafka", etc. config: text("config").notNull(), // JSON string with the configuration for the destination enabled: integer("enabled", { mode: "boolean" }) @@ -476,14 +484,19 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), - allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), - allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }) + .notNull() + .default(false), + allResources: integer("allResources", { mode: "boolean" }) + .notNull() + .default(false), lastTriggeredAt: integer("lastTriggeredAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() @@ -531,19 +544,27 @@ 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: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), email: text("email") }); export const alertWebhookActions = sqliteTable("alertWebhookActions", { - webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }), + webhookActionId: integer("webhookActionId").primaryKey({ + autoIncrement: true + }), alertRuleId: integer("alertRuleId") .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: integer("enabled", { mode: "boolean" }).notNull().default(true), lastSentAt: integer("lastSentAt") }); diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 5542384a9..899c4a82f 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -23,6 +23,7 @@ export type AlertEventType = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; export type AlertNotificationProps = { @@ -114,6 +115,15 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Unhealthy", statusColor: "#dc2626" }; + case "resource_degraded": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; case "resource_toggle": return { heading: "Resource Status Changed", @@ -135,7 +145,10 @@ function getEventMeta(eventType: AlertEventType): { } } -function resolveToggleStatus(status: unknown): { label: string; color: string } { +function resolveToggleStatus(status: unknown): { + label: string; + color: string; +} { switch (String(status).toLowerCase()) { case "online": return { label: "Online", color: "#16a34a" }; diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9b5c3104b..9f675f4c0 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -23,6 +23,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { + fireResourceDegradedAlert, fireResourceHealthyAlert, fireResourceUnhealthyAlert } from "./resourceEvents"; @@ -217,6 +218,14 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null undefined, trx ); + } else if (health === "degraded") { + await fireResourceDegradedAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + trx + ); } } } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 8c20bc5a1..41c77dc11 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -130,9 +130,9 @@ export async function fireResourceUnhealthyAlert( } /** - * Fire a `resource_toggle` alert for the given resource. + * Fire a `resource_degraded` alert for the given resource. * - * Call this when a resource's enabled/disabled status is toggled so that any + * Call this after a resource has been detected as degraded so that any * matching `alertRules` can dispatch their email and webhook actions. * * @param orgId - Organisation that owns the resource. @@ -140,7 +140,7 @@ export async function fireResourceUnhealthyAlert( * @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( +export async function fireResourceDegradedAlert( orgId: string, resourceId: number, resourceName?: string | null, @@ -148,8 +148,16 @@ export async function fireResourceToggleAlert( trx: Transaction | typeof db = db ): Promise { try { + await trx.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "degraded", + timestamp: Math.floor(Date.now() / 1000) + }); + await processAlerts({ - eventType: "resource_toggle", + eventType: "resource_degraded", orgId, resourceId, data: { @@ -157,9 +165,20 @@ export async function fireResourceToggleAlert( ...extra } }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "degraded", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); } catch (err) { logger.error( - `fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, + `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`, err ); } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 598262e38..6f99b102c 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -88,6 +88,8 @@ function buildSubject(context: AlertContext): string { return "[Alert] Resource Healthy"; case "resource_unhealthy": return "[Alert] Resource Unhealthy"; + case "resource_degraded": + return "[Alert] Resource Degraded"; case "resource_toggle": return "[Alert] Resource Status Changed"; default: { diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 2dd0eb600..3975eb09f 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -12,7 +12,10 @@ */ import logger from "@server/logger"; -import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { + AlertContext, + WebhookAlertConfig +} from "@server/routers/alertRule/types"; const REQUEST_TIMEOUT_MS = 15_000; const MAX_RETRIES = 3; @@ -56,7 +59,10 @@ export async function sendAlertWebhook( for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { const controller = new AbortController(); - const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeoutHandle = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); let response: Response; try { @@ -75,7 +81,9 @@ export async function sendAlertWebhook( ); } else { const msg = err instanceof Error ? err.message : String(err); - lastError = new Error(`Alert webhook: request to "${url}" failed – ${msg}`); + lastError = new Error( + `Alert webhook: request to "${url}" failed – ${msg}` + ); } if (attempt < MAX_RETRIES) { const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); @@ -111,11 +119,18 @@ export async function sendAlertWebhook( continue; } - logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`); + logger.debug( + `Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})` + ); return; } - throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); + throw ( + lastError ?? + new Error( + `Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"` + ) + ); } // --------------------------------------------------------------------------- @@ -139,6 +154,8 @@ function deriveStatus( case "health_check_unhealthy": case "resource_unhealthy": return "unhealthy"; + case "resource_degraded": + return "degraded"; case "health_check_toggle": case "resource_toggle": return String(data.status ?? "unknown"); @@ -154,7 +171,9 @@ function deriveStatus( // Header construction (mirrors HttpLogDestination.buildHeaders) // --------------------------------------------------------------------------- -function buildHeaders(webhookConfig: WebhookAlertConfig): Record { +function buildHeaders( + webhookConfig: WebhookAlertConfig +): Record { const headers: Record = { "Content-Type": "application/json" }; diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts index 42e63b288..a43b8e201 100644 --- a/server/private/routers/alertEvents/triggerResourceAlert.ts +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -24,7 +24,8 @@ import { eq, and } from "drizzle-orm"; import { fireResourceHealthyAlert, fireResourceUnhealthyAlert, - fireResourceToggleAlert + fireResourceToggleAlert, + fireResourceDegradedAlert } from "#private/lib/alerts/events/resourceEvents"; const paramsSchema = z.strictObject({ @@ -33,7 +34,12 @@ const paramsSchema = z.strictObject({ }); const bodySchema = z.strictObject({ - eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"]) + eventType: z.enum([ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" + ]) }); export type TriggerResourceAlertResponse = { @@ -101,8 +107,8 @@ export async function triggerResourceAlert( resourceId, resource.name ?? undefined ); - } else { - await fireResourceToggleAlert( + } else if (eventType === "resource_degraded") { + await fireResourceDegradedAlert( orgId, resourceId, resource.name ?? undefined diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index ceaacf73c..f9b84ebab 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -33,7 +33,11 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; -export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const; +export const SITE_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; export const HC_EVENT_TYPES = [ "health_check_healthy", "health_check_unhealthy", @@ -42,6 +46,7 @@ export const HC_EVENT_TYPES = [ export const RESOURCE_EVENT_TYPES = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ] as const; @@ -92,19 +97,24 @@ 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 - ); + const isResourceEvent = ( + RESOURCE_EVENT_TYPES as readonly string[] + ).includes(val.eventType); if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one siteId is required for site event types when allSites is false", + message: + "At least one siteId is required for site event types when allSites is false", path: ["siteIds"] }); } - if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { + if ( + isHcEvent && + !val.allHealthChecks && + val.healthCheckIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: @@ -129,10 +139,15 @@ const bodySchema = z }); } - if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) { + if ( + isResourceEvent && + !val.allResources && + val.resourceIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one resourceId is required for resource event types when allResources is false", + message: + "At least one resourceId is required for resource event types when allResources is false", path: ["resourceIds"] }); } @@ -148,7 +163,8 @@ const bodySchema = z if (isResourceEvent && val.healthCheckIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "healthCheckIds must not be set for resource event types", + message: + "healthCheckIds must not be set for resource event types", path: ["healthCheckIds"] }); } @@ -164,7 +180,8 @@ const bodySchema = z if (isHcEvent && val.resourceIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "resourceIds must not be set for health check event types", + message: + "resourceIds must not be set for health check event types", path: ["resourceIds"] }); } @@ -284,9 +301,7 @@ 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 diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index a31a4d119..9684b88a4 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -76,6 +76,7 @@ const SITE_ALERT_EVENT_TYPES = [ const RESOURCE_ALERT_EVENT_TYPES = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ] as const; diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index a9e66350e..e3f92591d 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -37,6 +37,7 @@ export type GetAlertRuleResponse = { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; enabled: boolean; cooldownSeconds: number; @@ -94,6 +95,7 @@ export type AlertEventType = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; // --------------------------------------------------------------------------- diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 52ff3b609..f8fcf468d 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -118,6 +118,8 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { return t("alertingTriggerResourceHealthy"); case "resource_unhealthy": return t("alertingTriggerResourceUnhealthy"); + case "resource_degraded": + return t("alertingTriggerResourceDegraded"); case "resource_toggle": return t("alertingTriggerResourceToggle"); default: diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 324f29552..2b56eb98d 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -32,7 +32,6 @@ 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, diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 1d420f433..887fbaa5a 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -1147,6 +1147,7 @@ export function AlertRuleSourceFields({ if ( curTrigger !== "resource_healthy" && curTrigger !== "resource_unhealthy" && + curTrigger !== "resource_degraded" && curTrigger !== "resource_toggle" ) { setValue("trigger", "resource_toggle", { @@ -1367,6 +1368,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerResourceUnhealthy")} + + {t("alertingTriggerResourceDegraded")} + ) : ( <> diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 111487c48..039c367b6 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -25,6 +25,7 @@ export type AlertTrigger = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; export type AlertRuleFormAction = @@ -77,6 +78,7 @@ export type AlertRuleApiPayload = { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; enabled: boolean; allSites: boolean; @@ -160,6 +162,7 @@ export function buildFormSchema(t: (k: string) => string) { "health_check_toggle", "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ]), actions: z.array( @@ -243,6 +246,7 @@ export function buildFormSchema(t: (k: string) => string) { const resourceTriggers: AlertTrigger[] = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ]; if ( @@ -344,7 +348,9 @@ export function alertRuleAllResourcesSelected( eventType: string, resourceIds: number[] | undefined ): boolean { - return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0; + return ( + eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0 + ); } export function alertRuleAllHealthChecksSelected( From 7c7d1f641e174b94fd228d6d2f4173d824e79635 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 25 Apr 2026 15:29:59 -0700 Subject: [PATCH 2/3] Support unknown and degraded status --- server/lib/alerts/events/healthCheckEvents.ts | 11 + server/lib/blueprints/proxyResources.ts | 27 ++ server/lib/statusHistory.ts | 51 +++- .../lib/alerts/events/healthCheckEvents.ts | 47 +++- .../lib/alerts/events/resourceEvents.ts | 46 +++ server/private/lib/alerts/types.ts | 63 +++++ server/routers/target/updateTarget.ts | 15 + src/app/admin/users/AdminUsersTable.tsx | 264 ++++++++++++++++++ src/components/AdminUsersDataTable.tsx | 37 +++ src/components/RolesDataTable.tsx | 41 +++ src/components/UptimeBar.tsx | 7 +- src/components/UptimeMiniBar.tsx | 5 +- src/components/UsersDataTable.tsx | 41 +++ src/lib/alertRulesLocalStorage.ts | 129 +++++++++ 14 files changed, 766 insertions(+), 18 deletions(-) create mode 100644 server/private/lib/alerts/types.ts create mode 100644 src/app/admin/users/AdminUsersTable.tsx create mode 100644 src/components/AdminUsersDataTable.tsx create mode 100644 src/components/RolesDataTable.tsx create mode 100644 src/components/UsersDataTable.tsx create mode 100644 src/lib/alertRulesLocalStorage.ts diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index ba79feb4b..0a6d40f16 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -20,4 +20,15 @@ export async function fireHealthCheckUnhealthyAlert( trx?: unknown ): Promise { return; +} + +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx?: unknown +): Promise { + return; } \ No newline at end of file diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 136fa1545..fc1fee5b0 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -34,6 +34,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { @@ -169,6 +170,18 @@ export async function updateProxyResources( .returning(); healthchecksToUpdate.push(newHealthcheck); + + // Insert unknown status history when HC is created in disabled state + if (!healthcheckData?.enabled) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } // Find existing resource by niceId and orgId @@ -557,6 +570,20 @@ export async function updateProxyResources( targetsToUpdate.push(updatedTarget); } } + + // Insert unknown status history when HC is disabled + const isDisablingHc = + !healthcheckData?.enabled && oldHealthcheck?.hcEnabled; + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } else { await createTarget(existingResource.resourceId, targetData); } diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 001a0b93b..896a5e302 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket { uptimePercent: number; // 0-100 totalDowntimeSeconds: number; downtimeWindows: { start: number; end: number | null; status: string }[]; - status: "good" | "degraded" | "bad" | "no_data"; + status: "good" | "degraded" | "bad" | "no_data" | "unknown"; } export interface StatusHistoryResponse { @@ -54,6 +54,7 @@ export function computeBuckets( const windows: { start: number; end: number | null; status: string }[] = []; let dayDowntime = 0; + let dayDegradedTime = 0; let windowStart = dayStartSec; let windowStatus = currentStatus; @@ -63,8 +64,8 @@ export function computeBuckets( const windowEnd = evt.timestamp; const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown) { dayDowntime += windowEnd - windowStart; windows.push({ @@ -72,6 +73,13 @@ export function computeBuckets( end: windowEnd, status: windowStatus, }); + } else if (isDegraded) { + dayDegradedTime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus, + }); } } windowStart = evt.timestamp; @@ -83,8 +91,8 @@ export function computeBuckets( const finalEnd = Math.min(dayEndSec, nowSec); const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown && finalEnd > windowStart) { dayDowntime += finalEnd - windowStart; windows.push({ @@ -92,6 +100,13 @@ export function computeBuckets( end: finalEnd, status: windowStatus, }); + } else if (isDegraded && finalEnd > windowStart) { + dayDegradedTime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus, + }); } } @@ -105,7 +120,7 @@ export function computeBuckets( effectiveDayLength > 0 ? Math.max( 0, - ((effectiveDayLength - dayDowntime) / + ((effectiveDayLength - dayDowntime - dayDegradedTime) / effectiveDayLength) * 100 ) @@ -113,11 +128,27 @@ export function computeBuckets( const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + const hasAnyData = currentStatus !== null || dayEvents.length > 0; + + // The whole observable window is "unknown" if every status we have seen is unknown + const allStatuses = [ + ...(currentStatus !== null ? [currentStatus] : []), + ...dayEvents.map((e) => e.status) + ]; + const onlyUnknownData = + hasAnyData && allStatuses.every((s) => s === "unknown"); + 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"; + if (hasAnyData) { + if (onlyUnknownData) { + status = "unknown"; + } else if (dayDowntime > 0 && uptimePct < 50) { + status = "bad"; + } else if (dayDowntime > 0 || dayDegradedTime > 0) { + status = "degraded"; + } else { + status = "good"; + } } buckets.push({ diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9f675f4c0..04f197a8d 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -25,7 +25,8 @@ import { eq } from "drizzle-orm"; import { fireResourceDegradedAlert, fireResourceHealthyAlert, - fireResourceUnhealthyAlert + fireResourceUnhealthyAlert, + fireResourceUnknownAlert } from "./resourceEvents"; // --------------------------------------------------------------------------- @@ -148,6 +149,32 @@ export async function fireHealthCheckUnhealthyAlert( } } +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await handleResource(orgId, healthCheckTargetId, trx); + } catch (err) { + logger.error( + `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) { if (!healthCheckTargetId) { return; @@ -178,10 +205,16 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .where(eq(targets.resourceId, resource.resourceId)); let health = "healthy"; + const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); - if (allHealthy) { + if (allUnknown) { + logger.debug( + `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` + ); + health = "unknown"; + } else if (allHealthy) { health = "healthy"; } else if (allUnhealthy) { logger.debug( @@ -202,7 +235,15 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .set({ health }) .where(eq(resources.resourceId, resource.resourceId)); - if (health === "unhealthy") { + if (health === "unknown") { + await fireResourceUnknownAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + trx + ); + } else if (health === "unhealthy") { await fireResourceUnhealthyAlert( orgId, resource.resourceId, diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 41c77dc11..289b19b90 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -183,3 +183,49 @@ export async function fireResourceDegradedAlert( ); } } + +/** + * Fire a `resource_unknown` alert for the given resource. + * + * Call this when all health checks on a resource are disabled so that the + * resource status transitions to unknown. + * + * @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 fireResourceUnknownAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unknown", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..e79db2ef5 --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +// --------------------------------------------------------------------------- +// 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; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: 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 diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index dad302de8..21f52566f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -225,6 +226,11 @@ export async function updateTarget( hcHealthValue = undefined; } + const isDisablingHc = + (parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null) && + existingHc.hcEnabled === true; + const [updatedHc] = await db .update(targetHealthCheck) .set({ @@ -250,6 +256,15 @@ export async function updateTarget( .where(eq(targetHealthCheck.targetId, targetId)) .returning(); + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + resource.orgId, + existingHc.targetHealthCheckId, + existingHc.name, + updatedHc.targetId + ); + } + if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx new file mode 100644 index 000000000..1c7d1b7fd --- /dev/null +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; + +export type GlobalUserRow = { + id: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; + dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "id", + friendlyName: "ID", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "username", + friendlyName: t("username"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + friendlyName: t("email"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + friendlyName: t("name"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + friendlyName: t("identityProvider"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "twoFactorEnabled", + friendlyName: t("twoFactor"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ + + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

{t("userQuestionRemove")}

+ +

{t("userMessageRemove")}

+
+ } + buttonText={t("userDeleteConfirm")} + onConfirm={async () => deleteUser(selected!.id)} + string={ + selected.email || selected.name || selected.username + } + title={t("userDeleteServer")} + /> + )} + + + + ); +} diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx new file mode 100644 index 000000000..afa473e86 --- /dev/null +++ b/src/components/AdminUsersDataTable.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx new file mode 100644 index 000000000..5a2d1cb4c --- /dev/null +++ b/src/components/RolesDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + createRole?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function RolesDataTable({ + columns, + data, + createRole, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index 5bd5ce3f1..37e38bcaa 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -42,7 +42,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeBarProps = { @@ -188,7 +189,7 @@ export default function UptimeBar({
{formatDate(day.date)}
- {day.status !== "no_data" && ( + {day.status !== "no_data" && day.status !== "unknown" && (
{t("uptimeTooltipUptimeLabel")}:{" "} @@ -224,7 +225,7 @@ export default function UptimeBar({ ))}
)} - {day.status === "no_data" && ( + {(day.status === "no_data" || day.status === "unknown") && (
{t("uptimeNoMonitoringData")}
diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b4c8aa3bd..b7e684c8b 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -34,7 +34,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeMiniBarProps = { @@ -137,7 +138,7 @@ export default function UptimeMiniBar({ {formatDate(day.date)}
- {day.status === "no_data" + {day.status === "no_data" || day.status === "unknown" ? t("uptimeNoData") : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx new file mode 100644 index 000000000..ececa4c17 --- /dev/null +++ b/src/components/UsersDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + inviteUser?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + inviteUser, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts new file mode 100644 index 000000000..2471219b0 --- /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("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 bf1870608b736ae2c88af4ba7022a462602a8573 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 25 Apr 2026 15:51:39 -0700 Subject: [PATCH 3/3] Exclude wildcard resources --- server/routers/resource/listResources.ts | 3 +++ src/components/CreateShareLinkForm.tsx | 19 ++++++------------- src/components/resource-selector.tsx | 13 +++++++++---- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 9127d74e6..729d24ebe 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -143,6 +143,7 @@ export type ResourceWithTargets = { domainId: string | null; niceId: string; headerAuthId: number | null; + wildcard: boolean; targets: Array<{ targetId: number; ip: string; @@ -176,6 +177,7 @@ function queryResourcesBase() { enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, + wildcard: resources.wildcard, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, @@ -456,6 +458,7 @@ export async function listResources( http: row.http, protocol: row.protocol, proxyPort: row.proxyPort, + wildcard: row.wildcard, enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index d0e26a1c2..2e5dbe655 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -47,15 +47,7 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { CaretSortIcon } from "@radix-ui/react-icons"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { ChevronsUpDown } from "lucide-react"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { constructShareLink } from "@app/lib/shareLinks"; @@ -275,10 +267,11 @@ export default function CreateShareLinkForm({ ; export type ResourceSelectorProps = { orgId: string; selectedResource?: SelectedResource | null; onSelectResource: (resource: SelectedResource) => void; + excludeWildcard?: boolean; }; export function ResourceSelector({ orgId, selectedResource, - onSelectResource + onSelectResource, + excludeWildcard = false }: ResourceSelectorProps) { const t = useTranslations(); const [resourceSearchQuery, setResourceSearchQuery] = useState(""); @@ -46,10 +48,13 @@ export function ResourceSelector({ // always include the selected resource in the list of resources shown const resourcesShown = useMemo(() => { - const allResources: Array = [...resources]; + const allResources: Array = excludeWildcard + ? resources.filter((r) => !r.wildcard) + : [...resources]; if ( debouncedSearchQuery.trim().length === 0 && selectedResource && + !(excludeWildcard && selectedResource.wildcard) && !allResources.find( (resource) => resource.resourceId === selectedResource?.resourceId @@ -58,7 +63,7 @@ export function ResourceSelector({ allResources.unshift(selectedResource); } return allResources; - }, [debouncedSearchQuery, resources, selectedResource]); + }, [debouncedSearchQuery, resources, selectedResource, excludeWildcard]); return (