mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-29 23:41:31 +02:00
Compare commits
13 Commits
2a281ec002
...
230f77118a
| Author | SHA1 | Date | |
|---|---|---|---|
| 230f77118a | |||
| bcb5b7b4a7 | |||
| 90a2ed2f10 | |||
| fc69364feb | |||
| 245755a140 | |||
| dcbd22b4ad | |||
| 8481b0a073 | |||
| f651ca84fa | |||
| 6b83d3c3f1 | |||
| d463a578c2 | |||
| 9d0a8ecb09 | |||
| af5394d464 | |||
| c956e0d401 |
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user