Compare commits

...

13 Commits

Author SHA1 Message Date
Owen 230f77118a Also check when getting the cert 2026-04-22 21:11:52 -07:00
Owen bcb5b7b4a7 Show status in messages 2026-04-22 20:44:35 -07:00
Owen 90a2ed2f10 Create pending cert 2026-04-22 20:39:04 -07:00
Owen fc69364feb Show cert status 2026-04-22 20:36:00 -07:00
Owen 245755a140 Use transactions 2026-04-22 18:13:15 -07:00
Owen dcbd22b4ad Handle all of the alerting from the functions 2026-04-22 18:13:15 -07:00
miloschwartz 8481b0a073 dont filter admin role in role selector for alerts 2026-04-22 17:52:31 -07:00
miloschwartz f651ca84fa remove empty table state lines 2026-04-22 17:43:29 -07:00
miloschwartz 6b83d3c3f1 add meta titles to alert pages 2026-04-22 17:27:30 -07:00
Owen d463a578c2 Handle *. wildcard domains in the db 2026-04-22 17:06:22 -07:00
Owen 9d0a8ecb09 Update placeholder and handle wildcard certs 2026-04-22 16:48:51 -07:00
Owen af5394d464 Add more information about caches 2026-04-22 16:48:51 -07:00
miloschwartz c956e0d401 add meta titles to auth pages 2026-04-22 16:09:16 -07:00
47 changed files with 734 additions and 408 deletions
+1
View File
@@ -2908,6 +2908,7 @@
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
"privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen this tab, or your browser, then navigate back to this page.",
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
"editDomain": "Edit Domain",
+2 -1
View File
@@ -157,7 +157,8 @@ export const resources = pgTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: varchar("health") // "healthy", "unhealthy"
});
export const targets = pgTable("targets", {
+2 -1
View File
@@ -178,7 +178,8 @@ export const resources = sqliteTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: text("health") // "healthy", "unhealthy"
});
export const targets = sqliteTable("targets", {
+52 -17
View File
@@ -36,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: string;
previewText: string;
summary: string;
statusLabel: string;
statusColor: string;
statusLabel: string | null;
statusColor: string | null;
} {
switch (eventType) {
case "site_online":
@@ -63,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Status Changed",
previewText: "A site in your organization has changed status.",
summary: "A site in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
case "health_check_healthy":
return {
@@ -93,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): {
"A health check in your organization has changed status.",
summary:
"A health check in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
case "resource_healthy":
return {
@@ -120,8 +120,8 @@ function getEventMeta(eventType: AlertEventType): {
previewText:
"A resource in your organization has changed status.",
summary: "A resource in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
default:
return {
@@ -135,11 +135,26 @@ function getEventMeta(eventType: AlertEventType): {
}
}
function resolveToggleStatus(status: unknown): { label: string; color: string } {
switch (String(status).toLowerCase()) {
case "online":
return { label: "Online", color: "#16a34a" };
case "offline":
return { label: "Offline", color: "#dc2626" };
case "healthy":
return { label: "Healthy", color: "#16a34a" };
case "unhealthy":
return { label: "Unhealthy", color: "#dc2626" };
default:
return { label: String(status ?? "Unknown"), color: "#f59e0b" };
}
}
function formatDataItems(
data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] {
return Object.entries(data)
.filter(([key]) => key !== "orgId")
.filter(([key]) => key !== "orgId" && key !== "status")
.map(([key, value]) => ({
label: key
.replace(/([A-Z])/g, " $1")
@@ -154,16 +169,36 @@ export const AlertNotification = (props: AlertNotificationProps) => {
const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data);
const isToggle =
eventType === "site_toggle" ||
eventType === "health_check_toggle" ||
eventType === "resource_toggle";
const resolvedStatus = isToggle
? resolveToggleStatus(data.status)
: meta.statusLabel != null
? { label: meta.statusLabel, color: meta.statusColor! }
: null;
const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId },
{
label: "Status",
value: (
<span style={{ color: meta.statusColor, fontWeight: 600 }}>
{meta.statusLabel}
</span>
)
},
...(resolvedStatus != null
? [
{
label: "Status",
value: (
<span
style={{
color: resolvedStatus.color,
fontWeight: 600
}}
>
{resolvedStatus.label}
</span>
)
}
]
: []),
{ label: "Time", value: new Date().toUTCString() },
...dataItems
];
@@ -4,7 +4,9 @@ export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
@@ -13,7 +15,9 @@ export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
}
+7 -4
View File
@@ -2,19 +2,22 @@ export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}
export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}
export async function fireResourceToggleAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
): Promise<void> {}
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}
+5 -3
View File
@@ -4,7 +4,8 @@ export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
@@ -13,7 +14,8 @@ export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
}
+7 -2
View File
@@ -280,6 +280,7 @@ async function syncAcmeCerts(
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
const wildcard = domain.startsWith("*.");
if (!domain) {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
@@ -309,7 +310,12 @@ async function syncAcmeCerts(
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.where(
and(
eq(certificates.domain, domain),
eq(certificates.wildcard, wildcard)
)
)
.limit(1);
let oldCertPem: string | null = null;
@@ -364,7 +370,6 @@ async function syncAcmeCerts(
}
}
const wildcard = domain.startsWith("*.");
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
@@ -13,6 +13,19 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction
} from "@server/db";
import { eq } from "drizzle-orm";
import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +46,21 @@ export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
extra?: Record<string, unknown>
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: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await handleResource(orgId, healthCheckTargetId, trx);
await processAlerts({
eventType: "health_check_healthy",
orgId,
@@ -51,6 +76,7 @@ export async function fireHealthCheckHealthyAlert(
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
@@ -78,9 +104,21 @@ export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
extra?: Record<string, unknown>
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: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await handleResource(orgId, healthCheckTargetId, trx);
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
@@ -96,6 +134,7 @@ export async function fireHealthCheckUnhealthyAlert(
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
@@ -107,3 +146,67 @@ export async function fireHealthCheckUnhealthyAlert(
);
}
}
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
if (!healthCheckTargetId) {
return;
}
// we have resources lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
if (!allHealthy) {
logger.debug(
`Not marking resource ${resource.resourceId} as healthy because not all targets are healthy`
);
health = "unhealthy";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
}
}
}
@@ -13,6 +13,7 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, statusHistory, Transaction } from "@server/db";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +34,18 @@ export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_healthy",
orgId,
@@ -51,6 +61,7 @@ export async function fireResourceHealthyAlert(
resourceId,
data: {
resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
@@ -78,9 +89,18 @@ export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_unhealthy",
orgId,
@@ -96,6 +116,7 @@ export async function fireResourceUnhealthyAlert(
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
@@ -123,7 +144,8 @@ export async function fireResourceToggleAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await processAlerts({
+51 -2
View File
@@ -13,6 +13,9 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +36,18 @@ export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "site_online",
orgId,
@@ -51,6 +63,7 @@ export async function fireSiteOnlineAlert(
siteId,
data: {
siteId,
status: "online",
...(siteName != null ? { siteName } : {}),
...extra
}
@@ -78,9 +91,44 @@ export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId)
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
undefined,
undefined,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
@@ -96,6 +144,7 @@ export async function fireSiteOfflineAlert(
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}
@@ -42,6 +42,7 @@ export async function sendAlertWebhook(
const payload = {
event: context.eventType,
timestamp: new Date().toISOString(),
status: deriveStatus(context.eventType, context.data),
data: {
orgId: context.orgId,
...context.data
@@ -117,6 +118,38 @@ export async function sendAlertWebhook(
throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`);
}
// ---------------------------------------------------------------------------
// Status derivation
// ---------------------------------------------------------------------------
function deriveStatus(
eventType: AlertContext["eventType"],
data: Record<string, unknown>
): string {
switch (eventType) {
case "site_online":
return "online";
case "site_offline":
return "offline";
case "site_toggle":
return String(data.status ?? "unknown");
case "health_check_healthy":
case "resource_healthy":
return "healthy";
case "health_check_unhealthy":
case "resource_unhealthy":
return "unhealthy";
case "health_check_toggle":
case "resource_toggle":
return String(data.status ?? "unknown");
default: {
const _exhaustive: never = eventType;
void _exhaustive;
return "unknown";
}
}
}
// ---------------------------------------------------------------------------
// Header construction (mirrors HttpLogDestination.buildHeaders)
// ---------------------------------------------------------------------------
+20 -9
View File
@@ -18,8 +18,7 @@ import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import cache from "#private/lib/cache";
import { build } from "@server/build";
// Define the return type for clarity and type safety
export type CertificateResult = {
@@ -78,6 +77,9 @@ export async function getValidCertificatesForDomains(
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
@@ -91,10 +93,13 @@ export async function getValidCertificatesForDomains(
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, parentDomainsArray),
inArray(certificates.domain, [
...parentDomainsArray,
...wildcardPrefixedArray
]),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
@@ -103,13 +108,18 @@ export async function getValidCertificatesForDomains(
)
);
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
const normalizeWildcardDomain = (domain: string): string =>
domain.startsWith("*.") ? domain.slice(2) : domain;
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
wildcardMatches.set(cert.domain, cert);
// Normalize to bare parent domain so lookups are consistent regardless of storage format
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
} else {
exactMatches.set(cert.domain, cert);
}
@@ -122,14 +132,15 @@ export async function getValidCertificatesForDomains(
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate that matches the exact domain
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
else {
if (wildcardMatches.has(domain)) {
foundCert = wildcardMatches.get(domain);
const normalizedDomain = normalizeWildcardDomain(domain);
if (wildcardMatches.has(normalizedDomain)) {
foundCert = wildcardMatches.get(normalizedDomain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = domain.split(".");
const parts = normalizedDomain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
@@ -91,14 +91,6 @@ export async function triggerHealthCheckAlert(
);
}
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: healthCheckId,
orgId,
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "health_check_healthy") {
await fireHealthCheckHealthyAlert(
orgId,
@@ -89,16 +89,6 @@ export async function triggerResourceAlert(
);
}
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
await db.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId,
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
}
if (eventType === "resource_healthy") {
await fireResourceHealthyAlert(
orgId,
@@ -132,4 +122,4 @@ export async function triggerResourceAlert(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}
@@ -83,14 +83,6 @@ export async function triggerSiteAlert(
);
}
await db.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId,
status: eventType === "site_online" ? "online" : "offline",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "site_online") {
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
} else {
@@ -110,4 +102,4 @@ export async function triggerSiteAlert(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}
@@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger";
import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm";
import privateConfig from "#private/lib/config";
/**
* Checks if a certificate exists for the given domain.
@@ -27,10 +26,6 @@ export async function createCertificate(
domain: string,
trx: Transaction | typeof db
) {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
return;
}
const [domainRecord] = await trx
.select()
.from(domains)
@@ -41,8 +41,9 @@ async function query(domainId: string, domain: string) {
}
let existing: any[] = [];
if (domainRecord.type == "ns") {
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { // the manual "wildcard" domains can have wildcard certs
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await db
.select({
@@ -64,7 +65,8 @@ async function query(domainId: string, domain: string) {
eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or(
eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown)
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
);
@@ -1,5 +1,9 @@
import { MessageHandler } from "@server/routers/ws";
import { db, Newt, sites } from "@server/db";
import {
db,
Newt,
sites
} from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert } from "@server/lib/alerts";
@@ -25,15 +29,17 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
try {
// Update the client's last ping timestamp
const [site] = await db
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId))
.returning();
await db.transaction(async (trx) => {
const [site] = await trx
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId!))
.returning();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx);
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });
}
+47 -70
View File
@@ -1,8 +1,13 @@
import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
import {
hasActiveConnections,
} from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm";
db,
newts,
sites,
targetHealthCheck,
targets,
statusHistory
} from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
@@ -72,48 +77,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: staleSite.siteId,
orgId: staleSite.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
const healthChecksOnSite = await db
.select()
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targets.targetId, targetHealthCheck.targetId)
)
.innerJoin(sites, eq(sites.siteId, targets.siteId))
.where(eq(sites.siteId, staleSite.siteId));
for (const healthCheck of healthChecksOnSite) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
await fireSiteOfflineAlert(
staleSite.orgId,
staleSite.siteId,
staleSite.name,
undefined,
trx
);
await db
.update(targetHealthCheck)
.set({ hcHealth: "unknown" })
.where(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheck.targetHealthCheck
.targetHealthCheckId
)
);
// TODO: should we be firing an alert here when the health check goes to unknown?
}
await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name);
});
}
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
@@ -150,20 +127,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
} else if (
lastBandwidthUpdate >= wireguardOfflineThreshold &&
!site.online
@@ -172,20 +149,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
);
await db
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
await fireSiteOnlineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
}
}
} catch (error) {
+4 -9
View File
@@ -1,5 +1,5 @@
import { db } from "@server/db";
import { sites, clients, olms, statusHistory } from "@server/db";
import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
@@ -147,14 +147,9 @@ async function flushSitePingsToDb(): Promise<void> {
}, "flushSitePingsToDb");
for (const site of newlyOnlineSites) {
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
await db.transaction(async (trx) => {
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx);
});
}
} catch (error) {
logger.error(
@@ -31,6 +31,8 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -494,6 +496,10 @@ export async function createSiteResource(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
if (ssl && mode === "http" && domainId && fullDomain && build != "oss") {
await createCertificate(domainId, fullDomain, db);
}
return response(res, {
data: newSiteResource,
success: true,
@@ -14,10 +14,7 @@ import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts";
import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert
} from "#dynamic/lib/alerts";
interface TargetHealthStatus {
status: string;
@@ -94,26 +91,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
const [targetCheck] = await db
.select({
targetId: targets.targetId,
siteId: targets.siteId,
targetId: targetHealthCheck.targetId,
orgId: targetHealthCheck.orgId,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceOrgId: resources.orgId,
resourceId: resources.resourceId,
resourceName: resources.name,
name: targetHealthCheck.name,
hcHealth: targetHealthCheck.hcHealth
})
.from(targetHealthCheck)
.innerJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
.innerJoin(
targets,
eq(targetHealthCheck.targetId, targets.targetId)
)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
)
.where(
and(
eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
@@ -138,104 +122,40 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue;
}
// Update the target's health status in the database
await db
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetId, targetCheck.targetId));
// Update the target's health status in the database and fire alert in a transaction
await db.transaction(async (trx) => {
await trx
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId));
const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
if (!orgId) {
logger.warn(
`No org ID found for target ${targetId}, skipping status history logging`
);
continue;
}
// Log the state change to status history
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: targetCheck.targetHealthCheckId,
orgId: orgId,
status: healthStatus.status,
timestamp: Math.floor(Date.now() / 1000)
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
trx
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckHealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
trx
);
}
});
if (targetCheck.resourceId) {
// Log the state change to status history for the resource as well
// so we can show the resource status along with the site
// if the status is healthy we should check if ALL of the targets on the resource are currently healthy and if not then dont mark the resource as healthy yet, we want to wait until all targets are healthy to mark the resource as healthy
let status = healthStatus.status;
if (healthStatus.status === "healthy") {
const otherTargets = await db
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(
and(
eq(targets.resourceId, targetCheck.resourceId),
ne(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated
)
);
const allHealthy = otherTargets.every(
(t) => t.hcHealth === "healthy"
);
if (!allHealthy) {
logger.debug(
`Not marking resource ${targetCheck.resourceId} as healthy because not all targets are healthy`
);
status = "unhealthy";
}
}
await db.insert(statusHistory).values({
entityType: "resource",
entityId: targetCheck.resourceId,
orgId: orgId,
status: status,
timestamp: Math.floor(Date.now() / 1000)
});
if (status === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
} else if (status === "healthy") {
await fireResourceHealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
}
}
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckHealthyAlert(
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined
);
}
logger.debug(
`Updated health status for target ${targetId} to ${healthStatus.status}`
);
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Edit Alert"
};
export default function EditAlertRuleLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Create Alert"
};
export default function CreateAlertRuleLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Set Up 2FA"
};
export default function TwoFactorSetupLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+5
View File
@@ -5,6 +5,11 @@ import { cache } from "react";
import DeleteAccountClient from "./DeleteAccountClient";
import { getTranslations } from "next-intl/server";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Delete Account"
};
export const dynamic = "force-dynamic";
@@ -8,6 +8,11 @@ import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Complete Login"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Initial Setup"
};
export default async function Layout(props: { children: React.ReactNode }) {
const setupRes = await internal.get<
+4 -1
View File
@@ -10,7 +10,10 @@ import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
title: {
template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
default: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`
},
description: ""
};
+5
View File
@@ -4,6 +4,11 @@ import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authorize Device"
};
export const dynamic = "force-dynamic";
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Device Authorized"
};
export default function DeviceAuthSuccessLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+5
View File
@@ -17,6 +17,11 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Log In"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -12,6 +12,11 @@ import {
import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organization Login"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -18,6 +18,11 @@ import ValidateSessionTransferToken from "@app/components/ValidateSessionTransfe
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Choose Organization"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -7,6 +7,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { getTranslations } from "next-intl/server";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Reset Password"
};
export const dynamic = "force-dynamic";
@@ -27,6 +27,11 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Resource Access"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -7,6 +7,11 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Create Account"
};
export const dynamic = "force-dynamic";
+5
View File
@@ -4,6 +4,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Verify Email"
};
export const dynamic = "force-dynamic";
+11 -1
View File
@@ -18,6 +18,7 @@ export default async function MaintenanceScreen() {
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
let steps = t("privateMaintenanceScreenSteps");
return (
<div className="min-h-screen flex items-center justify-center p-4">
@@ -25,7 +26,16 @@ export default async function MaintenanceScreen() {
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{message}</CardContent>
<CardContent className="space-y-4">
<p>{message}</p>
<p className="text-sm text-muted-foreground">{steps}</p>
<a
href="https://docs.pangolin.net/manage/dns-cache"
className="text-sm text-primary hover:underline"
>
{t("learnMore")}
</a>
</CardContent>
</Card>
</div>
);
+42 -21
View File
@@ -54,6 +54,7 @@ import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus";
// --- Helpers (shared) ---
@@ -1072,28 +1073,48 @@ export function InternalResourceForm({
}}
/>
</div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={httpSectionDisabled}
<div className="flex items-start justify-between gap-4">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={httpSectionDisabled}
/>
</FormControl>
</FormItem>
)}
/>
{variant === "edit" &&
resource?.domainId &&
httpConfigFullDomain &&
form.watch("ssl") && (
<div className="flex items-center gap-1 pt-1">
<span className="text-sm font-medium text-muted-foreground">
{t("certificateStatus")}:
</span>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={httpConfigFullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
</div>
</div>
) : (
<div className="space-y-4">
+6 -4
View File
@@ -30,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
cols={resource.http ? 5 : 4}
>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
@@ -43,7 +43,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} />
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -133,8 +136,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* Certificate Status Column */}
{resource.http &&
resource.domainId &&
resource.fullDomain &&
env.flags.usePangolinDns && (
resource.fullDomain && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
+1 -4
View File
@@ -97,10 +97,7 @@ export default function UptimeAlertSection({
);
const allRoles = useMemo(
() =>
orgRoles
.map((r) => ({ id: String(r.roleId), text: r.name }))
.filter((r) => r.text !== "Admin"),
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
[orgRoles]
);
@@ -30,10 +30,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
RadioGroup,
RadioGroupItem
} from "@app/components/ui/radio-group";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
@@ -59,7 +56,6 @@ export function AddActionPanel({
}) {
const t = useTranslations();
const EXTERNAL_INTEGRATIONS = [
{
id: "pagerduty",
@@ -247,9 +243,7 @@ function HealthCheckMultiSelect({
const shown = useMemo(() => {
const query = debounced.trim().toLowerCase();
const base = query
? healthChecks.filter((hc) =>
hc.name.toLowerCase().includes(query)
)
? healthChecks.filter((hc) => hc.name.toLowerCase().includes(query))
: healthChecks;
// Always keep already-selected items visible even if they fall outside the search
if (query && value.length > 0) {
@@ -323,9 +317,7 @@ function HealthCheckMultiSelect({
aria-hidden
tabIndex={-1}
/>
<span className="truncate">
{hc.name}
</span>
<span className="truncate">{hc.name}</span>
</CommandItem>
))}
</CommandGroup>
@@ -510,8 +502,12 @@ function NotifyActionFields({
number | null
>(null);
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId }));
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
orgQueries.users({ orgId })
);
const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(
orgQueries.roles({ orgId })
);
const allUsers = useMemo(
() =>
@@ -527,10 +523,7 @@ function NotifyActionFields({
);
const allRoles = useMemo(
() =>
orgRoles
.map((r) => ({ id: String(r.roleId), text: r.name }))
.filter((r) => r.text !== "Admin"),
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
[orgRoles]
);
@@ -578,9 +571,18 @@ function NotifyActionFields({
hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[];
const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[];
const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[];
const userTags = (useWatch({
control,
name: `actions.${index}.userTags`
}) ?? []) as Tag[];
const roleTags = (useWatch({
control,
name: `actions.${index}.roleTags`
}) ?? []) as Tag[];
const emailTags = (useWatch({
control,
name: `actions.${index}.emailTags`
}) ?? []) as Tag[];
return (
<div className="space-y-3 pt-1">
@@ -788,7 +790,9 @@ function WebhookActionFields({
{t("httpDestAuthNoneTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthNoneDescription")}
{t(
"httpDestAuthNoneDescription"
)}
</p>
</div>
</div>
@@ -806,10 +810,14 @@ function WebhookActionFields({
htmlFor={`auth-bearer-${index}`}
className="cursor-pointer font-medium"
>
{t("httpDestAuthBearerTitle")}
{t(
"httpDestAuthBearerTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBearerDescription")}
{t(
"httpDestAuthBearerDescription"
)}
</p>
</div>
{field.value === "bearer" && (
@@ -821,7 +829,9 @@ function WebhookActionFields({
<FormControl>
<Input
{...f}
placeholder={t("httpDestAuthBearerPlaceholder")}
placeholder={t(
"httpDestAuthBearerPlaceholder"
)}
/>
</FormControl>
<FormMessage />
@@ -845,10 +855,14 @@ function WebhookActionFields({
htmlFor={`auth-basic-${index}`}
className="cursor-pointer font-medium"
>
{t("httpDestAuthBasicTitle")}
{t(
"httpDestAuthBasicTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBasicDescription")}
{t(
"httpDestAuthBasicDescription"
)}
</p>
</div>
{field.value === "basic" && (
@@ -860,7 +874,9 @@ function WebhookActionFields({
<FormControl>
<Input
{...f}
placeholder={t("httpDestAuthBasicPlaceholder")}
placeholder={t(
"httpDestAuthBasicPlaceholder"
)}
/>
</FormControl>
<FormMessage />
@@ -884,10 +900,14 @@ function WebhookActionFields({
htmlFor={`auth-custom-${index}`}
className="cursor-pointer font-medium"
>
{t("httpDestAuthCustomTitle")}
{t(
"httpDestAuthCustomTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthCustomDescription")}
{t(
"httpDestAuthCustomDescription"
)}
</p>
</div>
{field.value === "custom" && (
@@ -895,12 +915,16 @@ function WebhookActionFields({
<FormField
control={control}
name={`actions.${index}.customHeaderName`}
render={({ field: f }) => (
render={({
field: f
}) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...f}
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
@@ -910,12 +934,16 @@ function WebhookActionFields({
<FormField
control={control}
name={`actions.${index}.customHeaderValue`}
render={({ field: f }) => (
render={({
field: f
}) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...f}
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderValuePlaceholder"
)}
/>
</FormControl>
<FormMessage />
@@ -949,7 +977,7 @@ function WebhookHeadersField({
}) {
const t = useTranslations();
const headers =
(useWatch({ control, name: `actions.${index}.headers` as const }) ?? []);
useWatch({ control, name: `actions.${index}.headers` as const }) ?? [];
return (
<div className="space-y-2">
<FormLabel>{t("alertingWebhookHeaders")}</FormLabel>
@@ -961,7 +989,12 @@ function WebhookHeadersField({
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder={t("webhookHeaderKeyPlaceholder")} />
<Input
{...field}
placeholder={t(
"webhookHeaderKeyPlaceholder"
)}
/>
</FormControl>
</FormItem>
)}
@@ -972,7 +1005,12 @@ function WebhookHeadersField({
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder={t("webhookHeaderValuePlaceholder")} />
<Input
{...field}
placeholder={t(
"webhookHeaderValuePlaceholder"
)}
/>
</FormControl>
</FormItem>
)}
@@ -984,9 +1022,8 @@ function WebhookHeadersField({
className="shrink-0"
onClick={() => {
const cur =
form.getValues(
`actions.${index}.headers`
) ?? [];
form.getValues(`actions.${index}.headers`) ??
[];
form.setValue(
`actions.${index}.headers`,
cur.filter((__, i) => i !== hi),
@@ -1005,10 +1042,11 @@ function WebhookHeadersField({
onClick={() => {
const cur =
form.getValues(`actions.${index}.headers`) ?? [];
form.setValue(`actions.${index}.headers`, [
...cur,
{ key: "", value: "" }
], { shouldDirty: true });
form.setValue(
`actions.${index}.headers`,
[...cur, { key: "", value: "" }],
{ shouldDirty: true }
);
}}
>
<Plus className="h-4 w-4 mr-1" />
@@ -1111,22 +1149,18 @@ export function AlertRuleSourceFields({
curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_toggle"
) {
setValue(
"trigger",
"resource_toggle",
{ shouldValidate: true }
);
setValue("trigger", "resource_toggle", {
shouldValidate: true
});
}
} else if (
curTrigger !== "health_check_healthy" &&
curTrigger !== "health_check_unhealthy" &&
curTrigger !== "health_check_toggle"
) {
setValue(
"trigger",
"health_check_toggle",
{ shouldValidate: true }
);
setValue("trigger", "health_check_toggle", {
shouldValidate: true
});
}
}}
>
@@ -77,11 +77,11 @@ function VerticalRuleStep({
className="flex flex-col items-center gap-0 shrink-0 w-8"
aria-hidden
>
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-border bg-background text-sm font-semibold text-muted-foreground">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background text-sm text-muted-foreground">
{stepNumber}
</div>
{!isLast && (
<div className="w-px flex-1 min-h-8 my-1 border-l-2 border-dashed border-border" />
<div className="w-px flex-1 min-h-8 my-1 border-l border-dashed border-border" />
)}
</div>
<div
@@ -178,7 +178,7 @@ export default function AlertRuleGraphEditor({
>
<div className="flex flex-wrap items-center gap-2">
{isNew && (
<Badge variant="secondary" >
<Badge variant="secondary">
{t("alertingDraftBadge")}
</Badge>
)}
@@ -209,7 +209,9 @@ export default function AlertRuleGraphEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingRuleCooldown")}
{t(
"alertingRuleCooldown"
)}
</FormLabel>
<FormControl>
<Input
@@ -229,7 +231,9 @@ export default function AlertRuleGraphEditor({
/>
</FormControl>
<FormDescription>
{t("alertingRuleCooldownDescription")}
{t(
"alertingRuleCooldownDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
+38 -32
View File
@@ -256,31 +256,39 @@ export function ControlledDataTable<TData, TValue>({
addButtonText && ((addActions && addActions.length > 0) || onAdd)
);
const showAddActionInEmptyState = !hasRows && hasAddAction;
const addAction = addActions && addActions.length > 0 && addButtonText ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={addButtonDisabled || isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem key={i} onSelect={() => action.onSelect()}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : onAdd && addButtonText ? (
<Button onClick={onAdd} loading={isNavigatingToAddPage} disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
) : null;
const addAction =
addActions && addActions.length > 0 && addButtonText ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={addButtonDisabled || isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() => action.onSelect()}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : onAdd && addButtonText ? (
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
) : null;
return (
<div className="container mx-auto max-w-12xl">
@@ -606,13 +614,11 @@ export function ControlledDataTable<TData, TValue>({
<DataTableEmptyState
colSpan={columns.length}
action={
showAddActionInEmptyState
? (
<div className="hidden sm:block">
{addAction}
</div>
)
: undefined
showAddActionInEmptyState ? (
<div className="hidden sm:block">
{addAction}
</div>
) : undefined
}
/>
)}
+1 -4
View File
@@ -26,10 +26,7 @@ export function DataTableEmptyState({
>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
(_, i) => (
<div
key={i}
className="h-10 shrink-0 border-b border-border/30"
/>
<div key={i} className="h-10 shrink-0" />
)
)}
</div>