Merge branch 'dev' into feat/roles-and-user-multi-selectors

This commit is contained in:
Fred KISSIE
2026-04-30 16:55:25 +02:00
189 changed files with 6765 additions and 1954 deletions

View File

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

View File

@@ -0,0 +1,129 @@
import { z } from "zod";
const STORAGE_PREFIX = "pangolin:alert-rules:";
export const webhookHeaderEntrySchema = z.object({
key: z.string(),
value: z.string()
});
export const alertActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
emails: z.array(z.string())
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),
method: z.string().min(1),
headers: z.array(webhookHeaderEntrySchema),
secret: z.string().optional()
})
]);
export const alertSourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("site"),
siteIds: z.array(z.number())
}),
z.object({
type: z.literal("health_check"),
targetIds: z.array(z.number())
})
]);
export const alertTriggerSchema = z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]);
export const alertRuleSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
source: alertSourceSchema,
trigger: alertTriggerSchema,
actions: z.array(alertActionSchema).min(1)
});
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
function storageKey(orgId: string) {
return `${STORAGE_PREFIX}${orgId}`;
}
export function getRule(orgId: string, ruleId: string): AlertRule | undefined {
return loadRules(orgId).find((r) => r.id === ruleId);
}
export function loadRules(orgId: string): AlertRule[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(storageKey(orgId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const out: AlertRule[] = [];
for (const item of parsed) {
const r = alertRuleSchema.safeParse(item);
if (r.success) {
out.push(r.data);
}
}
return out;
} catch {
return [];
}
}
export function saveRules(orgId: string, rules: AlertRule[]) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
}
export function upsertRule(orgId: string, rule: AlertRule) {
const rules = loadRules(orgId);
const i = rules.findIndex((r) => r.id === rule.id);
if (i >= 0) {
rules[i] = rule;
} else {
rules.push(rule);
}
saveRules(orgId, rules);
}
export function deleteRule(orgId: string, ruleId: string) {
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
saveRules(orgId, rules);
}
export function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function isoNow() {
return new Date().toISOString();
}

View File

@@ -0,0 +1,5 @@
export const dataTableFilterPopoverContentClassName =
"w-[min(16rem,calc(100vw-2rem))] p-0";
export const dataTableFilterDropdownContentClassName =
"w-[min(16rem,calc(100vw-2rem))]";

View File

@@ -0,0 +1,32 @@
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http";
destination: string;
httpHttpsPort: number | null;
scheme: "http" | "https" | null;
};
export function resolveHttpHttpsDisplayPort(
mode: "http",
httpHttpsPort: number | null
): number {
if (httpHttpsPort != null) {
return httpHttpsPort;
}
return 80;
}
export function formatSiteResourceDestinationDisplay(
row: SiteResourceDestinationInput
): string {
const { mode, destination, httpHttpsPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
}

View File

@@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild
export const SINGLE_LABEL_STRICT_RE =
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
export function sanitizeInputRaw(input: string): string {
/**
* A wildcard subdomain is either bare "*" or "*.label1.label2…" where every
* label after the dot is a valid hostname label. This mirrors the shape that
* the server's `wildcardSubdomainSchema` accepts.
*/
export const WILDCARD_SUBDOMAIN_RE =
/^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
export function isWildcardSubdomain(input: string): boolean {
return WILDCARD_SUBDOMAIN_RE.test(input);
}
export function sanitizeInputRaw(input: string, allowWildcard = false): string {
if (!input) return "";
// When wildcard mode is active, preserve a leading "* " / "*." prefix and
// only sanitize the remainder so the user can type "*.level1" naturally.
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1);
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "");
return "*" + sanitizedRest;
}
return input
.toLowerCase()
.normalize("NFC") // normalize Unicode
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
}
export function finalizeSubdomainSanitize(input: string): string {
export function finalizeSubdomainSanitize(
input: string,
allowWildcard = false
): string {
if (!input) return "";
// If the input is a valid wildcard and the caller permits it, keep it as-is
// (just lowercase the non-wildcard labels).
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1); // everything after the leading "*"
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "")
.replace(/\.{2,}/g, ".")
.replace(/^-+|-+$/g, "")
.replace(/(\.-)|(-\.)/g, ".");
const candidate = "*" + sanitizedRest;
// Return only if it still forms a valid wildcard after sanitizing
return isWildcardSubdomain(candidate) ? candidate : "";
}
return input
.toLowerCase()
.normalize("NFC")
@@ -30,6 +72,7 @@ export function validateByDomainType(
domainType: {
type: "provided-search" | "organization";
domainType?: "ns" | "cname" | "wildcard";
allowWildcard?: boolean;
}
): boolean {
if (!domainType) return false;
@@ -46,6 +89,12 @@ export function validateByDomainType(
domainType.domainType === "wildcard"
) {
if (subdomain === "") return true;
// Wildcard subdomain validation (only when caller opts in)
if (domainType.allowWildcard && subdomain.startsWith("*")) {
return isWildcardSubdomain(subdomain);
}
if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split(".");
return labels.every(
@@ -57,10 +106,19 @@ export function validateByDomainType(
return false;
}
export const isValidSubdomainStructure = (input: string): boolean => {
export const isValidSubdomainStructure = (
input: string,
allowWildcard = false
): boolean => {
if (!input) return false;
// A valid wildcard subdomain is structurally valid when the caller allows it
if (allowWildcard && input.startsWith("*")) {
return isWildcardSubdomain(input);
}
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false;
if (input.includes("..")) return false;
return input.split(".").every((label) => regex.test(label));