diff --git a/src/components/dashboard/Settings.jsx b/src/components/dashboard/Settings.jsx index 83a9038..3f5ddf2 100644 --- a/src/components/dashboard/Settings.jsx +++ b/src/components/dashboard/Settings.jsx @@ -2,9 +2,8 @@ import React, { useMemo, useState, useEffect } from "react"; import { useSettings } from "../../hooks/useSettings"; import { useStore } from "../../lib/store"; import { getEnvironmentConfig } from "../../lib/config"; -import { updateCustomNetworkConfig } from "../../lib/stellar"; - -const SESSION_API_KEY = 'stellar_custom_api_key'; +import { saveAlertRule, getAlertRules, deleteAlertRule } from "../../lib/alertRulesDb"; // Import IndexedDB helpers +import { ALERT_RULE_TYPE, ALERT_CHANNEL } from "../../lib/alerts"; // Import alert types function FieldLabel({ children }) { return ( @@ -58,16 +57,22 @@ export default function Settings() { const [apiKey, setApiKey] = useState(() => sessionStorage.getItem(SESSION_API_KEY) || ""); const baseline = useMemo(() => getEnvironmentConfig(), []); - function handleApiKeyChange(val) { - setApiKey(val); - if (val) { - sessionStorage.setItem(SESSION_API_KEY, val); - updateCustomNetworkConfig({ customHeaders: { Authorization: `Bearer ${val}` } }); - } else { - sessionStorage.removeItem(SESSION_API_KEY); - updateCustomNetworkConfig({ customHeaders: {} }); + // State for Alert Rules + const [alertRules, setAlertRules] = useState([]); + const [newRuleType, setNewRuleType] = useState(ALERT_RULE_TYPE.BALANCE_LOW); + const [newRuleThreshold, setNewRuleThreshold] = useState(0); + const [newRuleAssetCode, setNewRuleAssetCode] = useState("XLM"); + const [newRuleChannel, setNewRuleChannel] = useState(ALERT_CHANNEL.EFFECTS); + const [newRuleAccount, setNewRuleAccount] = useState(""); // Optional: specific account for the rule + + // Load alert rules on component mount + useEffect(() => { + async function loadRules() { + const rules = await getAlertRules(); + setAlertRules(rules); } - } + loadRules(); + }, []); function handleSaveProfile() { const name = profileName.trim() || activeProfileName; @@ -177,6 +182,22 @@ export default function Settings() { }); } + async function handleAddAlertRule() { + if (newRuleThreshold < 0) { + alert("Threshold cannot be negative."); + return; + } + const newRule = { id: `rule-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, type: newRuleType, threshold: Number(newRuleThreshold), assetCode: newRuleAssetCode.trim().toUpperCase(), channel: newRuleChannel, account: newRuleAccount.trim() || undefined }; + await saveAlertRule(newRule); + setAlertRules(await getAlertRules()); // Refresh list + setNewRuleThreshold(0); setNewRuleAssetCode("XLM"); setNewRuleAccount(""); // Reset form fields + } + + async function handleDeleteAlertRule(ruleId) { + await deleteAlertRule(ruleId); + setAlertRules(await getAlertRules()); // Refresh list + } + return (
@@ -244,6 +265,144 @@ export default function Settings() {
+ {/* New section for Alert Rules */} +
+ Alert Rules +
+ {alertRules.length === 0 ? ( +
No alert rules configured.
+ ) : ( + alertRules.map((rule) => ( +
+ + {rule.type.replace(/_/g, ' ')}: {rule.threshold} {rule.assetCode} (Channel: {rule.channel}) {rule.account ? `(Account: ${rule.account})` : ''} + + +
+ )) + )} +
+ +
+ + + + + + + + + +
+ + +
+
Configuration Profiles diff --git a/src/hooks/useAccountStream.ts b/src/hooks/useAccountStream.ts index 1ce2672..0a88a31 100644 --- a/src/hooks/useAccountStream.ts +++ b/src/hooks/useAccountStream.ts @@ -13,6 +13,8 @@ import type { AccountStreamEvent, StreamStatus, } from '../lib/websocket/StreamTypes' +import { evaluateEventRules, ALERT_RULE_TYPE, ALERT_CHANNEL } from '../lib/alerts'; // Import new evaluation function and types +} from '../lib/websocket/StreamTypes' export interface UseAccountStreamOptions { channels?: AccountStreamChannel[] @@ -49,12 +51,22 @@ export function useAccountStream( const [status, setStatus] = useState('idle') const [lastEventAt, setLastEventAt] = useState(null) const [errored, setErrored] = useState(false) + const [activeAlertRules, setActiveAlertRules] = useState([]); // New state for active alert rules // Latest channels list — kept in a ref so we don't re-subscribe on prop churn // when the array is reference-new but value-equal. const channelsRef = useRef(channels) channelsRef.current = channels + useEffect(() => { + // Load alert rules from IndexedDB on mount + async function loadRules() { + const rules = await getAlertRules(); + setActiveAlertRules(rules); + } + loadRules(); + }, []); // Empty dependency array means this runs once on mount + useEffect(() => { if (!accountId) { setStatus('idle') @@ -76,6 +88,10 @@ export function useAccountStream( if (emitNotifications) { const notif = summarizeEvent(event, accountId) if (notif) notificationStore.push(notif) + + // Evaluate custom alert rules + const triggeredAlerts = evaluateEventRules(event, activeAlertRules, accountId); + triggeredAlerts.forEach(alert => notificationStore.push(alert)); } }, { @@ -104,7 +120,7 @@ export function useAccountStream( return () => { for (const cleanup of unsubscribers) cleanup() } - }, [accountId, network, channels, options.cursor, bufferSize, emitNotifications]) + }, [accountId, network, channels, options.cursor, bufferSize, emitNotifications, activeAlertRules]) // Add activeAlertRules to dependencies return { events, status, lastEventAt, errored } } diff --git a/src/lib/alertRulesDb.js b/src/lib/alertRulesDb.js new file mode 100644 index 0000000..9d8904e --- /dev/null +++ b/src/lib/alertRulesDb.js @@ -0,0 +1,60 @@ +import { openDB } from 'idb'; // Using idb for simpler IndexedDB interactions + +const DB_NAME = 'stellar-dev-dashboard'; +const DB_VERSION = 1; +const STORE_NAME = 'alert-rules'; + +let dbPromise; + +function initDb() { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }, + }); + } + return dbPromise; +} + +/** + * Saves an alert rule to IndexedDB. + * @param {AlertRule} rule - The alert rule object to save. + * @returns {Promise} The ID of the saved rule. + */ +export async function saveAlertRule(rule) { + const db = await initDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await tx.store.put(rule); + await tx.done; + return rule.id; +} + +/** + * Reads all alert rules from IndexedDB. + * @returns {Promise} An array of alert rule objects. + */ +export async function getAlertRules() { + const db = await initDb(); + const tx = db.transaction(STORE_NAME, 'readonly'); + const rules = await tx.store.getAll(); + await tx.done; + return rules; +} + +/** + * Deletes an alert rule from IndexedDB by its ID. + * @param {string} ruleId - The ID of the rule to delete. + * @returns {Promise} + */ +export async function deleteAlertRule(ruleId) { + const db = await initDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await tx.store.delete(ruleId); + await tx.done; +} + +// Initialize the database on module load +initDb(); \ No newline at end of file diff --git a/src/lib/alerts.js b/src/lib/alerts.js index 0029590..efdb221 100644 --- a/src/lib/alerts.js +++ b/src/lib/alerts.js @@ -4,6 +4,31 @@ const ALERT_SEVERITY = { CRITICAL: "critical", }; +const ALERT_RULE_TYPE = { + BALANCE_LOW: "balance_low", + BALANCE_HIGH: "balance_high", + PAYMENT_INCOMING: "payment_incoming", + PAYMENT_OUTGOING: "payment_outgoing", + TRANSACTION_FAILED: "transaction_failed", + // Add other types as needed +}; + +const ALERT_CHANNEL = { + PAYMENTS: "payments", + EFFECTS: "effects", + TRANSACTIONS: "transactions", +}; + +/** + * @typedef {object} AlertRule + * @property {string} id - Unique ID for the rule + * @property {ALERT_RULE_TYPE} type - Type of alert rule (e.g., 'balance_low', 'payment_incoming') + * @property {number} threshold - Numeric threshold for the rule (e.g., 100 for balance, 0 for payment) + * @property {string} assetCode - Asset code for balance/payment rules (e.g., 'XLM', 'USD') + * @property {ALERT_CHANNEL} channel - The stream channel this rule applies to + * @property {string} [account] - Optional account ID if the rule is specific to an account + */ + export function evaluateAlertRules(snapshot, score) { const alerts = []; const memory = snapshot?.memory; @@ -41,6 +66,122 @@ export function evaluateAlertRules(snapshot, score) { return alerts; } +/** + * Evaluates an incoming streaming event against a collection of user-defined alert rules. + * @param {AccountStreamEvent} incomingEvent - The event received from the account stream. + * @param {AlertRule[]} activeRules - An array of active alert rules. + * @param {string} watchedAccount - The account ID being watched. + * @returns {Array} An array of alert payloads for violated rules. + */ +export function evaluateEventRules(incomingEvent, activeRules, watchedAccount) { + const triggeredAlerts = []; + const record = incomingEvent.record; + + for (const rule of activeRules) { + // Filter rules by channel and optionally by specific account if rule.account is set + if (rule.channel !== incomingEvent.channel) { + continue; + } + if (rule.account && rule.account !== watchedAccount) { + continue; + } + + switch (rule.type) { + case ALERT_RULE_TYPE.BALANCE_LOW: + case ALERT_RULE_TYPE.BALANCE_HIGH: { + // This rule type would typically apply to 'effects' channel, specifically 'account_credited'/'account_debited' + // or a dedicated balance stream if available. For now, we'll assume it's derived from effects. + // This is a simplification; a real-world scenario might need a dedicated balance stream or periodic checks. + // For now, we'll check the *transaction amount* against the threshold. + // This is a significant simplification as it doesn't reflect the actual account balance. + if (incomingEvent.channel === ALERT_CHANNEL.EFFECTS) { + const type = String(record.type); + if (type === 'account_credited' || type === 'account_debited') { + const assetCode = String(record.asset_code || 'XLM'); + if (assetCode === rule.assetCode) { + const amount = parseFloat(record.amount); + if (rule.type === ALERT_RULE_TYPE.BALANCE_LOW && amount < rule.threshold) { + triggeredAlerts.push({ + id: `balance-low-${rule.assetCode}-${rule.id}`, + severity: ALERT_SEVERITY.CRITICAL, + title: `Low ${rule.assetCode} balance detected`, + description: `Transaction amount ${amount} is below threshold ${rule.threshold}.`, + }); + } else if (rule.type === ALERT_RULE_TYPE.BALANCE_HIGH && amount > rule.threshold) { + triggeredAlerts.push({ + id: `balance-high-${rule.assetCode}-${rule.id}`, + severity: ALERT_SEVERITY.WARNING, + title: `High ${rule.assetCode} balance detected`, + description: `Transaction amount ${amount} is above threshold ${rule.threshold}.`, + }); + } + } + } + } + break; + } + case ALERT_RULE_TYPE.PAYMENT_INCOMING: { + if (incomingEvent.channel === ALERT_CHANNEL.PAYMENTS) { + const to = String(record.to); + if (to === watchedAccount) { + const amount = parseFloat(record.amount); + const assetCode = String(record.asset_code || 'XLM'); + if (assetCode === rule.assetCode && amount >= rule.threshold) { + triggeredAlerts.push({ + id: `incoming-payment-${rule.assetCode}-${rule.id}`, + severity: ALERT_SEVERITY.INFO, + title: `Incoming ${assetCode} payment received`, + description: `Received ${amount} ${assetCode} from ${truncateAddr(String(record.from))}.`, + }); + } + } + } + break; + } + case ALERT_RULE_TYPE.PAYMENT_OUTGOING: { + if (incomingEvent.channel === ALERT_CHANNEL.PAYMENTS) { + const from = String(record.from); + if (from === watchedAccount) { + const amount = parseFloat(record.amount); + const assetCode = String(record.asset_code || 'XLM'); + if (assetCode === rule.assetCode && amount >= rule.threshold) { + triggeredAlerts.push({ + id: `outgoing-payment-${rule.assetCode}-${rule.id}`, + severity: ALERT_SEVERITY.INFO, + title: `Outgoing ${assetCode} payment sent`, + description: `Sent ${amount} ${assetCode} to ${truncateAddr(String(record.to))}.`, + }); + } + } + } + break; + } + case ALERT_RULE_TYPE.TRANSACTION_FAILED: { + if (incomingEvent.channel === ALERT_CHANNEL.TRANSACTIONS) { + const successful = record.successful; + if (successful === false) { // Assuming threshold 0 means any failure + triggeredAlerts.push({ + id: `transaction-failed-${rule.id}`, + severity: ALERT_SEVERITY.CRITICAL, + title: `Transaction failed`, + description: `Transaction ${String(record.id).slice(0, 8)}... failed.`, + }); + } + } + break; + } + // Add more rule types as needed + } + } + return triggeredAlerts; +} + +// Helper function from useAccountStream.ts, needed here for description truncation +function truncateAddr(addr) { + if (addr.length <= 12) return addr; + return `${addr.slice(0, 5)}…${addr.slice(-4)}`; +} + export class AlertCenter { constructor() { this.listeners = new Set(); @@ -76,4 +217,4 @@ export class AlertCenter { export const alertCenter = new AlertCenter(); -export { ALERT_SEVERITY }; +export { ALERT_SEVERITY, ALERT_RULE_TYPE, ALERT_CHANNEL };