enhanced alert granularity with min/max thresholds
This commit is contained in:
@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the min/max thresholds on an alert rule
|
||||
* @param {number} alertId - Alert rule ID
|
||||
* @param {number|null} minimum - Minimum amount (null to clear)
|
||||
* @param {number|null} maximum - Maximum amount (null to clear)
|
||||
* @returns {Promise<Object>} Updated alert rule
|
||||
*/
|
||||
export async function updateAlertThresholds(alertId, minimum, maximum) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
update_min_max: true,
|
||||
minimum: minimum ?? null,
|
||||
maximum: maximum ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update alert thresholds";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alert rule
|
||||
* @param {number} alertId - Alert rule ID
|
||||
|
||||
@@ -51,6 +51,69 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.alerts__accordion-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alerts__accordion-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alerts__accordion-chevron {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.alerts__accordion-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.alerts__accordion-panel {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.alerts__accordion-panel--open {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
padding: 0.75rem 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
.alerts__threshold-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.alerts__threshold-inputs .form-field {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alerts__threshold-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.alerts__email-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
updateAlertThresholds,
|
||||
deleteAlert,
|
||||
} from "../../api/alerts";
|
||||
import {
|
||||
@@ -39,6 +40,64 @@ export default function Alerts() {
|
||||
const [sendingDigest, setSendingDigest] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
const [openAccordions, setOpenAccordions] = useState({});
|
||||
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||
|
||||
function toggleAccordion(alertId) {
|
||||
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
|
||||
}
|
||||
|
||||
function getThresholdEdit(alert) {
|
||||
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
|
||||
return {
|
||||
minimum: alert.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function setThresholdEdit(alertId, field, value) {
|
||||
setThresholdEdits((prev) => {
|
||||
const current = prev[alertId] || getThresholdEditForAlert(alertId);
|
||||
return { ...prev, [alertId]: { ...current, [field]: value } };
|
||||
});
|
||||
}
|
||||
|
||||
function getThresholdEditForAlert(alertId) {
|
||||
const alert = alerts.find((a) => a.id === alertId);
|
||||
return {
|
||||
minimum: alert?.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert?.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveThresholds(alertId) {
|
||||
const edit = thresholdEdits[alertId];
|
||||
if (!edit) return;
|
||||
|
||||
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
|
||||
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
|
||||
|
||||
if (min !== null && isNaN(min)) return;
|
||||
if (max !== null && isNaN(max)) return;
|
||||
if (min !== null && max !== null && min > max) return;
|
||||
|
||||
try {
|
||||
const updated = await updateAlertThresholds(alertId, min, max);
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? updated : a)),
|
||||
);
|
||||
setThresholdEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[alertId];
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update thresholds:", err);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -314,42 +373,132 @@ export default function Alerts() {
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
{alerts.map((alert) => {
|
||||
const hasAccordion =
|
||||
alert.type === "incoming_tx" ||
|
||||
alert.type === "outgoing_tx";
|
||||
const isOpen = !!openAccordions[alert.id];
|
||||
const edit = getThresholdEdit(alert);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.minimum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Min: {alert.minimum} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.maximum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Max: {alert.maximum} ETH
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status:{" "}
|
||||
{alert.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(
|
||||
alert.id,
|
||||
alert.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{alert.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleDeleteAlert(alert.id)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(alert.id, alert.enabled)
|
||||
}
|
||||
>
|
||||
{alert.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteAlert(alert.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{hasAccordion && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="alerts__accordion-toggle"
|
||||
onClick={() =>
|
||||
toggleAccordion(alert.id)
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
Add optional minimum and maximum
|
||||
threshold values
|
||||
</button>
|
||||
<div
|
||||
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
|
||||
>
|
||||
<div className="alerts__threshold-inputs">
|
||||
<Input
|
||||
label="Minimum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.minimum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"minimum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No minimum"
|
||||
/>
|
||||
<Input
|
||||
label="Maximum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.maximum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"maximum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No maximum"
|
||||
/>
|
||||
</div>
|
||||
<div className="alerts__threshold-actions">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleSaveThresholds(alert.id)
|
||||
}
|
||||
className="btn--sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user