mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-09 22:04:16 +00:00
Merge branch 'dev' into feat/roles-and-user-multi-selectors
This commit is contained in:
@@ -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(
|
||||
|
||||
129
src/lib/alertRulesLocalStorage.ts
Normal file
129
src/lib/alertRulesLocalStorage.ts
Normal 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();
|
||||
}
|
||||
5
src/lib/dataTableFilterPopover.ts
Normal file
5
src/lib/dataTableFilterPopover.ts
Normal 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))]";
|
||||
32
src/lib/formatSiteResourceAccess.ts
Normal file
32
src/lib/formatSiteResourceAccess.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user