Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
miloschwartz
2026-04-26 10:23:36 -07:00
31 changed files with 946 additions and 77 deletions

View File

@@ -1432,6 +1432,7 @@
"alertingTriggerHcToggle": "Health check status changes", "alertingTriggerHcToggle": "Health check status changes",
"alertingTriggerResourceHealthy": "Resource healthy", "alertingTriggerResourceHealthy": "Resource healthy",
"alertingTriggerResourceUnhealthy": "Resource unhealthy", "alertingTriggerResourceUnhealthy": "Resource unhealthy",
"alertingTriggerResourceDegraded": "Resource degraded",
"alertingSearchHealthChecks": "Search health checks…", "alertingSearchHealthChecks": "Search health checks…",
"alertingHealthChecksEmpty": "No health checks available.", "alertingHealthChecksEmpty": "No health checks available.",
"alertingTriggerResourceToggle": "Resource status changes", "alertingTriggerResourceToggle": "Resource status changes",

View File

@@ -484,6 +484,7 @@ export const alertRules = pgTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),

View File

@@ -425,10 +425,18 @@ export const eventStreamingDestinations = sqliteTable(
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" })
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), .notNull()
sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), .default(false),
sendAccessLogs: integer("sendAccessLogs", { 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. type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" })
@@ -476,14 +484,19 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300), cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), allHealthChecks: integer("allHealthChecks", { mode: "boolean" })
allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), .notNull()
.default(false),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
lastTriggeredAt: integer("lastTriggeredAt"), lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull() updatedAt: integer("updatedAt").notNull()
@@ -531,14 +544,22 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
emailActionId: integer("emailActionId") emailActionId: integer("emailActionId")
.notNull() .notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), .references(() => alertEmailActions.emailActionId, {
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), onDelete: "cascade"
roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: text("email") email: text("email")
}); });
export const alertWebhookActions = sqliteTable("alertWebhookActions", { export const alertWebhookActions = sqliteTable("alertWebhookActions", {
webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }), webhookActionId: integer("webhookActionId").primaryKey({
autoIncrement: true
}),
alertRuleId: integer("alertRuleId") alertRuleId: integer("alertRuleId")
.notNull() .notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }), .references(() => alertRules.alertRuleId, { onDelete: "cascade" }),

View File

@@ -23,6 +23,7 @@ export type AlertEventType =
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
export type AlertNotificationProps = { export type AlertNotificationProps = {
@@ -114,6 +115,15 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Unhealthy", statusLabel: "Unhealthy",
statusColor: "#dc2626" 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": case "resource_toggle":
return { return {
heading: "Resource Status Changed", 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()) { switch (String(status).toLowerCase()) {
case "online": case "online":
return { label: "Online", color: "#16a34a" }; return { label: "Online", color: "#16a34a" };

View File

@@ -21,3 +21,14 @@ export async function fireHealthCheckUnhealthyAlert(
): Promise<void> { ): Promise<void> {
return; return;
} }
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}

View File

@@ -34,6 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
@@ -169,6 +170,18 @@ export async function updateProxyResources(
.returning(); .returning();
healthchecksToUpdate.push(newHealthcheck); 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 // Find existing resource by niceId and orgId
@@ -557,6 +570,20 @@ export async function updateProxyResources(
targetsToUpdate.push(updatedTarget); 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 { } else {
await createTarget(existingResource.resourceId, targetData); await createTarget(existingResource.resourceId, targetData);
} }

View File

@@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket {
uptimePercent: number; // 0-100 uptimePercent: number; // 0-100
totalDowntimeSeconds: number; totalDowntimeSeconds: number;
downtimeWindows: { start: number; end: number | null; status: string }[]; downtimeWindows: { start: number; end: number | null; status: string }[];
status: "good" | "degraded" | "bad" | "no_data"; status: "good" | "degraded" | "bad" | "no_data" | "unknown";
} }
export interface StatusHistoryResponse { export interface StatusHistoryResponse {
@@ -54,6 +54,7 @@ export function computeBuckets(
const windows: { start: number; end: number | null; status: string }[] = []; const windows: { start: number; end: number | null; status: string }[] = [];
let dayDowntime = 0; let dayDowntime = 0;
let dayDegradedTime = 0;
let windowStart = dayStartSec; let windowStart = dayStartSec;
let windowStatus = currentStatus; let windowStatus = currentStatus;
@@ -63,8 +64,8 @@ export function computeBuckets(
const windowEnd = evt.timestamp; const windowEnd = evt.timestamp;
const isDown = const isDown =
windowStatus === "offline" || windowStatus === "offline" ||
windowStatus === "unhealthy" || windowStatus === "unhealthy";
windowStatus === "unknown"; const isDegraded = windowStatus === "degraded";
if (isDown) { if (isDown) {
dayDowntime += windowEnd - windowStart; dayDowntime += windowEnd - windowStart;
windows.push({ windows.push({
@@ -72,6 +73,13 @@ export function computeBuckets(
end: windowEnd, end: windowEnd,
status: windowStatus, status: windowStatus,
}); });
} else if (isDegraded) {
dayDegradedTime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
} }
} }
windowStart = evt.timestamp; windowStart = evt.timestamp;
@@ -83,8 +91,8 @@ export function computeBuckets(
const finalEnd = Math.min(dayEndSec, nowSec); const finalEnd = Math.min(dayEndSec, nowSec);
const isDown = const isDown =
windowStatus === "offline" || windowStatus === "offline" ||
windowStatus === "unhealthy" || windowStatus === "unhealthy";
windowStatus === "unknown"; const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) { if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart; dayDowntime += finalEnd - windowStart;
windows.push({ windows.push({
@@ -92,6 +100,13 @@ export function computeBuckets(
end: finalEnd, end: finalEnd,
status: windowStatus, 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 effectiveDayLength > 0
? Math.max( ? Math.max(
0, 0,
((effectiveDayLength - dayDowntime) / ((effectiveDayLength - dayDowntime - dayDegradedTime) /
effectiveDayLength) * effectiveDayLength) *
100 100
) )
@@ -113,11 +128,27 @@ export function computeBuckets(
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); 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"; let status: StatusHistoryDayBucket["status"] = "no_data";
if (currentStatus !== null || dayEvents.length > 0) { if (hasAnyData) {
if (uptimePct >= 99) status = "good"; if (onlyUnknownData) {
else if (uptimePct >= 50) status = "degraded"; status = "unknown";
else status = "bad"; } else if (dayDowntime > 0 && uptimePct < 50) {
status = "bad";
} else if (dayDowntime > 0 || dayDegradedTime > 0) {
status = "degraded";
} else {
status = "good";
}
} }
buckets.push({ buckets.push({

View File

@@ -23,8 +23,10 @@ import {
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
fireResourceDegradedAlert,
fireResourceHealthyAlert, fireResourceHealthyAlert,
fireResourceUnhealthyAlert fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents"; } from "./resourceEvents";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -147,6 +149,32 @@ export async function fireHealthCheckUnhealthyAlert(
} }
} }
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
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) { async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
if (!healthCheckTargetId) { if (!healthCheckTargetId) {
return; return;
@@ -177,10 +205,16 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.resourceId, resource.resourceId));
let health = "healthy"; let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); 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"; health = "healthy";
} else if (allUnhealthy) { } else if (allUnhealthy) {
logger.debug( logger.debug(
@@ -201,7 +235,15 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
.set({ health }) .set({ health })
.where(eq(resources.resourceId, resource.resourceId)); .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( await fireResourceUnhealthyAlert(
orgId, orgId,
resource.resourceId, resource.resourceId,
@@ -217,6 +259,14 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
undefined, undefined,
trx trx
); );
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
} }
} }
} }

View File

@@ -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. * matching `alertRules` can dispatch their email and webhook actions.
* *
* @param orgId - Organisation that owns the resource. * @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 resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload. * @param extra - Any additional key/value pairs to include in the payload.
*/ */
export async function fireResourceToggleAlert( export async function fireResourceDegradedAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
@@ -148,8 +148,16 @@ export async function fireResourceToggleAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({ await processAlerts({
eventType: "resource_toggle", eventType: "resource_degraded",
orgId, orgId,
resourceId, resourceId,
data: { data: {
@@ -157,9 +165,66 @@ export async function fireResourceToggleAlert(
...extra ...extra
} }
}); });
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) { } catch (err) {
logger.error( logger.error(
`fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* 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<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
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 err
); );
} }

View File

@@ -88,6 +88,8 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Resource Healthy"; return "[Alert] Resource Healthy";
case "resource_unhealthy": case "resource_unhealthy":
return "[Alert] Resource Unhealthy"; return "[Alert] Resource Unhealthy";
case "resource_degraded":
return "[Alert] Resource Degraded";
case "resource_toggle": case "resource_toggle":
return "[Alert] Resource Status Changed"; return "[Alert] Resource Status Changed";
default: { default: {

View File

@@ -12,7 +12,10 @@
*/ */
import logger from "@server/logger"; 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 REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
@@ -56,7 +59,10 @@ export async function sendAlertWebhook(
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const timeoutHandle = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS
);
let response: Response; let response: Response;
try { try {
@@ -75,7 +81,9 @@ export async function sendAlertWebhook(
); );
} else { } else {
const msg = err instanceof Error ? err.message : String(err); 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) { if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
@@ -111,11 +119,18 @@ export async function sendAlertWebhook(
continue; 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; 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 "health_check_unhealthy":
case "resource_unhealthy": case "resource_unhealthy":
return "unhealthy"; return "unhealthy";
case "resource_degraded":
return "degraded";
case "health_check_toggle": case "health_check_toggle":
case "resource_toggle": case "resource_toggle":
return String(data.status ?? "unknown"); return String(data.status ?? "unknown");
@@ -154,7 +171,9 @@ function deriveStatus(
// Header construction (mirrors HttpLogDestination.buildHeaders) // Header construction (mirrors HttpLogDestination.buildHeaders)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildHeaders(webhookConfig: WebhookAlertConfig): Record<string, string> { function buildHeaders(
webhookConfig: WebhookAlertConfig
): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json" "Content-Type": "application/json"
}; };

View File

@@ -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<string, unknown>;
}

View File

@@ -24,7 +24,8 @@ import { eq, and } from "drizzle-orm";
import { import {
fireResourceHealthyAlert, fireResourceHealthyAlert,
fireResourceUnhealthyAlert, fireResourceUnhealthyAlert,
fireResourceToggleAlert fireResourceToggleAlert,
fireResourceDegradedAlert
} from "#private/lib/alerts/events/resourceEvents"; } from "#private/lib/alerts/events/resourceEvents";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -33,7 +34,12 @@ const paramsSchema = z.strictObject({
}); });
const bodySchema = 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 = { export type TriggerResourceAlertResponse = {
@@ -101,8 +107,8 @@ export async function triggerResourceAlert(
resourceId, resourceId,
resource.name ?? undefined resource.name ?? undefined
); );
} else { } else if (eventType === "resource_degraded") {
await fireResourceToggleAlert( await fireResourceDegradedAlert(
orgId, orgId,
resourceId, resourceId,
resource.name ?? undefined resource.name ?? undefined

View File

@@ -33,7 +33,11 @@ import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; 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 = [ export const HC_EVENT_TYPES = [
"health_check_healthy", "health_check_healthy",
"health_check_unhealthy", "health_check_unhealthy",
@@ -42,6 +46,7 @@ export const HC_EVENT_TYPES = [
export const RESOURCE_EVENT_TYPES = [ export const RESOURCE_EVENT_TYPES = [
"resource_healthy", "resource_healthy",
"resource_unhealthy", "resource_unhealthy",
"resource_degraded",
"resource_toggle" "resource_toggle"
] as const; ] as const;
@@ -92,19 +97,24 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType val.eventType
); );
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( const isResourceEvent = (
val.eventType RESOURCE_EVENT_TYPES as readonly string[]
); ).includes(val.eventType);
if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { if (isSiteEvent && !val.allSites && val.siteIds.length === 0) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, 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"] path: ["siteIds"]
}); });
} }
if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { if (
isHcEvent &&
!val.allHealthChecks &&
val.healthCheckIds.length === 0
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, 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"] path: ["resourceIds"]
}); });
} }
@@ -148,7 +163,8 @@ const bodySchema = z
if (isResourceEvent && val.healthCheckIds.length > 0) { if (isResourceEvent && val.healthCheckIds.length > 0) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, 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"] path: ["healthCheckIds"]
}); });
} }
@@ -164,7 +180,8 @@ const bodySchema = z
if (isHcEvent && val.resourceIds.length > 0) { if (isHcEvent && val.resourceIds.length > 0) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, 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"] path: ["resourceIds"]
}); });
} }
@@ -284,9 +301,7 @@ export async function createAlertRule(
// Create the email action pivot row and recipients if any recipients // Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails). // were supplied (userIds, roleIds, or raw emails).
const hasRecipients = const hasRecipients =
userIds.length > 0 || userIds.length > 0 || roleIds.length > 0 || emails.length > 0;
roleIds.length > 0 ||
emails.length > 0;
if (hasRecipients) { if (hasRecipients) {
const [emailActionRow] = await db const [emailActionRow] = await db

View File

@@ -76,6 +76,7 @@ const SITE_ALERT_EVENT_TYPES = [
const RESOURCE_ALERT_EVENT_TYPES = [ const RESOURCE_ALERT_EVENT_TYPES = [
"resource_healthy", "resource_healthy",
"resource_unhealthy", "resource_unhealthy",
"resource_degraded",
"resource_toggle" "resource_toggle"
] as const; ] as const;

View File

@@ -37,6 +37,7 @@ export type GetAlertRuleResponse = {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
enabled: boolean; enabled: boolean;
cooldownSeconds: number; cooldownSeconds: number;
@@ -94,6 +95,7 @@ export type AlertEventType =
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -138,6 +138,7 @@ export type ResourceWithTargets = {
domainId: string | null; domainId: string | null;
niceId: string; niceId: string;
headerAuthId: number | null; headerAuthId: number | null;
wildcard: boolean;
targets: Array<{ targets: Array<{
targetId: number; targetId: number;
ip: string; ip: string;
@@ -171,6 +172,7 @@ function queryResourcesBase() {
enabled: resources.enabled, enabled: resources.enabled,
domainId: resources.domainId, domainId: resources.domainId,
niceId: resources.niceId, niceId: resources.niceId,
wildcard: resources.wildcard,
headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId: headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
@@ -453,6 +455,7 @@ export async function listResources(
http: row.http, http: row.http,
protocol: row.protocol, protocol: row.protocol,
proxyPort: row.proxyPort, proxyPort: row.proxyPort,
wildcard: row.wildcard,
enabled: row.enabled, enabled: row.enabled,
domainId: row.domainId, domainId: row.domainId,
headerAuthId: row.headerAuthId, headerAuthId: row.headerAuthId,

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -225,6 +226,11 @@ export async function updateTarget(
hcHealthValue = undefined; hcHealthValue = undefined;
} }
const isDisablingHc =
(parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null) &&
existingHc.hcEnabled === true;
const [updatedHc] = await db const [updatedHc] = await db
.update(targetHealthCheck) .update(targetHealthCheck)
.set({ .set({
@@ -250,6 +256,15 @@ export async function updateTarget(
.where(eq(targetHealthCheck.targetId, targetId)) .where(eq(targetHealthCheck.targetId, targetId))
.returning(); .returning();
if (isDisablingHc) {
await fireHealthCheckUnknownAlert(
resource.orgId,
existingHc.targetHealthCheckId,
existingHc.name,
updatedHc.targetId
);
}
if (site.pubKey) { if (site.pubKey) {
if (site.type == "wireguard") { if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, { await addPeer(site.exitNodeId!, {

View File

@@ -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<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(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<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t("userDeleteServer")}
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t("userServer")}
searchPlaceholder={t("userSearch")}
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="username"
stickyRightColumn="actions"
/>
);
}

View File

@@ -118,6 +118,8 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) {
return t("alertingTriggerResourceHealthy"); return t("alertingTriggerResourceHealthy");
case "resource_unhealthy": case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy"); return t("alertingTriggerResourceUnhealthy");
case "resource_degraded":
return t("alertingTriggerResourceDegraded");
case "resource_toggle": case "resource_toggle":
return t("alertingTriggerResourceToggle"); return t("alertingTriggerResourceToggle");
default: default:

View File

@@ -47,15 +47,7 @@ import {
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { import { ChevronsUpDown } from "lucide-react";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks"; import { constructShareLink } from "@app/lib/shareLinks";
@@ -275,6 +267,7 @@ export default function CreateShareLinkForm({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<ResourceSelector <ResourceSelector
excludeWildcard
orgId={ orgId={
org.org org.org
.orgId .orgId

View File

@@ -32,7 +32,6 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon, ArrowDown01Icon,

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createRole?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function RolesDataTable<TData, TValue>({
columns,
data,
createRole,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t("roles")}
searchPlaceholder={t("accessRolesSearch")}
searchColumn="name"
onAdd={createRole}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessRolesAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -42,7 +42,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500", good: "bg-green-500",
degraded: "bg-yellow-500", degraded: "bg-yellow-500",
bad: "bg-red-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 = { type UptimeBarProps = {
@@ -188,7 +189,7 @@ export default function UptimeBar({
<div className="font-semibold text-xs"> <div className="font-semibold text-xs">
{formatDate(day.date)} {formatDate(day.date)}
</div> </div>
{day.status !== "no_data" && ( {day.status !== "no_data" && day.status !== "unknown" && (
<div className="text-xs text-primary-foreground/80"> <div className="text-xs text-primary-foreground/80">
{t("uptimeTooltipUptimeLabel")}:{" "} {t("uptimeTooltipUptimeLabel")}:{" "}
<span className="font-medium text-primary-foreground"> <span className="font-medium text-primary-foreground">
@@ -224,7 +225,7 @@ export default function UptimeBar({
))} ))}
</div> </div>
)} )}
{day.status === "no_data" && ( {(day.status === "no_data" || day.status === "unknown") && (
<div className="text-xs text-primary-foreground/60"> <div className="text-xs text-primary-foreground/60">
{t("uptimeNoMonitoringData")} {t("uptimeNoMonitoringData")}
</div> </div>

View File

@@ -34,7 +34,8 @@ const barColorClass: Record<string, string> = {
good: "bg-green-500", good: "bg-green-500",
degraded: "bg-yellow-500", degraded: "bg-yellow-500",
bad: "bg-red-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 = { type UptimeMiniBarProps = {
@@ -137,7 +138,7 @@ export default function UptimeMiniBar({
{formatDate(day.date)} {formatDate(day.date)}
</div> </div>
<div className="text-xs text-primary-foreground/80"> <div className="text-xs text-primary-foreground/80">
{day.status === "no_data" {day.status === "no_data" || day.status === "unknown"
? t("uptimeNoData") ? t("uptimeNoData")
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`} : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
</div> </div>

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
inviteUser?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
inviteUser,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t("users")}
searchPlaceholder={t("accessUsersSearch")}
searchColumn="email"
onAdd={inviteUser}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("accessUserCreate")}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1147,6 +1147,7 @@ export function AlertRuleSourceFields({
if ( if (
curTrigger !== "resource_healthy" && curTrigger !== "resource_healthy" &&
curTrigger !== "resource_unhealthy" && curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_degraded" &&
curTrigger !== "resource_toggle" curTrigger !== "resource_toggle"
) { ) {
setValue("trigger", "resource_toggle", { setValue("trigger", "resource_toggle", {
@@ -1367,6 +1368,9 @@ export function AlertRuleTriggerFields({
<SelectItem value="resource_unhealthy"> <SelectItem value="resource_unhealthy">
{t("alertingTriggerResourceUnhealthy")} {t("alertingTriggerResourceUnhealthy")}
</SelectItem> </SelectItem>
<SelectItem value="resource_degraded">
{t("alertingTriggerResourceDegraded")}
</SelectItem>
</> </>
) : ( ) : (
<> <>

View File

@@ -17,19 +17,21 @@ import { useDebounce } from "use-debounce";
export type SelectedResource = Pick< export type SelectedResource = Pick<
ListResourcesResponse["resources"][number], ListResourcesResponse["resources"][number],
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl" "name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard"
>; >;
export type ResourceSelectorProps = { export type ResourceSelectorProps = {
orgId: string; orgId: string;
selectedResource?: SelectedResource | null; selectedResource?: SelectedResource | null;
onSelectResource: (resource: SelectedResource) => void; onSelectResource: (resource: SelectedResource) => void;
excludeWildcard?: boolean;
}; };
export function ResourceSelector({ export function ResourceSelector({
orgId, orgId,
selectedResource, selectedResource,
onSelectResource onSelectResource,
excludeWildcard = false
}: ResourceSelectorProps) { }: ResourceSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [resourceSearchQuery, setResourceSearchQuery] = useState(""); const [resourceSearchQuery, setResourceSearchQuery] = useState("");
@@ -46,10 +48,13 @@ export function ResourceSelector({
// always include the selected resource in the list of resources shown // always include the selected resource in the list of resources shown
const resourcesShown = useMemo(() => { const resourcesShown = useMemo(() => {
const allResources: Array<SelectedResource> = [...resources]; const allResources: Array<SelectedResource> = excludeWildcard
? resources.filter((r) => !r.wildcard)
: [...resources];
if ( if (
debouncedSearchQuery.trim().length === 0 && debouncedSearchQuery.trim().length === 0 &&
selectedResource && selectedResource &&
!(excludeWildcard && selectedResource.wildcard) &&
!allResources.find( !allResources.find(
(resource) => (resource) =>
resource.resourceId === selectedResource?.resourceId resource.resourceId === selectedResource?.resourceId
@@ -58,7 +63,7 @@ export function ResourceSelector({
allResources.unshift(selectedResource); allResources.unshift(selectedResource);
} }
return allResources; return allResources;
}, [debouncedSearchQuery, resources, selectedResource]); }, [debouncedSearchQuery, resources, selectedResource, excludeWildcard]);
return ( return (
<Command shouldFilter={false}> <Command shouldFilter={false}>

View File

@@ -25,6 +25,7 @@ export type AlertTrigger =
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
export type AlertRuleFormAction = export type AlertRuleFormAction =
@@ -77,6 +78,7 @@ export type AlertRuleApiPayload = {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
enabled: boolean; enabled: boolean;
allSites: boolean; allSites: boolean;
@@ -160,6 +162,7 @@ export function buildFormSchema(t: (k: string) => string) {
"health_check_toggle", "health_check_toggle",
"resource_healthy", "resource_healthy",
"resource_unhealthy", "resource_unhealthy",
"resource_degraded",
"resource_toggle" "resource_toggle"
]), ]),
actions: z.array( actions: z.array(
@@ -243,6 +246,7 @@ export function buildFormSchema(t: (k: string) => string) {
const resourceTriggers: AlertTrigger[] = [ const resourceTriggers: AlertTrigger[] = [
"resource_healthy", "resource_healthy",
"resource_unhealthy", "resource_unhealthy",
"resource_degraded",
"resource_toggle" "resource_toggle"
]; ];
if ( if (
@@ -344,7 +348,9 @@ export function alertRuleAllResourcesSelected(
eventType: string, eventType: string,
resourceIds: number[] | undefined resourceIds: number[] | undefined
): boolean { ): boolean {
return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0; return (
eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0
);
} }
export function alertRuleAllHealthChecksSelected( export function alertRuleAllHealthChecksSelected(

View File

@@ -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<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
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();
}