enhanced alert granularity with min/max thresholds

This commit is contained in:
KS Jannette
2026-03-06 23:33:12 -05:00
parent d3530deb46
commit 0fb4dcd7d1
10 changed files with 479 additions and 61 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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" : ""}`}
>
&#9654;
</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>