mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 16:55:44 +00:00
remove graph
This commit is contained in:
@@ -1364,8 +1364,8 @@
|
|||||||
"alertingDeleteRule": "Delete alert rule",
|
"alertingDeleteRule": "Delete alert rule",
|
||||||
"alertingRuleDeleted": "Alert rule deleted",
|
"alertingRuleDeleted": "Alert rule deleted",
|
||||||
"alertingRuleSaved": "Alert rule saved",
|
"alertingRuleSaved": "Alert rule saved",
|
||||||
"alertingEditRule": "Edit alert rule",
|
"alertingEditRule": "Edit Alert Rule",
|
||||||
"alertingCreateRule": "Create alert rule",
|
"alertingCreateRule": "Create Alert Rule",
|
||||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
|
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
|
||||||
"alertingRuleNamePlaceholder": "Production site down",
|
"alertingRuleNamePlaceholder": "Production site down",
|
||||||
"alertingRuleEnabled": "Rule enabled",
|
"alertingRuleEnabled": "Rule enabled",
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xyflow/react": "^12.8.4",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { apiResponseToFormValues } from "@app/lib/alertRuleForm";
|
import { apiResponseToFormValues } from "@app/lib/alertRuleForm";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -59,9 +60,15 @@ export default function EditAlertRulePage() {
|
|||||||
|
|
||||||
if (formValues === undefined) {
|
if (formValues === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
<>
|
||||||
{t("loading")}
|
<HeaderTitle
|
||||||
</div>
|
title={t("alertingEditRule")}
|
||||||
|
description={t("alertingRuleCredenzaDescription")}
|
||||||
|
/>
|
||||||
|
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +77,19 @@ export default function EditAlertRulePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertRuleGraphEditor
|
<>
|
||||||
key={alertRuleId}
|
<HeaderTitle
|
||||||
orgId={orgId}
|
title={t("alertingEditRule")}
|
||||||
alertRuleId={alertRuleId}
|
description={t("alertingRuleCredenzaDescription")}
|
||||||
initialValues={formValues}
|
/>
|
||||||
isNew={false}
|
<AlertRuleGraphEditor
|
||||||
disabled={!isPaid}
|
key={alertRuleId}
|
||||||
/>
|
orgId={orgId}
|
||||||
|
alertRuleId={alertRuleId}
|
||||||
|
initialValues={formValues}
|
||||||
|
isNew={false}
|
||||||
|
disabled={!isPaid}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { defaultFormValues } from "@app/lib/alertRuleForm";
|
import { defaultFormValues } from "@app/lib/alertRuleForm";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function NewAlertRulePage() {
|
export default function NewAlertRulePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const orgId = params.orgId as string;
|
const orgId = params.orgId as string;
|
||||||
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertRuleGraphEditor
|
<>
|
||||||
orgId={orgId}
|
<HeaderTitle
|
||||||
initialValues={defaultFormValues()}
|
title={t("alertingCreateRule")}
|
||||||
isNew
|
description={t("alertingRuleCredenzaDescription")}
|
||||||
disabled={!isPaid}
|
/>
|
||||||
/>
|
<AlertRuleGraphEditor
|
||||||
|
orgId={orgId}
|
||||||
|
initialValues={defaultFormValues()}
|
||||||
|
isNew
|
||||||
|
disabled={!isPaid}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function AddActionPanel({
|
|||||||
|
|
||||||
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>("notify");
|
||||||
|
|
||||||
const isPremiumSelected =
|
const isPremiumSelected =
|
||||||
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
||||||
|
|||||||
@@ -8,13 +8,7 @@ import {
|
|||||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||||
import { SettingsContainer } from "@app/components/Settings";
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from "@app/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -24,291 +18,35 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
buildFormSchema,
|
buildFormSchema,
|
||||||
defaultFormValues,
|
defaultFormValues,
|
||||||
formValuesToApiPayload,
|
formValuesToApiPayload,
|
||||||
type AlertRuleFormAction,
|
|
||||||
type AlertRuleFormValues
|
type AlertRuleFormValues
|
||||||
} from "@app/lib/alertRuleForm";
|
} from "@app/lib/alertRuleForm";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import type { CreateAlertRuleResponse } from "@server/private/routers/alertRule";
|
import type { CreateAlertRuleResponse } from "@server/private/routers/alertRule";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import {
|
|
||||||
Background,
|
|
||||||
Handle,
|
|
||||||
Position,
|
|
||||||
ReactFlow,
|
|
||||||
ReactFlowProvider,
|
|
||||||
useEdgesState,
|
|
||||||
useNodesState,
|
|
||||||
type Edge,
|
|
||||||
type Node,
|
|
||||||
type NodeProps,
|
|
||||||
type NodeTypes
|
|
||||||
} from "@xyflow/react";
|
|
||||||
import "@xyflow/react/dist/style.css";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Check, ChevronLeft } from "lucide-react";
|
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
type AlertRuleT = ReturnType<typeof useTranslations>;
|
const FORM_ID = "alert-rule-form";
|
||||||
|
|
||||||
export type AlertStepId = "source" | "trigger" | "actions";
|
type StepAccent = {
|
||||||
|
labelClass: string;
|
||||||
type AlertStepNodeData = {
|
icon: typeof Flag;
|
||||||
roleLabel: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
configured: boolean;
|
|
||||||
accent: string;
|
|
||||||
topBorderClass: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
|
|
||||||
if (v.sourceType === "site") {
|
|
||||||
if (v.siteIds.length === 0) {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
return t("alertingSummarySites", { count: v.siteIds.length });
|
|
||||||
}
|
|
||||||
if (v.sourceType === "resource") {
|
|
||||||
if (v.resourceIds.length === 0) {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
return t("alertingSummaryResources", { count: v.resourceIds.length });
|
|
||||||
}
|
|
||||||
if (v.healthCheckIds.length === 0) {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
return t("alertingSummaryHealthChecks", { count: v.healthCheckIds.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
|
|
||||||
switch (v.trigger) {
|
|
||||||
case "site_online":
|
|
||||||
return t("alertingTriggerSiteOnline");
|
|
||||||
case "site_offline":
|
|
||||||
return t("alertingTriggerSiteOffline");
|
|
||||||
case "site_toggle":
|
|
||||||
return t("alertingTriggerSiteToggle");
|
|
||||||
case "health_check_healthy":
|
|
||||||
return t("alertingTriggerHcHealthy");
|
|
||||||
case "health_check_unhealthy":
|
|
||||||
return t("alertingTriggerHcUnhealthy");
|
|
||||||
case "health_check_toggle":
|
|
||||||
return t("alertingTriggerHcToggle");
|
|
||||||
case "resource_healthy":
|
|
||||||
return t("alertingTriggerResourceHealthy");
|
|
||||||
case "resource_unhealthy":
|
|
||||||
return t("alertingTriggerResourceUnhealthy");
|
|
||||||
case "resource_toggle":
|
|
||||||
return t("alertingTriggerResourceToggle");
|
|
||||||
default:
|
|
||||||
return v.trigger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function oneActionConfigured(a: AlertRuleFormAction): boolean {
|
|
||||||
if (a.type === "notify") {
|
|
||||||
return (
|
|
||||||
a.userTags.length > 0 ||
|
|
||||||
a.roleTags.length > 0 ||
|
|
||||||
a.emailTags.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(a.url.trim());
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
|
|
||||||
switch (a.type) {
|
|
||||||
case "notify":
|
|
||||||
return t("alertingActionNotify");
|
|
||||||
case "webhook":
|
|
||||||
return t("alertingActionWebhook");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
|
|
||||||
if (a.type === "notify") {
|
|
||||||
if (
|
|
||||||
a.userTags.length === 0 &&
|
|
||||||
a.roleTags.length === 0 &&
|
|
||||||
a.emailTags.length === 0
|
|
||||||
) {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (a.userTags.length > 0) {
|
|
||||||
parts.push(t("alertingUsersSelected", { count: a.userTags.length }));
|
|
||||||
}
|
|
||||||
if (a.roleTags.length > 0) {
|
|
||||||
parts.push(t("alertingRolesSelected", { count: a.roleTags.length }));
|
|
||||||
}
|
|
||||||
if (a.emailTags.length > 0) {
|
|
||||||
parts.push(
|
|
||||||
`${t("alertingNotifyEmails")} (${a.emailTags.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
const url = a.url.trim();
|
|
||||||
if (!url) {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch {
|
|
||||||
return t("alertingNodeNotConfigured");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepConfigured(
|
|
||||||
step: "source" | "trigger",
|
|
||||||
v: AlertRuleFormValues
|
|
||||||
): boolean {
|
|
||||||
if (step === "source") {
|
|
||||||
return v.sourceType === "site"
|
|
||||||
? v.siteIds.length > 0
|
|
||||||
: v.healthCheckIds.length > 0;
|
|
||||||
}
|
|
||||||
return Boolean(v.trigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActionStepNodeData(
|
|
||||||
index: number,
|
|
||||||
action: AlertRuleFormAction,
|
|
||||||
t: AlertRuleT
|
|
||||||
): AlertStepNodeData {
|
|
||||||
return {
|
|
||||||
roleLabel: `${t("alertingNodeRoleAction")} ${index + 1}`,
|
|
||||||
title: actionTypeLabel(action, t),
|
|
||||||
subtitle: summarizeOneAction(action, t),
|
|
||||||
configured: oneActionConfigured(action),
|
|
||||||
accent: "text-amber-600 dark:text-amber-400",
|
|
||||||
topBorderClass: "border-t-amber-500"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActionsPlaceholderNodeData(t: AlertRuleT): AlertStepNodeData {
|
|
||||||
return {
|
|
||||||
roleLabel: t("alertingNodeRoleAction"),
|
|
||||||
title: t("alertingSectionActions"),
|
|
||||||
subtitle: t("alertingNodeNotConfigured"),
|
|
||||||
configured: false,
|
|
||||||
accent: "text-amber-600 dark:text-amber-400",
|
|
||||||
topBorderClass: "border-t-amber-500"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlertStepNode = memo(function AlertStepNodeFn({
|
|
||||||
data,
|
|
||||||
selected
|
|
||||||
}: NodeProps<Node<AlertStepNodeData>>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative rounded-xl border-2 border-t-[3px] bg-card px-5 py-4 shadow-sm min-w-[260px] max-w-[320px] transition-shadow",
|
|
||||||
data.topBorderClass,
|
|
||||||
selected
|
|
||||||
? "border-primary ring-2 ring-primary/25 shadow-md"
|
|
||||||
: "border-border"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
className="!bg-muted-foreground !w-2 !h-2"
|
|
||||||
/>
|
|
||||||
{data.configured && (
|
|
||||||
<Check
|
|
||||||
className="absolute top-3 right-3 h-5 w-5 text-green-600"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] font-semibold uppercase tracking-wide",
|
|
||||||
data.accent
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{data.roleLabel}
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-base mt-1">{data.title}</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-3 leading-snug">
|
|
||||||
{data.subtitle}
|
|
||||||
</p>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
className="!bg-muted-foreground !w-2 !h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = {
|
|
||||||
alertStep: AlertStepNode
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_NODE_X_GAP = 280;
|
|
||||||
const ACTION_NODE_Y = 468;
|
|
||||||
const SOURCE_NODE_POS = { x: 120, y: 28 };
|
|
||||||
const TRIGGER_NODE_POS = { x: 120, y: 248 };
|
|
||||||
|
|
||||||
function buildNodeData(
|
|
||||||
stepId: "source" | "trigger",
|
|
||||||
v: AlertRuleFormValues,
|
|
||||||
t: AlertRuleT
|
|
||||||
): AlertStepNodeData {
|
|
||||||
const accents: Record<
|
|
||||||
"source" | "trigger",
|
|
||||||
{ accent: string; topBorderClass: string; role: string; title: string }
|
|
||||||
> = {
|
|
||||||
source: {
|
|
||||||
accent: "text-blue-600 dark:text-blue-400",
|
|
||||||
topBorderClass: "border-t-blue-500",
|
|
||||||
role: t("alertingNodeRoleSource"),
|
|
||||||
title: t("alertingSectionSource")
|
|
||||||
},
|
|
||||||
trigger: {
|
|
||||||
accent: "text-emerald-600 dark:text-emerald-400",
|
|
||||||
topBorderClass: "border-t-emerald-500",
|
|
||||||
role: t("alertingNodeRoleTrigger"),
|
|
||||||
title: t("alertingSectionTrigger")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const meta = accents[stepId];
|
|
||||||
const subtitle =
|
|
||||||
stepId === "source"
|
|
||||||
? summarizeSource(v, t)
|
|
||||||
: summarizeTrigger(v, t);
|
|
||||||
return {
|
|
||||||
roleLabel: meta.role,
|
|
||||||
title: meta.title,
|
|
||||||
subtitle,
|
|
||||||
configured: stepConfigured(stepId, v),
|
|
||||||
accent: meta.accent,
|
|
||||||
topBorderClass: meta.topBorderClass
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertRuleGraphEditorProps = {
|
type AlertRuleGraphEditorProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
alertRuleId?: number;
|
alertRuleId?: number;
|
||||||
@@ -317,7 +55,53 @@ type AlertRuleGraphEditorProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORM_ID = "alert-rule-graph-form";
|
function VerticalRuleStep({
|
||||||
|
stepNumber,
|
||||||
|
isLast,
|
||||||
|
title,
|
||||||
|
accent,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
stepNumber: number;
|
||||||
|
isLast: boolean;
|
||||||
|
title: string;
|
||||||
|
accent: StepAccent;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const Icon = accent.icon;
|
||||||
|
return (
|
||||||
|
<li className="flex gap-4 sm:gap-5">
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
{stepNumber}
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div className="w-px flex-1 min-h-8 my-1 border-l-2 border-dashed border-border" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isLast
|
||||||
|
? "min-w-0 flex-1 space-y-3"
|
||||||
|
: "min-w-0 flex-1 space-y-3 pb-10"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 font-semibold text-base ${accent.labelClass}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 sm:p-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AlertRuleGraphEditor({
|
export default function AlertRuleGraphEditor({
|
||||||
orgId,
|
orgId,
|
||||||
@@ -341,180 +125,6 @@ export default function AlertRuleGraphEditor({
|
|||||||
name: "actions"
|
name: "actions"
|
||||||
});
|
});
|
||||||
|
|
||||||
const wName = useWatch({ control: form.control, name: "name" }) ?? "";
|
|
||||||
const wEnabled =
|
|
||||||
useWatch({ control: form.control, name: "enabled" }) ?? true;
|
|
||||||
const wSourceType =
|
|
||||||
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
|
|
||||||
const wAllSites =
|
|
||||||
useWatch({ control: form.control, name: "allSites" }) ?? true;
|
|
||||||
const wSiteIds =
|
|
||||||
useWatch({ control: form.control, name: "siteIds" }) ?? [];
|
|
||||||
const wAllHealthChecks =
|
|
||||||
useWatch({ control: form.control, name: "allHealthChecks" }) ?? true;
|
|
||||||
const wHealthCheckIds =
|
|
||||||
useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
|
|
||||||
const wAllResources =
|
|
||||||
useWatch({ control: form.control, name: "allResources" }) ?? true;
|
|
||||||
const wResourceIds =
|
|
||||||
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
|
|
||||||
const wTrigger =
|
|
||||||
useWatch({ control: form.control, name: "trigger" }) ??
|
|
||||||
"site_toggle";
|
|
||||||
const wActions =
|
|
||||||
useWatch({ control: form.control, name: "actions" }) ?? [];
|
|
||||||
|
|
||||||
const flowValues: AlertRuleFormValues = useMemo(
|
|
||||||
() => ({
|
|
||||||
name: wName,
|
|
||||||
enabled: wEnabled,
|
|
||||||
sourceType: wSourceType,
|
|
||||||
allSites: wAllSites,
|
|
||||||
siteIds: wSiteIds,
|
|
||||||
allHealthChecks: wAllHealthChecks,
|
|
||||||
healthCheckIds: wHealthCheckIds,
|
|
||||||
allResources: wAllResources,
|
|
||||||
resourceIds: wResourceIds,
|
|
||||||
trigger: wTrigger,
|
|
||||||
actions: wActions
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
wName,
|
|
||||||
wEnabled,
|
|
||||||
wSourceType,
|
|
||||||
wAllSites,
|
|
||||||
wSiteIds,
|
|
||||||
wAllHealthChecks,
|
|
||||||
wHealthCheckIds,
|
|
||||||
wAllResources,
|
|
||||||
wResourceIds,
|
|
||||||
wTrigger,
|
|
||||||
wActions
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedStep, setSelectedStep] = useState<string>("source");
|
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
||||||
|
|
||||||
const nodesSyncKeyRef = useRef("");
|
|
||||||
useEffect(() => {
|
|
||||||
const key = JSON.stringify({ flowValues, selectedStep });
|
|
||||||
if (key === nodesSyncKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nodesSyncKeyRef.current = key;
|
|
||||||
|
|
||||||
const nActions = flowValues.actions.length;
|
|
||||||
const actionNodes: Node[] =
|
|
||||||
nActions === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
type: "alertStep",
|
|
||||||
position: {
|
|
||||||
x: TRIGGER_NODE_POS.x,
|
|
||||||
y: ACTION_NODE_Y
|
|
||||||
},
|
|
||||||
data: buildActionsPlaceholderNodeData(t),
|
|
||||||
selected:
|
|
||||||
selectedStep === "actions" ||
|
|
||||||
selectedStep.startsWith("action-")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: flowValues.actions.map((action, i) => {
|
|
||||||
const totalWidth =
|
|
||||||
(nActions - 1) * ACTION_NODE_X_GAP;
|
|
||||||
const originX =
|
|
||||||
TRIGGER_NODE_POS.x - totalWidth / 2;
|
|
||||||
return {
|
|
||||||
id: `action-${i}`,
|
|
||||||
type: "alertStep",
|
|
||||||
position: {
|
|
||||||
x: originX + i * ACTION_NODE_X_GAP,
|
|
||||||
y: ACTION_NODE_Y
|
|
||||||
},
|
|
||||||
data: buildActionStepNodeData(i, action, t),
|
|
||||||
selected: selectedStep === `action-${i}`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setNodes([
|
|
||||||
{
|
|
||||||
id: "source",
|
|
||||||
type: "alertStep",
|
|
||||||
position: SOURCE_NODE_POS,
|
|
||||||
data: buildNodeData("source", flowValues, t),
|
|
||||||
selected: selectedStep === "source"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trigger",
|
|
||||||
type: "alertStep",
|
|
||||||
position: TRIGGER_NODE_POS,
|
|
||||||
data: buildNodeData("trigger", flowValues, t),
|
|
||||||
selected: selectedStep === "trigger"
|
|
||||||
},
|
|
||||||
...actionNodes
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nextEdges: Edge[] = [
|
|
||||||
{
|
|
||||||
id: "e-src-trg",
|
|
||||||
source: "source",
|
|
||||||
target: "trigger",
|
|
||||||
animated: true
|
|
||||||
},
|
|
||||||
...(nActions === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "e-trg-act",
|
|
||||||
source: "trigger",
|
|
||||||
target: "actions",
|
|
||||||
animated: true
|
|
||||||
} as const
|
|
||||||
]
|
|
||||||
: flowValues.actions.map((_, i) => ({
|
|
||||||
id: `e-trg-act-${i}`,
|
|
||||||
source: "trigger",
|
|
||||||
target: `action-${i}`,
|
|
||||||
animated: true
|
|
||||||
})))
|
|
||||||
];
|
|
||||||
setEdges(nextEdges);
|
|
||||||
}, [flowValues, selectedStep, t, setNodes, setEdges]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedStep === "actions" && wActions.length > 0) {
|
|
||||||
setSelectedStep("action-0");
|
|
||||||
}
|
|
||||||
}, [selectedStep, wActions.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (wActions.length === 0 && /^action-\d+$/.test(selectedStep)) {
|
|
||||||
setSelectedStep("actions");
|
|
||||||
}
|
|
||||||
}, [wActions.length, selectedStep]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const m = /^action-(\d+)$/.exec(selectedStep);
|
|
||||||
if (!m) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const i = parseInt(m[1], 10);
|
|
||||||
if (i >= wActions.length) {
|
|
||||||
setSelectedStep(
|
|
||||||
wActions.length > 0
|
|
||||||
? `action-${wActions.length - 1}`
|
|
||||||
: "actions"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [wActions.length, selectedStep]);
|
|
||||||
|
|
||||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
|
||||||
setSelectedStep(node.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -545,173 +155,158 @@ export default function AlertRuleGraphEditor({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActionsSidebar =
|
|
||||||
selectedStep === "actions" || selectedStep.startsWith("action-");
|
|
||||||
|
|
||||||
const sidebarTitle = isActionsSidebar
|
|
||||||
? t("alertingConfigureActions")
|
|
||||||
: selectedStep === "source"
|
|
||||||
? t("alertingConfigureSource")
|
|
||||||
: t("alertingConfigureTrigger");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={FORM_ID} onSubmit={onSubmit}>
|
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||||
<Card>
|
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||||
<CardContent className="p-4 sm:p-5 space-y-4">
|
<aside className="w-full lg:w-[min(100%,280px)] shrink-0 lg:sticky lg:top-16 space-y-4">
|
||||||
<fieldset
|
<Card>
|
||||||
disabled={disabled}
|
<CardContent className="p-4 sm:p-5 space-y-4">
|
||||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
<fieldset
|
||||||
>
|
disabled={disabled}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:flex-wrap md:items-center">
|
className={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
disabled
|
||||||
<Button variant="outline" size="sm" asChild>
|
? "opacity-50 pointer-events-none"
|
||||||
<Link href={`/${orgId}/settings/alerting`}>
|
: "space-y-4"
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
}
|
||||||
{t("alertingBackToRules")}
|
>
|
||||||
</Link>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</Button>
|
{isNew && (
|
||||||
{isNew && (
|
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
|
||||||
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground">
|
{t("alertingDraftBadge")}
|
||||||
{t("alertingDraftBadge")}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="name"
|
||||||
name="name"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem className="flex-1 min-w-0 md:min-w-[12rem] md:max-w-md">
|
<FormLabel>
|
||||||
<FormLabel className="sr-only">
|
{t("name")}
|
||||||
{t("name")}
|
</FormLabel>
|
||||||
</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input
|
||||||
<Input
|
{...field}
|
||||||
{...field}
|
placeholder={t(
|
||||||
placeholder={t(
|
"alertingRuleNamePlaceholder"
|
||||||
"alertingRuleNamePlaceholder"
|
)}
|
||||||
)}
|
/>
|
||||||
className="font-medium"
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
</FormControl>
|
</FormItem>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<FormField
|
||||||
/>
|
control={form.control}
|
||||||
<div className="flex flex-wrap items-center gap-3 md:ml-auto">
|
name="enabled"
|
||||||
<FormField
|
render={({ field }) => (
|
||||||
control={form.control}
|
<FormItem>
|
||||||
name="enabled"
|
<FormControl>
|
||||||
render={({ field }) => (
|
<SwitchInput
|
||||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
id="alert-rule-enabled"
|
||||||
<FormLabel className="text-sm font-normal cursor-pointer whitespace-nowrap">
|
label={t(
|
||||||
{t("alertingRuleEnabled")}
|
"alertingRuleEnabled"
|
||||||
</FormLabel>
|
)}
|
||||||
<FormControl>
|
checked={
|
||||||
<Switch
|
field.value
|
||||||
checked={field.value}
|
}
|
||||||
onCheckedChange={
|
onCheckedChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
/>
|
disabled={
|
||||||
</FormControl>
|
disabled
|
||||||
</FormItem>
|
}
|
||||||
)}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<FormMessage />
|
||||||
{isSaving ? t("saving") : t("save")}
|
</FormItem>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<Button
|
||||||
</fieldset>
|
type="submit"
|
||||||
</CardContent>
|
className="w-full"
|
||||||
</Card>
|
disabled={isSaving}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
|
||||||
<Card className="flex flex-col w-full overflow-hidden">
|
|
||||||
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
|
||||||
<CardTitle className="text-lg font-bold tracking-tight">
|
|
||||||
{t("alertingGraphCanvasTitle")}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="pt-0">
|
|
||||||
{t("alertingGraphCanvasDescription")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-md border bg-muted/30 overflow-hidden",
|
|
||||||
"min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
onNodesChange={onNodesChange}
|
|
||||||
onEdgesChange={onEdgesChange}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
fitView
|
|
||||||
fitViewOptions={{
|
|
||||||
padding: 0.35
|
|
||||||
}}
|
|
||||||
minZoom={0.5}
|
|
||||||
maxZoom={1.25}
|
|
||||||
nodesDraggable={false}
|
|
||||||
nodesConnectable={false}
|
|
||||||
elementsSelectable
|
|
||||||
panOnScroll
|
|
||||||
zoomOnScroll
|
|
||||||
proOptions={{
|
|
||||||
hideAttribution: true
|
|
||||||
}}
|
|
||||||
className="bg-transparent !h-full !w-full min-h-[min(66vh,560px)] lg:min-h-[680px]"
|
|
||||||
>
|
>
|
||||||
<Background gap={16} size={1} />
|
{isSaving ? t("saving") : t("save")}
|
||||||
</ReactFlow>
|
</Button>
|
||||||
</ReactFlowProvider>
|
</fieldset>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</aside>
|
||||||
|
|
||||||
<Card className="flex flex-col w-full">
|
<div className="min-w-0 flex-1 w-full max-w-3xl">
|
||||||
<CardHeader className="pb-2 pt-5 px-5 space-y-1 sm:px-6">
|
<ol className="list-none m-0 p-0">
|
||||||
<CardTitle className="text-lg font-bold tracking-tight">
|
<VerticalRuleStep
|
||||||
{sidebarTitle}
|
stepNumber={1}
|
||||||
</CardTitle>
|
isLast={false}
|
||||||
<CardDescription className="pt-0">
|
title={t("alertingSectionSource")}
|
||||||
{t("alertingSidebarHint")}
|
accent={{
|
||||||
</CardDescription>
|
labelClass:
|
||||||
</CardHeader>
|
"text-emerald-600 dark:text-emerald-400",
|
||||||
<CardContent className="p-4 sm:p-5 sm:px-6 pt-0">
|
icon: Flag
|
||||||
<fieldset
|
}}
|
||||||
disabled={disabled}
|
>
|
||||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
<fieldset
|
||||||
>
|
disabled={disabled}
|
||||||
<div className="space-y-6">
|
className={
|
||||||
{selectedStep === "source" && (
|
disabled
|
||||||
<AlertRuleSourceFields
|
? "opacity-50 pointer-events-none"
|
||||||
orgId={orgId}
|
: ""
|
||||||
control={form.control}
|
}
|
||||||
/>
|
>
|
||||||
)}
|
<AlertRuleSourceFields
|
||||||
{selectedStep === "trigger" && (
|
orgId={orgId}
|
||||||
<AlertRuleTriggerFields
|
control={form.control}
|
||||||
control={form.control}
|
/>
|
||||||
/>
|
</fieldset>
|
||||||
)}
|
</VerticalRuleStep>
|
||||||
{isActionsSidebar && (
|
<VerticalRuleStep
|
||||||
<div className="space-y-4">
|
stepNumber={2}
|
||||||
<span className="text-sm font-medium">
|
isLast={false}
|
||||||
{t("alertingSectionActions")}
|
title={t("alertingSectionTrigger")}
|
||||||
</span>
|
accent={{
|
||||||
|
labelClass:
|
||||||
|
"text-amber-600 dark:text-amber-400",
|
||||||
|
icon: Cog
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
disabled={disabled}
|
||||||
|
className={
|
||||||
|
disabled
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertRuleTriggerFields
|
||||||
|
control={form.control}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</VerticalRuleStep>
|
||||||
|
<VerticalRuleStep
|
||||||
|
stepNumber={3}
|
||||||
|
isLast
|
||||||
|
title={t("alertingSectionActions")}
|
||||||
|
accent={{
|
||||||
|
labelClass:
|
||||||
|
"text-blue-600 dark:text-blue-400",
|
||||||
|
icon: Zap
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
disabled={disabled}
|
||||||
|
className={
|
||||||
|
disabled
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
<AddActionPanel
|
<AddActionPanel
|
||||||
onAdd={(type) => {
|
onAdd={(type) => {
|
||||||
const newIndex =
|
|
||||||
fields.length;
|
|
||||||
if (type === "notify") {
|
if (type === "notify") {
|
||||||
append({
|
append({
|
||||||
type: "notify",
|
type: "notify",
|
||||||
@@ -732,14 +327,14 @@ export default function AlertRuleGraphEditor({
|
|||||||
],
|
],
|
||||||
authType: "none",
|
authType: "none",
|
||||||
bearerToken: "",
|
bearerToken: "",
|
||||||
basicCredentials: "",
|
basicCredentials:
|
||||||
customHeaderName: "",
|
"",
|
||||||
customHeaderValue: ""
|
customHeaderName:
|
||||||
|
"",
|
||||||
|
customHeaderValue:
|
||||||
|
""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSelectedStep(
|
|
||||||
`action-${newIndex}`
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{fields.map((f, index) => (
|
{fields.map((f, index) => (
|
||||||
@@ -759,11 +354,10 @@ export default function AlertRuleGraphEditor({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</fieldset>
|
||||||
</div>
|
</VerticalRuleStep>
|
||||||
</fieldset>
|
</ol>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -317,14 +317,7 @@ export function defaultFormValues(): AlertRuleFormValues {
|
|||||||
allResources: true,
|
allResources: true,
|
||||||
resourceIds: [],
|
resourceIds: [],
|
||||||
trigger: "site_toggle",
|
trigger: "site_toggle",
|
||||||
actions: [
|
actions: []
|
||||||
{
|
|
||||||
type: "notify",
|
|
||||||
userTags: [],
|
|
||||||
roleTags: [],
|
|
||||||
emailTags: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,16 +372,6 @@ export function apiResponseToFormValues(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always ensure at least one action so the form is valid
|
|
||||||
if (actions.length === 0) {
|
|
||||||
actions.push({
|
|
||||||
type: "notify",
|
|
||||||
userTags: [],
|
|
||||||
roleTags: [],
|
|
||||||
emailTags: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSites = sourceType === "site" && rule.siteIds.length === 0;
|
const allSites = sourceType === "site" && rule.siteIds.length === 0;
|
||||||
const allHealthChecks =
|
const allHealthChecks =
|
||||||
sourceType === "health_check" && rule.healthCheckIds.length === 0;
|
sourceType === "health_check" && rule.healthCheckIds.length === 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user