Files
koin_ping_0.2.0/frontend/src/pages/Alerts.jsx
2026-03-02 03:36:21 -05:00

523 lines
22 KiB
JavaScript

import { useState, useEffect } from "react";
import AlertForm from "../components/AlertForm";
import Button from "../components/Button";
import Input from "../components/Input";
import { getAddresses } from "../api/addresses";
import {
getAlerts,
createAlert,
updateAlertStatus,
deleteAlert,
} from "../api/alerts";
import {
getNotificationConfig,
updateNotificationConfig,
testNotificationChannels,
setupEmail,
sendEmailDigest,
} from "../api/notificationConfig";
import "./Alerts.css";
export default function Alerts() {
const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null);
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [telegramBotToken, setTelegramBotToken] = useState("");
const [telegramChatId, setTelegramChatId] = useState("");
const [email, setEmail] = useState("");
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null);
const [notificationSuccess, setNotificationSuccess] = useState(null);
const [testingChannels, setTestingChannels] = useState(false);
const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const addressData = await getAddresses();
setAddresses(addressData);
if (addressData.length > 0) {
setSelectedAddressId(addressData[0].id);
}
const configData = await getNotificationConfig();
setNotificationEnabled(
configData.notification_enabled !== false,
);
setDiscordWebhookUrl(configData.discord_webhook_url || "");
setTelegramBotToken(configData.telegram_bot_token || "");
setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || "");
const hasSaved =
!!configData.discord_webhook_url ||
!!configData.telegram_bot_token ||
!!configData.telegram_chat_id ||
!!configData.email ||
!!configData.slack_webhook_url;
setHasExistingConfig(hasSaved);
} catch (err) {
setError(err.message);
console.error("Failed to fetch data:", err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
useEffect(() => {
if (!selectedAddressId) {
setAlerts([]);
return;
}
async function fetchAlerts() {
try {
const data = await getAlerts(selectedAddressId);
setAlerts(data);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alerts:", err);
}
}
fetchAlerts();
}, [selectedAddressId]);
async function handleAlertSubmit(data) {
if (!selectedAddressId) return;
try {
const newAlert = await createAlert(selectedAddressId, data);
setAlerts((prev) => [...prev, newAlert]);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to create alert:", err);
}
}
async function handleToggleAlert(alertId, currentStatus) {
try {
const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)),
);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update alert:", err);
}
}
async function handleDeleteAlert(alertId) {
try {
await deleteAlert(alertId);
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete alert:", err);
}
}
async function handleSaveNotificationConfig() {
if (hasExistingConfig) {
const confirmed = window.confirm(
"This will overwrite your previously saved notification settings. Continue?",
);
if (!confirmed) return;
}
try {
setNotificationLoading(true);
setNotificationError(null);
setNotificationSuccess(null);
const config = {
notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
};
await updateNotificationConfig(config);
setHasExistingConfig(true);
setNotificationSuccess("Notification settings saved!");
setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) {
setNotificationError(err.message);
console.error("Failed to save notification config:", err);
} finally {
setNotificationLoading(false);
}
}
async function handleTestChannels() {
try {
setTestingChannels(true);
setNotificationError(null);
setNotificationSuccess(null);
const data = await testNotificationChannels();
const results = data.results || [];
const failed = results.filter((r) => !r.success);
const succeeded = results.filter((r) => r.success);
if (failed.length === 0 && succeeded.length > 0) {
setNotificationSuccess(
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
);
} else if (failed.length > 0 && succeeded.length > 0) {
setNotificationSuccess(
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} else if (failed.length > 0) {
setNotificationError(
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
}
setTimeout(() => {
setNotificationSuccess(null);
setNotificationError(null);
}, 6000);
} catch (err) {
setNotificationError(err.message);
} finally {
setTestingChannels(false);
}
}
async function handleSetupEmail() {
try {
setSettingUpEmail(true);
setNotificationError(null);
setNotificationSuccess(null);
await handleSaveNotificationConfig();
const result = await setupEmail();
setNotificationSuccess(
result.message || "Confirmation email sent!",
);
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSettingUpEmail(false);
}
}
async function handleSendDigest() {
try {
setSendingDigest(true);
setNotificationError(null);
setNotificationSuccess(null);
const result = await sendEmailDigest();
setNotificationSuccess(result.message || "Digest email sent!");
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSendingDigest(false);
}
}
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
if (loading) {
return <div className="page">Loading addresses...</div>;
}
if (addresses.length === 0) {
return (
<div className="page">
<p>No addresses tracked yet. Add an address first to create alerts.</p>
</div>
);
}
return (
<div className="page page--wide">
<h1 className="mb-xl">Alert Rules & Notifications</h1>
<div className="alerts-grid">
{/* LEFT COLUMN: Alert Rules */}
<div>
<h2 className="mt-0">Alert Rules</h2>
<div className="mb-xl">
<label className="form-label mb-sm">
<strong>Select Address:</strong>
</label>
<select
value={selectedAddressId || ""}
onChange={(e) =>
setSelectedAddressId(Number(e.target.value))
}
className="form-select"
>
{addresses.map((addr) => (
<option key={addr.id} value={addr.id}>
{addr.label || "Unlabeled"} - {addr.address}
</option>
))}
</select>
</div>
{selectedAddress && (
<>
<div className="alerts__address-info">
<div className="text-sm text-muted">
Managing alerts for:
</div>
<div className="text-bold">
{selectedAddress.label || "Unlabeled"}
</div>
<div className="text-mono text-sm text-muted">
{selectedAddress.address}
</div>
</div>
<div className="mb-xl">
<h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} />
</div>
<div>
<h3>Active Alert Rules</h3>
{error && (
<p className="text-error">Error: {error}</p>
)}
{alerts.length === 0 ? (
<p className="text-dimmed">
No alert rules defined yet. Create one above.
</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
</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>
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
{/* RIGHT COLUMN: Notification Settings */}
<div>
<h2 className="mt-0">Notification Settings</h2>
{notificationSuccess && (
<div className="alert alert--success">{notificationSuccess}</div>
)}
{notificationError && (
<div className="alert alert--error">{notificationError}</div>
)}
{/* Master toggle */}
<div className="alerts__toggle-panel">
<label className="alerts__toggle-label">
<input
type="checkbox"
checked={notificationEnabled}
onChange={(e) =>
setNotificationEnabled(e.target.checked)
}
className="alerts__toggle-checkbox"
/>
<span className="text-bold">Enable Notifications</span>
</label>
<div className="alerts__toggle-hint">
Master switch for all notification channels
</div>
</div>
{/* All channel settings -- hidden when master toggle is off */}
{notificationEnabled && (
<>
{/* Telegram */}
<div className="section">
<h3 className="mt-0 mb-md">Telegram</h3>
<Input
label="Bot Token"
value={telegramBotToken}
onChange={setTelegramBotToken}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
/>
<Input
label="Chat ID"
value={telegramChatId}
onChange={setTelegramChatId}
placeholder="-1001234567890"
/>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div className="section">
<h3 className="mt-0 mb-md">Email</h3>
<Input
label="Email Address"
type="email"
value={email}
onChange={setEmail}
placeholder="you@example.com"
/>
<div className="help-link text-dimmed mb-md">
Alert notifications and digests will be sent to this address
</div>
<div className="alerts__email-buttons">
<Button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
className="btn--sm"
>
{settingUpEmail ? "Setting up..." : "Verify Email"}
</Button>
<Button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
variant="secondary"
className="btn--sm"
>
{sendingDigest ? "Sending..." : "Send Digest Now"}
</Button>
</div>
</div>
{/* Discord */}
<div className="section">
<h3 className="mt-0 mb-md">Discord</h3>
<Input
label="Discord Webhook URL"
value={discordWebhookUrl}
onChange={setDiscordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
/>
<a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to get a Discord webhook URL
</a>
</div>
{/* Slack */}
<div className="section">
<h3 className="mt-0 mb-md">Slack</h3>
<Input
label="Slack Webhook URL"
value={slackWebhookUrl}
onChange={setSlackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
/>
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to set up Slack Incoming Webhooks
</a>
</div>
{/* Save & Test buttons */}
<div className="alerts__save-test">
<Button
onClick={handleSaveNotificationConfig}
disabled={notificationLoading}
className="btn--lg"
>
{notificationLoading ? "Saving..." : "Save Settings"}
</Button>
<Button
onClick={handleTestChannels}
disabled={testingChannels}
variant="success"
className="btn--lg"
>
{testingChannels ? "Testing..." : "Test All Channels"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
function formatAlertType(type) {
const labels = {
incoming_tx: "Incoming transaction",
outgoing_tx: "Outgoing transaction",
large_transfer: "Large transfer",
balance_below: "Balance below threshold",
};
return labels[type] || type;
}