Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 171 additions & 12 deletions src/components/dashboard/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="animate-in" style={{ display: "flex", flexDirection: "column", gap: "18px" }}>
<div style={{ fontFamily: "var(--font-display)", fontSize: "22px", fontWeight: 700 }}>
Expand Down Expand Up @@ -244,6 +265,144 @@ export default function Settings() {
</div>
</div>

{/* New section for Alert Rules */}
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: "14px", display: "flex", flexDirection: "column", gap: "12px" }}>
<FieldLabel>Alert Rules</FieldLabel>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{alertRules.length === 0 ? (
<div style={{ fontSize: "12px", color: "var(--text-muted)" }}>No alert rules configured.</div>
) : (
alertRules.map((rule) => (
<div key={rule.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "12px", color: "var(--text-primary)", background: "var(--bg-elevated)", padding: "8px", borderRadius: "var(--radius-sm)" }}>
<span>
<strong>{rule.type.replace(/_/g, ' ')}</strong>: {rule.threshold} {rule.assetCode} (Channel: {rule.channel}) {rule.account ? `(Account: ${rule.account})` : ''}
</span>
<button
onClick={() => handleDeleteAlertRule(rule.id)}
style={{
padding: "4px 8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--red-dim)",
background: "var(--red-glow)",
color: "var(--red)",
fontSize: "10px",
cursor: "pointer",
}}
>
Delete
</button>
</div>
))
)}
</div>

<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "10px", marginTop: "10px" }}>
<label style={{ display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--text-secondary)" }}>
Alert Type
<select
value={newRuleType}
onChange={(e) => setNewRuleType(e.target.value)}
style={{
padding: "8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)",
background: "var(--bg-elevated)",
color: "var(--text-primary)",
}}
>
{Object.values(ALERT_RULE_TYPE).map((type) => (
<option key={type} value={type}>{type.replace(/_/g, ' ')}</option>
))}
</select>
</label>

<label style={{ display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--text-secondary)" }}>
Threshold
<input
type="number"
value={newRuleThreshold}
onChange={(e) => setNewRuleThreshold(Number(e.target.value))}
style={{
padding: "8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)",
background: "var(--bg-elevated)",
color: "var(--text-primary)",
}}
/>
</label>

<label style={{ display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--text-secondary)" }}>
Asset Code
<input
type="text"
value={newRuleAssetCode}
onChange={(e) => setNewRuleAssetCode(e.target.value)}
placeholder="XLM, USD, etc."
style={{
padding: "8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)",
background: "var(--bg-elevated)",
color: "var(--text-primary)",
}}
/>
</label>

<label style={{ display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--text-secondary)" }}>
Channel
<select
value={newRuleChannel}
onChange={(e) => setNewRuleChannel(e.target.value)}
style={{
padding: "8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)",
background: "var(--bg-elevated)",
color: "var(--text-primary)",
}}
>
{Object.values(ALERT_CHANNEL).map((channel) => (
<option key={channel} value={channel}>{channel}</option>
))}
</select>
</label>

<label style={{ display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--text-secondary)" }}>
Specific Account (Optional)
<input
type="text"
value={newRuleAccount}
onChange={(e) => setNewRuleAccount(e.target.value)}
placeholder="Account ID"
style={{
padding: "8px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)",
background: "var(--bg-elevated)",
color: "var(--text-primary)",
}}
/>
</label>
</div>

<button
onClick={handleAddAlertRule}
style={{
marginTop: "10px",
padding: "8px 10px",
borderRadius: "var(--radius-sm)",
border: "1px solid var(--green-dim)",
background: "var(--green-glow)",
color: "var(--green)",
fontSize: "12px",
cursor: "pointer",
}}
>
Add Alert Rule
</button>
</div>

<div style={{ background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: "14px", display: "flex", flexDirection: "column", gap: "12px" }}>
<FieldLabel>Configuration Profiles</FieldLabel>

Expand Down
18 changes: 17 additions & 1 deletion src/hooks/useAccountStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
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'

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Declaration or statement expected.

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Unexpected keyword or identifier.

export interface UseAccountStreamOptions {
channels?: AccountStreamChannel[]
Expand Down Expand Up @@ -49,12 +51,22 @@
const [status, setStatus] = useState<StreamStatus>('idle')
const [lastEventAt, setLastEventAt] = useState<number | null>(null)
const [errored, setErrored] = useState(false)
const [activeAlertRules, setActiveAlertRules] = useState<AlertRule[]>([]); // 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<AccountStreamChannel[]>(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')
Expand All @@ -76,6 +88,10 @@
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));
}
},
{
Expand Down Expand Up @@ -104,7 +120,7 @@
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 }
}
Expand Down
60 changes: 60 additions & 0 deletions src/lib/alertRulesDb.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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<AlertRule[]>} 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<void>}
*/
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();
Loading
Loading