Compare commits

..

28 Commits

Author SHA1 Message Date
Owen f03d0cd47f Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-04-23 13:37:44 -07:00
Owen Schwartz 925a59c080 Merge pull request #2873 from sidd190/fix/crowdsec-traefik-logrotate
(fix): Added a logrotate function to the crowdsec.go installer file
2026-04-23 12:18:00 -07:00
Owen a7c7319407 Deprecated sites should be optional 2026-04-23 12:09:22 -07:00
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
miloschwartz 2a281ec002 update telemetry 2026-04-22 15:06:37 -07:00
miloschwartz 4c000c1d49 add site online indicator to selector 2026-04-22 14:33:28 -07:00
miloschwartz ea4ff75552 cosmetic adjustments 2026-04-22 14:25:06 -07:00
Owen c78b866087 Add translations 2026-04-22 14:04:21 -07:00
miloschwartz 48b6e98bbc visual improvements 2026-04-22 12:25:01 -07:00
Owen 3d5260b13e Fix strings and local sites 2026-04-22 12:23:59 -07:00
miloschwartz d0b0d95b9a fix squished alert card when disabled 2026-04-22 12:16:39 -07:00
miloschwartz c2c8b7a631 disable overflow on header row for tables 2026-04-22 12:08:57 -07:00
Owen 9bc11b8717 Merge branch 'main' into dev 2026-04-22 11:38:14 -07:00
miloschwartz 1d53211fe0 fix logo size 2026-04-21 23:16:06 -07:00
Siddharth Bansal 473bce856d Pass installdir as a parameter 2026-04-20 21:36:42 +05:30
Siddharth Bansal 2c8b7b5ca5 (fix): Added a logrotate function to the crowdsec.go installer file 2026-04-19 12:33:59 +05:30
69 changed files with 1073 additions and 543 deletions
+70 -1
View File
@@ -6,12 +6,13 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func installCrowdsec(config Config) error {
func installCrowdsec(config Config, installDir string) error {
if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err)
@@ -40,6 +41,8 @@ func installCrowdsec(config Config) error {
os.Exit(1)
}
setupTraefikLogRotate(installDir)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err)
os.Exit(1)
@@ -208,3 +211,69 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik")
return nil
}
// setupTraefikLogRotate writes a logrotate config for the Traefik access log
// that CrowdSec depends on. This is only needed when CrowdSec is installed
// because the default Pangolin install does not enable Traefik access logs.
//
// copytruncate is used so Traefik does not need to be restarted or sent a
// signal after rotation — it keeps writing to the same file descriptor while
// the rotated copy is made and the original is truncated in place.
func setupTraefikLogRotate(installDir string) {
const logrotateDir = "/etc/logrotate.d"
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
logPath := filepath.Join(installDir, "config/traefik/logs/access.log")
if os.Geteuid() != 0 {
fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.")
fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,")
fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:")
printLogrotateConfig(logPath)
return
}
config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec.
# Generated by the Pangolin installer. Safe to edit.
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
if err := os.MkdirAll(logrotateDir, 0755); err != nil {
fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err)
return
}
if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil {
fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err)
fmt.Println("[logrotate] Set it up manually:")
printLogrotateConfig(logPath)
return
}
fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile)
fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.")
}
// printLogrotateConfig prints a logrotate config block to stdout so users can
// set it up manually when the installer cannot write to /etc.
func printLogrotateConfig(logPath string) {
fmt.Printf(`
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
}
+1 -1
View File
@@ -259,7 +259,7 @@ func main() {
}
config.DoCrowdsecInstall = true
err := installCrowdsec(config)
err := installCrowdsec(config, installDir)
if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err)
return
+32 -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",
@@ -3142,5 +3143,35 @@
"idpDeleteAllOrgsMenu": "Delete",
"publicIpEndpoint": "Endpoint",
"lastTriggeredAt": "Last Trigger",
"reject": "Reject"
"reject": "Reject",
"uptimeDaysAgo": "{count} days ago",
"uptimeToday": "Today",
"uptimeNoDataAvailable": "No data available",
"uptimeSuffix": "uptime",
"uptimeDowntimeSuffix": "downtime",
"uptimeTooltipUptimeLabel": "Uptime",
"uptimeTooltipDowntimeLabel": "Downtime",
"uptimeOngoing": "ongoing",
"uptimeNoMonitoringData": "No monitoring data",
"uptimeNoData": "No data",
"uptimeMiniBarDown": "Down",
"uptimeSectionTitle": "Uptime",
"uptimeSectionDescription": "Site availability over the last {days} days.",
"uptimeAddAlert": "Add Alert",
"uptimeViewAlerts": "View Alerts",
"uptimeCreateEmailAlert": "Create Email Alert",
"uptimeAlertDescriptionSite": "Get notified by email when this site goes offline or comes back online.",
"uptimeAlertDescriptionResource": "Get notified by email when this resource goes offline or comes back online.",
"uptimeAlertNamePlaceholder": "Alert name",
"uptimeAdditionalEmails": "Additional Emails",
"uptimeCreateAlert": "Create Alert",
"uptimeAlertNoRecipients": "No recipients",
"uptimeAlertNoRecipientsDescription": "Please add at least one user, role, or email to notify.",
"uptimeAlertCreated": "Alert created",
"uptimeAlertCreatedDescription": "You will be notified when this changes status.",
"uptimeAlertCreateFailed": "Failed to create alert",
"webhookUrlLabel": "URL",
"webhookHeaderKeyPlaceholder": "Key",
"webhookHeaderValuePlaceholder": "Value",
"alertLabel": "Alert"
}
+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;
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
site: z.string(), // DEPRECATED IN FAVOR OF sites
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
+21 -3
View File
@@ -2,7 +2,7 @@ import { PostHog } from "posthog-node";
import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import { apiKeys, db, roles, siteResources } from "@server/db";
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts";
@@ -15,6 +15,7 @@ class TelemetryClient {
private client: PostHog | null = null;
private enabled: boolean;
private intervalId: NodeJS.Timeout | null = null;
private collectionIntervalDays = 14;
constructor() {
const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
@@ -33,7 +34,7 @@ class TelemetryClient {
this.client = new PostHog(
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
{
host: "https://pangolin.net/relay-O7yI"
host: "https://telemetry.fossorial.io/relay-O7yI"
}
);
@@ -72,7 +73,7 @@ class TelemetryClient {
logger.debug("Successfully sent analytics data");
});
},
336 * 60 * 60 * 1000
this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds
);
this.collectAndSendAnalytics().catch((err) => {
@@ -157,6 +158,14 @@ class TelemetryClient {
})
.from(sites);
const [numAlertRules] = await db
.select({ count: count() })
.from(alertRules);
const [blueprintsCount] = await db
.select({ count: count() })
.from(blueprints);
const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
@@ -165,11 +174,14 @@ class TelemetryClient {
let numPrivResourceAliases = 0;
let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
} else if (res.mode === "cidr") {
numPrivResourceCidr += 1;
} else if (res.mode === "http") {
numPrivResourceHttp += 1;
}
if (res.alias) {
@@ -187,6 +199,9 @@ class TelemetryClient {
numPrivateResources: numPrivResources,
numPrivateResourceAliases: numPrivResourceAliases,
numPrivateResourceHosts: numPrivResourceHosts,
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
numIdentityProviders: idpCount.count,
@@ -197,6 +212,7 @@ class TelemetryClient {
appVersion: APP_VERSION,
numApiKeys: numApiKeys.count,
numCustomRoles: customRoles.count,
numBlueprints: blueprintsCount.count,
supporterStatus: {
valid: supporterKey?.valid || false,
tier: supporterKey?.tier || "None",
@@ -285,10 +301,12 @@ class TelemetryClient {
num_private_resource_aliases:
stats.numPrivateResourceAliases,
num_private_resource_hosts: stats.numPrivateResourceHosts,
num_private_resource_cidr: stats.numPrivateResourceCidr,
num_user_devices: stats.numUserDevices,
num_machine_clients: stats.numMachineClients,
num_identity_providers: stats.numIdentityProviders,
num_sites_online: stats.numSitesOnline,
num_blueprint_runs: stats.numBlueprints,
num_resources_sso_enabled: stats.resources.filter(
(r) => r.sso
).length,
+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
}
+93 -33
View File
@@ -15,6 +15,8 @@ import logger from "@server/logger";
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500;
/**
* Sends a single webhook POST for an alert event.
@@ -40,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
@@ -49,45 +52,102 @@ export async function sendAlertWebhook(
const body = JSON.stringify(payload);
const headers = buildHeaders(webhookConfig);
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let lastError: Error | undefined;
let response: Response;
try {
response = await fetch(url, {
method: webhookConfig.method ?? "POST",
headers,
body,
signal: controller.signal
});
} catch (err: unknown) {
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
throw new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Alert webhook: request to "${url}" failed ${msg}`);
} finally {
clearTimeout(timeoutHandle);
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
if (!response.ok) {
let snippet = "";
let response: Response;
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
response = await fetch(url, {
method: webhookConfig.method ?? "POST",
headers,
body,
signal: controller.signal
});
} catch (err: unknown) {
clearTimeout(timeoutHandle);
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
lastError = new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
} else {
const msg = err instanceof Error ? err.message : String(err);
lastError = new Error(`Alert webhook: request to "${url}" failed ${msg}`);
}
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
} finally {
clearTimeout(timeoutHandle);
}
throw new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
);
if (!response.ok) {
let snippet = "";
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
}
lastError = new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
);
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
}
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`);
return;
}
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`);
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";
}
}
}
// ---------------------------------------------------------------------------
+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}</>;
}
@@ -161,6 +161,7 @@ function MaintenanceSectionForm({
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<SettingsSectionForm>
<Form {...maintenanceForm}>
<form
@@ -168,9 +169,6 @@ function MaintenanceSectionForm({
className="space-y-4"
id="maintenance-settings-form"
>
<PaidFeaturesAlert
tiers={tierMatrix.maintencePage}
/>
<FormField
control={maintenanceForm.control}
name="maintenanceModeEnabled"
@@ -113,7 +113,7 @@ export default function GeneralPage() {
return (
<SettingsContainer>
{site?.siteId && site?.orgId && (
{site?.siteId && site?.orgId && site.type != "local" && (
<UptimeAlertSection
orgId={site.orgId}
siteId={site.siteId}
+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";
+5 -5
View File
@@ -41,11 +41,11 @@
}
.dark {
--background: #0d0d0f;
--background: #141415;
--foreground: oklch(0.985 0 0);
--card: #0d0d0f;
--card: #141415;
--card-foreground: oklch(0.985 0 0);
--popover: #0d0d0f;
--popover: #141415;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
@@ -65,11 +65,11 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #040404;
--sidebar: #0C0C0D;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: #131317;
--sidebar-accent: #171717;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
+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>
);
-5
View File
@@ -57,11 +57,6 @@ export default function BrandingLogo(props: BrandingLogoProps) {
alt="Logo"
width={props.width}
height={props.height}
style={
isNextImage
? { width: "auto", height: "auto" }
: undefined
}
/>
)
);
+7 -4
View File
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
className
)}
{...props}
@@ -122,7 +122,10 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
return (
<CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
<CredenzaHeader
className={cn("shrink-0 -mx-6 px-6", className)}
{...props}
>
{children}
</CredenzaHeader>
);
@@ -151,7 +154,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
return (
<div
className={cn(
"px-0 mb-4 space-y-4 overflow-x-hidden min-w-0",
"min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden px-0",
className
)}
{...props}
@@ -169,7 +172,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 md:-mb-4 px-6 py-4 border-t border-border gap-2 md:gap-0",
"mt-8 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:mt-0 md:-mb-4 md:gap-0",
className
)}
{...props}
+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">
+2 -2
View File
@@ -165,7 +165,7 @@ export function LayoutSidebar({
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 rounded-md",
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
@@ -216,7 +216,7 @@ export function LayoutSidebar({
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors"
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
+2 -2
View File
@@ -76,8 +76,8 @@ export function OrgSelector({
className={cn(
"cursor-pointer transition-colors",
isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
: "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
: "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
)}
>
{isCollapsed ? (
+5 -4
View File
@@ -14,6 +14,7 @@ import { Input } from "./ui/input";
import { Button } from "./ui/button";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
@@ -88,7 +89,7 @@ export function PathMatchModal({
{t("pathMatchModalDescription")}
</CredenzaDescription>
</CredenzaHeader>
<div className="grid gap-4">
<CredenzaBody className="grid gap-4 space-y-0">
<div className="grid gap-2">
<Label htmlFor="match-type">{t("pathMatchType")}</Label>
<Select value={matchType} onValueChange={setMatchType}>
@@ -122,7 +123,7 @@ export function PathMatchModal({
{getHelpText()}
</p>
</div>
</div>
</CredenzaBody>
<CredenzaFooter className="gap-2">
{/* {value?.path && (
)} */}
@@ -215,7 +216,7 @@ export function PathRewriteModal({
{t("pathRewriteModalDescription")}
</CredenzaDescription>
</CredenzaHeader>
<div className="grid gap-4">
<CredenzaBody className="grid gap-4 space-y-0">
<div className="grid gap-2">
<Label htmlFor="rewrite-type">
{t("pathRewriteType")}
@@ -257,7 +258,7 @@ export function PathRewriteModal({
{getHelpText()}
</p>
</div>
</div>
</CredenzaBody>
<CredenzaFooter className="gap-2">
{value?.rewritePath && (
<Button variant="outline" onClick={handleClear}>
+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", {
+3 -3
View File
@@ -122,7 +122,7 @@ function CollapsibleNavItem({
"px-3 py-1.5",
isActive
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
@@ -257,7 +257,7 @@ function CollapsedNavItemWithPopover({
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
@@ -451,7 +451,7 @@ export function SidebarNav({
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
: "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
onClick={(e) => {
+4 -1
View File
@@ -46,7 +46,7 @@ export type SiteRow = {
mbIn: string;
mbOut: string;
orgId: string;
type: "newt" | "wireguard";
type: "newt" | "wireguard" | "local";
newtVersion?: string;
newtUpdateAvailable?: boolean;
online: boolean;
@@ -236,6 +236,9 @@ export default function SitesTable({
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type == "local") {
return <span>-</span>;
}
return (
<UptimeMiniBar siteId={originalRow.id} days={30} />
);
+1 -1
View File
@@ -230,7 +230,7 @@ export default function SupporterStatus({
<p className="text-sm">
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
<Link
href="https://pangolin.net/pricing?hosting=self-host"
href="https://pangolin.net/pricing#Self-Hosted"
target="_blank"
rel="noopener noreferrer"
className="underline inline-flex items-center gap-1"
+1 -1
View File
@@ -45,6 +45,7 @@ export function SwitchInput({
return (
<div>
<div className="flex items-center space-x-2 mb-2">
{label && <Label htmlFor={id}>{label}</Label>}
<Switch
id={id}
checked={checked}
@@ -52,7 +53,6 @@ export function SwitchInput({
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
{label && <Label htmlFor={id}>{label}</Label>}
{info && (
<Popover>
<PopoverTrigger asChild>
+40 -35
View File
@@ -34,6 +34,7 @@ import { orgQueries } from "@app/lib/queries";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
interface UptimeAlertSectionProps {
orgId: string;
@@ -50,6 +51,7 @@ export default function UptimeAlertSection({
resourceId,
days = 90
}: UptimeAlertSectionProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const { isPaidUser } = usePaidStatus();
@@ -57,7 +59,7 @@ export default function UptimeAlertSection({
const [open, setOpen] = useState(false);
const [name, setName] = useState(
`${siteId ? "Site" : "Resource"} ${startingName} Alert`
`${siteId ? t("site") : t("resource")} ${startingName} ${t("alertLabel")}`
);
const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]);
@@ -73,9 +75,10 @@ export default function UptimeAlertSection({
>(null);
const [loading, setLoading] = useState(false);
const { data: alertRules, isLoading: alertRulesLoading } = useQuery(
orgQueries.alertRulesForSource({ orgId, siteId, resourceId })
);
const { data: alertRules, isLoading: alertRulesLoading } = useQuery({
...orgQueries.alertRulesForSource({ orgId, siteId, resourceId }),
enabled: isPaid
});
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
@@ -94,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]
);
@@ -111,9 +111,8 @@ export default function UptimeAlertSection({
) {
toast({
variant: "destructive",
title: "No recipients",
description:
"Please add at least one user, role, or email to notify."
title: t("uptimeAlertNoRecipients"),
description: t("uptimeAlertNoRecipientsDescription")
});
return;
}
@@ -135,12 +134,12 @@ export default function UptimeAlertSection({
});
toast({
title: "Alert created",
description: "You will be notified when this changes status."
title: t("uptimeAlertCreated"),
description: t("uptimeAlertCreatedDescription")
});
setOpen(false);
setName("Uptime Alert");
setName(t("uptimeSectionTitle"));
setUserTags([]);
setRoleTags([]);
setEmailTags([]);
@@ -155,8 +154,8 @@ export default function UptimeAlertSection({
} catch (e) {
toast({
variant: "destructive",
title: "Failed to create alert",
description: formatAxiosError(e, "An error occurred.")
title: t("uptimeAlertCreateFailed"),
description: formatAxiosError(e, t("errorOccurred"))
});
}
setLoading(false);
@@ -173,19 +172,19 @@ export default function UptimeAlertSection({
const alertButton = alertRulesLoading ? (
<Button variant="outline" type="button" loading aria-busy="true">
<BellPlus className="size-4 mr-2" />
Add Alert
{t("uptimeAddAlert")}
</Button>
) : hasRules ? (
<Button variant="outline" asChild>
<Link href={rulesListHref}>
<BellRing className="size-4 mr-2" />
View Alerts
{t("uptimeViewAlerts")}
</Link>
</Button>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<BellPlus className="size-4 mr-2" />
Add Alert
{t("uptimeAddAlert")}
</Button>
);
@@ -195,9 +194,11 @@ export default function UptimeAlertSection({
<SettingsSectionHeader>
<div className="flex justify-between items-start">
<div>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionTitle>
{t("uptimeSectionTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last {days} days.
{t("uptimeSectionDescription", { days })}
</SettingsSectionDescription>
</div>
{alertButton}
@@ -215,11 +216,13 @@ export default function UptimeAlertSection({
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Email Alert</CredenzaTitle>
<CredenzaTitle>
{t("uptimeCreateEmailAlert")}
</CredenzaTitle>
<CredenzaDescription>
Get notified by email when this{" "}
{siteId ? "site" : "resource"} goes offline or comes
back online.
{siteId
? t("uptimeAlertDescriptionSite")
: t("uptimeAlertDescriptionResource")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -231,20 +234,22 @@ export default function UptimeAlertSection({
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="alert-name">Name</Label>
<Label htmlFor="alert-name">
{t("name")}
</Label>
<Input
id="alert-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Alert name"
placeholder={t("uptimeAlertNamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label>Notify Users</Label>
<Label>{t("alertingNotifyUsers")}</Label>
<TagInput
activeTagIndex={activeUserTagIndex}
setActiveTagIndex={setActiveUserTagIndex}
placeholder="Select users..."
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
@@ -262,11 +267,11 @@ export default function UptimeAlertSection({
/>
</div>
<div className="space-y-2">
<Label>Notify Roles</Label>
<Label>{t("alertingNotifyRoles")}</Label>
<TagInput
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
placeholder="Select roles..."
placeholder={t("alertingSelectRoles")}
size="sm"
tags={roleTags}
setTags={(newTags) => {
@@ -284,11 +289,11 @@ export default function UptimeAlertSection({
/>
</div>
<div className="space-y-2">
<Label>Additional Emails</Label>
<Label>{t("uptimeAdditionalEmails")}</Label>
<TagInput
activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex}
placeholder="Enter email addresses..."
placeholder={t("alertingEmailPlaceholder")}
size="sm"
tags={emailTags}
setTags={(newTags) => {
@@ -312,14 +317,14 @@ export default function UptimeAlertSection({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={handleSubmit}
loading={loading}
disabled={loading || !isPaid}
>
Create Alert
{t("uptimeCreateAlert")}
</Button>
</CredenzaFooter>
</CredenzaContent>
+15 -13
View File
@@ -10,6 +10,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
function formatDuration(seconds: number): string {
if (seconds === 0) return "0s";
@@ -63,6 +64,7 @@ export default function UptimeBar({
title,
className
}: UptimeBarProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const siteQuery = useQuery({
@@ -104,7 +106,7 @@ export default function UptimeBar({
<div
className="flex items-center gap-4 text-sm ml-auto"
aria-busy="true"
aria-label="Loading uptime summary"
aria-label={t("loading")}
>
<span className="h-4 w-[4.5rem] shrink-0 rounded-md bg-muted animate-pulse" />
<span className="h-4 w-[7rem] shrink-0 rounded-md bg-muted animate-pulse" />
@@ -122,8 +124,8 @@ export default function UptimeBar({
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{days} days ago</span>
<span>Today</span>
<span>{t("uptimeDaysAgo", { count: days })}</span>
<span>{t("uptimeToday")}</span>
</div>
</div>
);
@@ -145,7 +147,7 @@ export default function UptimeBar({
<span className="font-semibold text-foreground">
{data.overallUptimePercent.toFixed(2)}%
</span>{" "}
uptime
{t("uptimeSuffix")}
</span>
{data.totalDowntimeSeconds > 0 && (
<span className="text-muted-foreground">
@@ -154,14 +156,14 @@ export default function UptimeBar({
data.totalDowntimeSeconds
)}
</span>{" "}
downtime
{t("uptimeDowntimeSuffix")}
</span>
)}
</>
)}
{allNoData && (
<span className="text-muted-foreground text-xs">
No data available
{t("uptimeNoDataAvailable")}
</span>
)}
</div>
@@ -188,7 +190,7 @@ export default function UptimeBar({
</div>
{day.status !== "no_data" && (
<div className="text-xs text-primary-foreground/80">
Uptime:{" "}
{t("uptimeTooltipUptimeLabel")}:{" "}
<span className="font-medium text-primary-foreground">
{day.uptimePercent.toFixed(1)}%
</span>
@@ -196,7 +198,7 @@ export default function UptimeBar({
)}
{day.totalDowntimeSeconds > 0 && (
<div className="text-xs text-primary-foreground/80">
Downtime:{" "}
{t("uptimeTooltipDowntimeLabel")}:{" "}
<span className="font-medium text-primary-foreground">
{formatDuration(
day.totalDowntimeSeconds
@@ -214,7 +216,7 @@ export default function UptimeBar({
{formatTime(w.start)}
{w.end
? ` ${formatTime(w.end)}`
: " ongoing"}{" "}
: ` ${t("uptimeOngoing")}`}{" "}
<span className="capitalize">
({w.status})
</span>
@@ -224,7 +226,7 @@ export default function UptimeBar({
)}
{day.status === "no_data" && (
<div className="text-xs text-primary-foreground/60">
No monitoring data
{t("uptimeNoMonitoringData")}
</div>
)}
</TooltipContent>
@@ -234,9 +236,9 @@ export default function UptimeBar({
{/* Date labels */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>{days} days ago</span>
<span>Today</span>
<span>{t("uptimeDaysAgo", { count: days })}</span>
<span>{t("uptimeToday")}</span>
</div>
</div>
);
}
}
+8 -6
View File
@@ -10,6 +10,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
function formatDuration(seconds: number): string {
if (seconds === 0) return "0s";
@@ -51,6 +52,7 @@ export default function UptimeMiniBar({
healthCheckId,
days = 30
}: UptimeMiniBarProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const siteQuery = useQuery({
@@ -105,7 +107,7 @@ export default function UptimeMiniBar({
<span
className="inline-flex min-w-[7ch] items-center justify-end text-xs text-muted-foreground whitespace-nowrap"
aria-busy="true"
aria-label="Loading uptime"
aria-label={t("loading")}
>
<span className="h-4 w-[5.5ch] max-w-full rounded bg-muted animate-pulse" />
</span>
@@ -136,12 +138,12 @@ export default function UptimeMiniBar({
</div>
<div className="text-xs text-primary-foreground/80">
{day.status === "no_data"
? "No data"
: `${day.uptimePercent.toFixed(1)}% uptime`}
? t("uptimeNoData")
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
</div>
{day.totalDowntimeSeconds > 0 && (
<div className="text-xs text-primary-foreground/70">
Down:{" "}
{t("uptimeMiniBarDown")}:{" "}
{formatDuration(day.totalDowntimeSeconds)}
</div>
)}
@@ -151,9 +153,9 @@ export default function UptimeMiniBar({
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{allNoData
? "No data"
? t("uptimeNoData")
: `${data.overallUptimePercent.toFixed(1)}%`}
</span>
</div>
);
}
}
@@ -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">
@@ -714,7 +716,7 @@ function WebhookActionFields({
name={`actions.${index}.url`}
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormLabel>{t("webhookUrlLabel")}</FormLabel>
<FormControl>
<Input
{...field}
@@ -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="Key" />
<Input
{...field}
placeholder={t(
"webhookHeaderKeyPlaceholder"
)}
/>
</FormControl>
</FormItem>
)}
@@ -972,7 +1005,12 @@ function WebhookHeadersField({
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="Value" />
<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
@@ -174,15 +174,11 @@ export default function AlertRuleGraphEditor({
<CardContent className="p-4 sm:p-5 space-y-4">
<fieldset
disabled={disabled}
className={
disabled
? "opacity-50 pointer-events-none"
: "space-y-4"
}
className={`space-y-4${disabled ? " opacity-50 pointer-events-none" : ""}`}
>
<div className="flex flex-wrap items-center gap-2">
{isNew && (
<Badge variant="secondary" >
<Badge variant="secondary">
{t("alertingDraftBadge")}
</Badge>
)}
@@ -213,7 +209,9 @@ export default function AlertRuleGraphEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingRuleCooldown")}
{t(
"alertingRuleCooldown"
)}
</FormLabel>
<FormControl>
<Input
@@ -233,7 +231,9 @@ export default function AlertRuleGraphEditor({
/>
</FormControl>
<FormDescription>
{t("alertingRuleCooldownDescription")}
{t(
"alertingRuleCooldownDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
+11 -2
View File
@@ -12,7 +12,7 @@ import {
import { Checkbox } from "./ui/checkbox";
import { useTranslations } from "next-intl";
import { useDebounce } from "use-debounce";
import type { Selectedsite } from "./site-selector";
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
export type MultiSitesSelectorProps = {
orgId: string;
@@ -107,7 +107,16 @@ export function MultiSitesSelector({
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{site.name}</span>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
</div>
</CommandItem>
))}
</CommandGroup>
+45 -2
View File
@@ -18,7 +18,41 @@ import { useDebounce } from "use-debounce";
export type Selectedsite = Pick<
ListSitesResponse["sites"][number],
"name" | "siteId" | "type"
>;
> & {
/** When omitted, no online/offline indicator is shown. */
online?: ListSitesResponse["sites"][number]["online"];
};
type SiteOnlineStatusProps = {
type: Selectedsite["type"];
online: Selectedsite["online"];
t: (key: "online" | "offline") => string;
};
/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */
export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) {
if (type !== "newt" && type !== "wireguard") {
return null;
}
if (typeof online !== "boolean") {
return null;
}
return (
<span
className="shrink-0 flex items-center"
role="img"
aria-label={online ? t("online") : t("offline")}
>
<div
className={
online
? "w-2 h-2 bg-green-500 rounded-full"
: "w-2 h-2 bg-neutral-500 rounded-full"
}
/>
</span>
);
}
export type SitesSelectorProps = {
orgId: string;
@@ -86,7 +120,16 @@ export function SitesSelector({
: "opacity-0"
)}
/>
{site.name}
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
</div>
</CommandItem>
))}
</CommandGroup>
+39 -33
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">
@@ -413,7 +421,7 @@ export function ControlledDataTable<TData, TValue>({
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -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>
+1 -1
View File
@@ -695,7 +695,7 @@ export function DataTable<TData, TValue>({
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+1 -1
View File
@@ -12,7 +12,7 @@ const Table = React.forwardRef<
>(({ className, sticky, ...props }, ref) => (
<div
className={cn("relative w-full", {
"overflow-auto": !sticky
"overflow-x-auto overflow-y-hidden": !sticky
})}
>
<table