diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index ba79feb4b..0a6d40f16 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -20,4 +20,15 @@ export async function fireHealthCheckUnhealthyAlert( trx?: unknown ): Promise { return; +} + +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx?: unknown +): Promise { + return; } \ No newline at end of file diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 136fa1545..fc1fee5b0 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -34,6 +34,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { @@ -169,6 +170,18 @@ export async function updateProxyResources( .returning(); healthchecksToUpdate.push(newHealthcheck); + + // Insert unknown status history when HC is created in disabled state + if (!healthcheckData?.enabled) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } // Find existing resource by niceId and orgId @@ -557,6 +570,20 @@ export async function updateProxyResources( targetsToUpdate.push(updatedTarget); } } + + // Insert unknown status history when HC is disabled + const isDisablingHc = + !healthcheckData?.enabled && oldHealthcheck?.hcEnabled; + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } else { await createTarget(existingResource.resourceId, targetData); } diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 001a0b93b..896a5e302 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket { uptimePercent: number; // 0-100 totalDowntimeSeconds: number; downtimeWindows: { start: number; end: number | null; status: string }[]; - status: "good" | "degraded" | "bad" | "no_data"; + status: "good" | "degraded" | "bad" | "no_data" | "unknown"; } export interface StatusHistoryResponse { @@ -54,6 +54,7 @@ export function computeBuckets( const windows: { start: number; end: number | null; status: string }[] = []; let dayDowntime = 0; + let dayDegradedTime = 0; let windowStart = dayStartSec; let windowStatus = currentStatus; @@ -63,8 +64,8 @@ export function computeBuckets( const windowEnd = evt.timestamp; const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown) { dayDowntime += windowEnd - windowStart; windows.push({ @@ -72,6 +73,13 @@ export function computeBuckets( end: windowEnd, status: windowStatus, }); + } else if (isDegraded) { + dayDegradedTime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus, + }); } } windowStart = evt.timestamp; @@ -83,8 +91,8 @@ export function computeBuckets( const finalEnd = Math.min(dayEndSec, nowSec); const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown && finalEnd > windowStart) { dayDowntime += finalEnd - windowStart; windows.push({ @@ -92,6 +100,13 @@ export function computeBuckets( end: finalEnd, status: windowStatus, }); + } else if (isDegraded && finalEnd > windowStart) { + dayDegradedTime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus, + }); } } @@ -105,7 +120,7 @@ export function computeBuckets( effectiveDayLength > 0 ? Math.max( 0, - ((effectiveDayLength - dayDowntime) / + ((effectiveDayLength - dayDowntime - dayDegradedTime) / effectiveDayLength) * 100 ) @@ -113,11 +128,27 @@ export function computeBuckets( const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + const hasAnyData = currentStatus !== null || dayEvents.length > 0; + + // The whole observable window is "unknown" if every status we have seen is unknown + const allStatuses = [ + ...(currentStatus !== null ? [currentStatus] : []), + ...dayEvents.map((e) => e.status) + ]; + const onlyUnknownData = + hasAnyData && allStatuses.every((s) => s === "unknown"); + let status: StatusHistoryDayBucket["status"] = "no_data"; - if (currentStatus !== null || dayEvents.length > 0) { - if (uptimePct >= 99) status = "good"; - else if (uptimePct >= 50) status = "degraded"; - else status = "bad"; + if (hasAnyData) { + if (onlyUnknownData) { + status = "unknown"; + } else if (dayDowntime > 0 && uptimePct < 50) { + status = "bad"; + } else if (dayDowntime > 0 || dayDegradedTime > 0) { + status = "degraded"; + } else { + status = "good"; + } } buckets.push({ diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9f675f4c0..04f197a8d 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -25,7 +25,8 @@ import { eq } from "drizzle-orm"; import { fireResourceDegradedAlert, fireResourceHealthyAlert, - fireResourceUnhealthyAlert + fireResourceUnhealthyAlert, + fireResourceUnknownAlert } from "./resourceEvents"; // --------------------------------------------------------------------------- @@ -148,6 +149,32 @@ export async function fireHealthCheckUnhealthyAlert( } } +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await handleResource(orgId, healthCheckTargetId, trx); + } catch (err) { + logger.error( + `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) { if (!healthCheckTargetId) { return; @@ -178,10 +205,16 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .where(eq(targets.resourceId, resource.resourceId)); let health = "healthy"; + const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); - if (allHealthy) { + if (allUnknown) { + logger.debug( + `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` + ); + health = "unknown"; + } else if (allHealthy) { health = "healthy"; } else if (allUnhealthy) { logger.debug( @@ -202,7 +235,15 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .set({ health }) .where(eq(resources.resourceId, resource.resourceId)); - if (health === "unhealthy") { + if (health === "unknown") { + await fireResourceUnknownAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + trx + ); + } else if (health === "unhealthy") { await fireResourceUnhealthyAlert( orgId, resource.resourceId, diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 41c77dc11..289b19b90 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -183,3 +183,49 @@ export async function fireResourceDegradedAlert( ); } } + +/** + * Fire a `resource_unknown` alert for the given resource. + * + * Call this when all health checks on a resource are disabled so that the + * resource status transitions to unknown. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceUnknownAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unknown", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..e79db2ef5 --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,63 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; +} + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} \ No newline at end of file diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index dad302de8..21f52566f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -225,6 +226,11 @@ export async function updateTarget( hcHealthValue = undefined; } + const isDisablingHc = + (parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null) && + existingHc.hcEnabled === true; + const [updatedHc] = await db .update(targetHealthCheck) .set({ @@ -250,6 +256,15 @@ export async function updateTarget( .where(eq(targetHealthCheck.targetId, targetId)) .returning(); + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + resource.orgId, + existingHc.targetHealthCheckId, + existingHc.name, + updatedHc.targetId + ); + } + if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx new file mode 100644 index 000000000..1c7d1b7fd --- /dev/null +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; + +export type GlobalUserRow = { + id: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; + dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "id", + friendlyName: "ID", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "username", + friendlyName: t("username"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + friendlyName: t("email"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + friendlyName: t("name"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + friendlyName: t("identityProvider"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "twoFactorEnabled", + friendlyName: t("twoFactor"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ + + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

{t("userQuestionRemove")}

+ +

{t("userMessageRemove")}

+
+ } + buttonText={t("userDeleteConfirm")} + onConfirm={async () => deleteUser(selected!.id)} + string={ + selected.email || selected.name || selected.username + } + title={t("userDeleteServer")} + /> + )} + + + + ); +} diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx new file mode 100644 index 000000000..afa473e86 --- /dev/null +++ b/src/components/AdminUsersDataTable.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx new file mode 100644 index 000000000..5a2d1cb4c --- /dev/null +++ b/src/components/RolesDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + createRole?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function RolesDataTable({ + columns, + data, + createRole, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index 5bd5ce3f1..37e38bcaa 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -42,7 +42,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeBarProps = { @@ -188,7 +189,7 @@ export default function UptimeBar({
{formatDate(day.date)}
- {day.status !== "no_data" && ( + {day.status !== "no_data" && day.status !== "unknown" && (
{t("uptimeTooltipUptimeLabel")}:{" "} @@ -224,7 +225,7 @@ export default function UptimeBar({ ))}
)} - {day.status === "no_data" && ( + {(day.status === "no_data" || day.status === "unknown") && (
{t("uptimeNoMonitoringData")}
diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b4c8aa3bd..b7e684c8b 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -34,7 +34,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeMiniBarProps = { @@ -137,7 +138,7 @@ export default function UptimeMiniBar({ {formatDate(day.date)}
- {day.status === "no_data" + {day.status === "no_data" || day.status === "unknown" ? t("uptimeNoData") : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx new file mode 100644 index 000000000..ececa4c17 --- /dev/null +++ b/src/components/UsersDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + inviteUser?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + inviteUser, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts new file mode 100644 index 000000000..2471219b0 --- /dev/null +++ b/src/lib/alertRulesLocalStorage.ts @@ -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; +export type AlertAction = z.infer; +export type AlertTrigger = z.infer; + +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(); +}