Skip to main content

ringkernel_txmon/gui/
app.rs

1//! Main iced Application for transaction monitoring.
2
3use super::theme::{colors, severity_color};
4use crate::factory::{CustomerStats, FactoryState, GeneratorConfig, TransactionGenerator};
5use crate::monitoring::MonitoringEngine;
6use crate::types::{AlertType, MonitoringAlert, Transaction};
7
8use iced::widget::{button, column, container, row, rule, scrollable, slider, space, text, Column};
9use iced::{time, Alignment, Color, Element, Length, Subscription, Theme};
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::time::{Duration, Instant};
12
13/// Maximum number of recent transactions to display.
14const MAX_RECENT_TRANSACTIONS: usize = 50;
15/// Maximum number of recent alerts to display.
16const MAX_RECENT_ALERTS: usize = 30;
17
18/// The main transaction monitoring application.
19pub struct TxMonApp {
20    // Factory state
21    factory_state: FactoryState,
22    generator: TransactionGenerator,
23
24    // Monitoring state
25    engine: MonitoringEngine,
26
27    // Data buffers
28    recent_transactions: VecDeque<Transaction>,
29    recent_alerts: VecDeque<MonitoringAlert>,
30
31    // Statistics
32    total_transactions: u64,
33    total_alerts: u64,
34    flagged_transactions: u64, // Transactions that triggered at least one alert
35    transactions_per_second: f32,
36    alerts_per_second: f32,
37    customer_stats: CustomerStats,
38
39    // Performance tracking
40    last_stats_update: Instant,
41    transactions_since_update: u64,
42    alerts_since_update: u64,
43
44    // UI state
45    transactions_per_second_slider: u32,
46    suspicious_rate_slider: u8,
47    selected_alert: Option<usize>,
48
49    // Alert tracking per customer
50    customer_alert_counts: HashMap<u64, u32>,
51}
52
53/// Messages for the application.
54#[derive(Debug, Clone)]
55pub enum Message {
56    // Factory controls
57    StartFactory,
58    PauseFactory,
59    StopFactory,
60    SetTransactionRate(u32),
61    SetSuspiciousRate(u8),
62
63    // Processing
64    Tick,
65
66    // UI interactions
67    SelectAlert(usize),
68    ClearAlerts,
69}
70
71impl TxMonApp {
72    /// Create a new application instance.
73    pub fn new() -> Self {
74        let config = GeneratorConfig::default();
75        let generator = TransactionGenerator::new(config.clone());
76        let customer_stats = generator.customer_stats();
77
78        Self {
79            factory_state: FactoryState::Stopped,
80            generator,
81            engine: MonitoringEngine::default(),
82            recent_transactions: VecDeque::with_capacity(MAX_RECENT_TRANSACTIONS),
83            recent_alerts: VecDeque::with_capacity(MAX_RECENT_ALERTS),
84            total_transactions: 0,
85            total_alerts: 0,
86            flagged_transactions: 0,
87            transactions_per_second: 0.0,
88            alerts_per_second: 0.0,
89            customer_stats,
90            last_stats_update: Instant::now(),
91            transactions_since_update: 0,
92            alerts_since_update: 0,
93            transactions_per_second_slider: config.transactions_per_second,
94            suspicious_rate_slider: config.suspicious_rate,
95            selected_alert: None,
96            customer_alert_counts: HashMap::new(),
97        }
98    }
99
100    /// Update the application state based on a message.
101    pub fn update(&mut self, message: Message) {
102        match message {
103            Message::StartFactory => {
104                self.factory_state = FactoryState::Running;
105            }
106
107            Message::PauseFactory => {
108                self.factory_state = FactoryState::Paused;
109            }
110
111            Message::StopFactory => {
112                self.factory_state = FactoryState::Stopped;
113                self.transactions_per_second = 0.0;
114                self.alerts_per_second = 0.0;
115            }
116
117            Message::SetTransactionRate(rate) => {
118                self.transactions_per_second_slider = rate;
119                let mut config = self.generator.config().clone();
120                config.transactions_per_second = rate;
121                self.generator.set_config(config);
122            }
123
124            Message::SetSuspiciousRate(rate) => {
125                self.suspicious_rate_slider = rate;
126                let mut config = self.generator.config().clone();
127                config.suspicious_rate = rate;
128                self.generator.set_config(config);
129            }
130
131            Message::Tick => {
132                if self.factory_state == FactoryState::Running {
133                    self.process_tick();
134                }
135                self.update_stats();
136            }
137
138            Message::SelectAlert(idx) => {
139                self.selected_alert = Some(idx);
140            }
141
142            Message::ClearAlerts => {
143                self.recent_alerts.clear();
144                self.selected_alert = None;
145            }
146        }
147    }
148
149    /// Process a simulation tick.
150    fn process_tick(&mut self) {
151        // Calculate how many transactions to generate this tick
152        // At 60 FPS, we need to generate TPS/60 transactions per tick
153        let config = self.generator.config();
154        let batch_size = (config.transactions_per_second as f32 / 60.0).ceil() as usize;
155        let batch_size = batch_size.max(1).min(config.batch_size as usize);
156
157        // Generate and process transactions
158        let (transactions, profiles) = self.generator.generate_batch();
159        let transactions: Vec<_> = transactions.into_iter().take(batch_size).collect();
160        let profiles: Vec<_> = profiles.into_iter().take(batch_size).collect();
161
162        let alerts = self.engine.process_batch(&transactions, &profiles);
163
164        // Count unique transactions that triggered alerts and track per-customer
165        let mut flagged_tx_ids: HashSet<u64> = HashSet::new();
166        for alert in &alerts {
167            flagged_tx_ids.insert(alert.transaction_id);
168            // Track alerts per customer
169            *self
170                .customer_alert_counts
171                .entry(alert.customer_id)
172                .or_insert(0) += 1;
173        }
174        let flagged_count = flagged_tx_ids.len() as u64;
175
176        // Update counters
177        self.total_transactions += transactions.len() as u64;
178        self.total_alerts += alerts.len() as u64;
179        self.flagged_transactions += flagged_count;
180        self.transactions_since_update += transactions.len() as u64;
181        self.alerts_since_update += alerts.len() as u64;
182
183        // Store recent transactions
184        for tx in transactions {
185            if self.recent_transactions.len() >= MAX_RECENT_TRANSACTIONS {
186                self.recent_transactions.pop_front();
187            }
188            self.recent_transactions.push_back(tx);
189        }
190
191        // Store recent alerts (newest first)
192        for alert in alerts {
193            if self.recent_alerts.len() >= MAX_RECENT_ALERTS {
194                self.recent_alerts.pop_back();
195            }
196            self.recent_alerts.push_front(alert);
197        }
198    }
199
200    /// Update statistics (called every tick).
201    fn update_stats(&mut self) {
202        let elapsed = self.last_stats_update.elapsed();
203        if elapsed >= Duration::from_millis(500) {
204            let secs = elapsed.as_secs_f32();
205            self.transactions_per_second = self.transactions_since_update as f32 / secs;
206            self.alerts_per_second = self.alerts_since_update as f32 / secs;
207
208            self.transactions_since_update = 0;
209            self.alerts_since_update = 0;
210            self.last_stats_update = Instant::now();
211        }
212    }
213
214    /// Build the view for the application.
215    pub fn view(&self) -> Element<'_, Message> {
216        let content = column![
217            self.view_header(),
218            rule::horizontal(1),
219            row![self.view_left_panel(), self.view_right_panel(),].spacing(20),
220        ]
221        .padding(20)
222        .spacing(15);
223
224        container(content)
225            .width(Length::Fill)
226            .height(Length::Fill)
227            .style(|_theme| container::Style {
228                background: Some(colors::BACKGROUND.into()),
229                ..Default::default()
230            })
231            .into()
232    }
233
234    /// View: Header with title and state indicator.
235    fn view_header(&self) -> Element<'_, Message> {
236        let state_color = match self.factory_state {
237            FactoryState::Running => colors::STATE_RUNNING,
238            FactoryState::Paused => colors::STATE_PAUSED,
239            FactoryState::Stopped => colors::STATE_STOPPED,
240        };
241
242        row![
243            text("RingKernel Transaction Monitor")
244                .size(24)
245                .color(colors::TEXT_PRIMARY),
246            space().width(Length::Fill),
247            container(
248                text(format!(" {} ", self.factory_state))
249                    .size(14)
250                    .color(colors::BACKGROUND)
251            )
252            .padding([4, 12])
253            .style(move |_theme| container::Style {
254                background: Some(state_color.into()),
255                border: iced::Border {
256                    radius: 4.0.into(),
257                    ..Default::default()
258                },
259                ..Default::default()
260            }),
261        ]
262        .align_y(Alignment::Center)
263        .into()
264    }
265
266    /// View: Left panel (controls, accounts, transactions).
267    fn view_left_panel(&self) -> Element<'_, Message> {
268        column![
269            self.view_factory_controls(),
270            space().height(15),
271            self.view_account_overview(),
272            space().height(15),
273            self.view_transaction_feed(),
274        ]
275        .width(Length::FillPortion(3))
276        .into()
277    }
278
279    /// View: Right panel (statistics, alerts).
280    fn view_right_panel(&self) -> Element<'_, Message> {
281        column![
282            self.view_statistics(),
283            space().height(10),
284            self.view_alert_legend(),
285            space().height(10),
286            self.view_high_risk_accounts(),
287            space().height(10),
288            self.view_alerts_panel(),
289        ]
290        .width(Length::FillPortion(2))
291        .into()
292    }
293
294    /// View: Factory controls panel.
295    fn view_factory_controls(&self) -> Element<'_, Message> {
296        let is_running = self.factory_state == FactoryState::Running;
297        let is_stopped = self.factory_state == FactoryState::Stopped;
298
299        let controls = row![
300            button(text(if is_running { "Pause" } else { "Start" }).size(14))
301                .padding([8, 16])
302                .on_press(if is_running {
303                    Message::PauseFactory
304                } else {
305                    Message::StartFactory
306                }),
307            button(text("Stop").size(14))
308                .padding([8, 16])
309                .on_press_maybe(if !is_stopped {
310                    Some(Message::StopFactory)
311                } else {
312                    None
313                }),
314        ]
315        .spacing(10);
316
317        let rate_slider = row![
318            text("Rate:").size(14).color(colors::TEXT_SECONDARY),
319            slider(
320                10..=5000,
321                self.transactions_per_second_slider,
322                Message::SetTransactionRate
323            )
324            .width(150),
325            text(format!("{} tx/s", self.transactions_per_second_slider))
326                .size(14)
327                .color(colors::TEXT_PRIMARY),
328        ]
329        .spacing(10)
330        .align_y(Alignment::Center);
331
332        let suspicious_slider = row![
333            text("Suspicious:").size(14).color(colors::TEXT_SECONDARY),
334            slider(
335                0..=50,
336                self.suspicious_rate_slider,
337                Message::SetSuspiciousRate
338            )
339            .width(150),
340            text(format!("{}%", self.suspicious_rate_slider))
341                .size(14)
342                .color(colors::TEXT_PRIMARY),
343        ]
344        .spacing(10)
345        .align_y(Alignment::Center);
346
347        self.panel(
348            "Factory Controls",
349            column![controls, rate_slider, suspicious_slider,].spacing(12),
350        )
351    }
352
353    /// View: Account overview panel.
354    fn view_account_overview(&self) -> Element<'_, Message> {
355        let stats = &self.customer_stats;
356
357        let content = column![
358            row![
359                self.stat_item("Active", format!("{}", stats.total)),
360                self.stat_item("Low Risk", format!("{}", stats.low_risk)),
361                self.stat_item("Medium", format!("{}", stats.medium_risk)),
362            ]
363            .spacing(20),
364            row![
365                self.stat_item_colored(
366                    "High Risk",
367                    format!("{}", stats.high_risk),
368                    colors::ALERT_HIGH
369                ),
370                self.stat_item_colored("PEP", format!("{}", stats.pep), colors::ALERT_MEDIUM),
371                self.stat_item_colored(
372                    "EDD Req.",
373                    format!("{}", stats.edd_required),
374                    colors::ALERT_LOW
375                ),
376            ]
377            .spacing(20),
378        ]
379        .spacing(10);
380
381        self.panel("Account Overview", content)
382    }
383
384    /// View: Transaction feed panel.
385    fn view_transaction_feed(&self) -> Element<'_, Message> {
386        // Collect transactions to owned copies to avoid lifetime issues
387        let txs: Vec<_> = self
388            .recent_transactions
389            .iter()
390            .rev()
391            .take(15)
392            .copied()
393            .collect();
394
395        let transactions: Vec<Element<Message>> = txs
396            .into_iter()
397            .map(|tx| Self::view_transaction_row_static(tx))
398            .collect();
399
400        let feed = if transactions.is_empty() {
401            column![text("No transactions yet...")
402                .size(14)
403                .color(colors::TEXT_DISABLED)]
404        } else {
405            Column::with_children(transactions).spacing(4)
406        };
407
408        self.panel("Live Transaction Feed", scrollable(feed).height(200))
409    }
410
411    /// View: Single transaction row (static version for use in closures).
412    fn view_transaction_row_static(tx: Transaction) -> Element<'static, Message> {
413        let time_str = format_timestamp(tx.timestamp);
414        let amount_color = if tx.amount_cents >= 1_000_000 {
415            colors::ALERT_HIGH
416        } else {
417            colors::TEXT_PRIMARY
418        };
419
420        row![
421            text(time_str)
422                .size(12)
423                .color(colors::TEXT_SECONDARY)
424                .width(70),
425            text(format!("#{}", tx.transaction_id % 10000))
426                .size(12)
427                .color(colors::TEXT_SECONDARY)
428                .width(50),
429            text(tx.format_amount())
430                .size(12)
431                .color(amount_color)
432                .width(80),
433            text(format!("-> {}", tx.country_name()))
434                .size(12)
435                .color(colors::TEXT_SECONDARY),
436            space().width(Length::Fill),
437            text(if tx.is_high_value() { "!" } else { "" })
438                .size(12)
439                .color(colors::ALERT_HIGH),
440        ]
441        .spacing(8)
442        .align_y(Alignment::Center)
443        .into()
444    }
445
446    /// View: Statistics panel.
447    fn view_statistics(&self) -> Element<'_, Message> {
448        // Flagged rate = percentage of transactions that triggered at least one alert
449        let flagged_rate = if self.total_transactions > 0 {
450            format!(
451                "{:.2}%",
452                (self.flagged_transactions as f64 / self.total_transactions as f64) * 100.0
453            )
454        } else {
455            "0%".to_string()
456        };
457
458        let content = column![
459            self.stat_row("TPS", format!("{:.0}", self.transactions_per_second)),
460            self.stat_row("Alerts/s", format!("{:.1}", self.alerts_per_second)),
461            rule::horizontal(1),
462            self.stat_row("Total Processed", format_number(self.total_transactions)),
463            self.stat_row("Total Alerts", format_number(self.total_alerts)),
464            self.stat_row("Flagged Tx", format_number(self.flagged_transactions)),
465            rule::horizontal(1),
466            self.stat_row("Flagged Rate", flagged_rate),
467        ]
468        .spacing(8);
469
470        self.panel("Statistics", content)
471    }
472
473    /// View: Alert types legend.
474    fn view_alert_legend(&self) -> Element<'_, Message> {
475        let alert_types = [
476            (
477                AlertType::VelocityBreach,
478                "Too many transactions in time window",
479            ),
480            (
481                AlertType::AmountThreshold,
482                "Transaction exceeds $ threshold",
483            ),
484            (
485                AlertType::StructuredTransaction,
486                "Smurfing: amounts just under threshold",
487            ),
488            (AlertType::GeographicAnomaly, "Unusual destination country"),
489        ];
490
491        let legend_items: Vec<Element<'_, Message>> = alert_types
492            .iter()
493            .map(|(alert_type, description)| {
494                row![
495                    text(alert_type.code()).size(11).color(colors::ACCENT_BLUE),
496                    text(format!(" - {}", description))
497                        .size(11)
498                        .color(colors::TEXT_SECONDARY),
499                ]
500                .into()
501            })
502            .collect();
503
504        self.panel(
505            "Alert Types",
506            Column::with_children(legend_items).spacing(4),
507        )
508    }
509
510    /// View: Top 5 high-risk accounts.
511    fn view_high_risk_accounts(&self) -> Element<'_, Message> {
512        // Get top 5 customers by alert count
513        let mut sorted_customers: Vec<_> = self.customer_alert_counts.iter().collect();
514        sorted_customers.sort_by(|a, b| b.1.cmp(a.1));
515
516        let top_accounts: Vec<Element<'_, Message>> = sorted_customers
517            .iter()
518            .take(5)
519            .enumerate()
520            .map(|(rank, (customer_id, alert_count))| {
521                let rank_color = match rank {
522                    0 => colors::ALERT_CRITICAL,
523                    1 => colors::ALERT_HIGH,
524                    2 => colors::ALERT_MEDIUM,
525                    _ => colors::TEXT_SECONDARY,
526                };
527                row![
528                    text(format!("{}.", rank + 1))
529                        .size(12)
530                        .color(rank_color)
531                        .width(20),
532                    text(format!("Customer #{}", customer_id))
533                        .size(12)
534                        .color(colors::TEXT_PRIMARY),
535                    space().width(Length::Fill),
536                    text(format!("{} alerts", alert_count))
537                        .size(12)
538                        .color(rank_color),
539                ]
540                .spacing(8)
541                .into()
542            })
543            .collect();
544
545        let content = if top_accounts.is_empty() {
546            column![text("No flagged accounts yet")
547                .size(12)
548                .color(colors::TEXT_DISABLED)]
549        } else {
550            Column::with_children(top_accounts).spacing(6)
551        };
552
553        self.panel("Top 5 High-Risk Accounts", content)
554    }
555
556    /// View: Alerts panel.
557    fn view_alerts_panel(&self) -> Element<'_, Message> {
558        let header = row![
559            text("Compliance Alerts")
560                .size(16)
561                .color(colors::TEXT_PRIMARY),
562            space().width(Length::Fill),
563            button(text("Clear").size(12))
564                .padding([4, 8])
565                .on_press(Message::ClearAlerts),
566        ]
567        .align_y(Alignment::Center);
568
569        let alerts: Vec<Element<Message>> = self
570            .recent_alerts
571            .iter()
572            .enumerate()
573            .take(10)
574            .map(|(idx, alert)| self.view_alert_row(idx, alert))
575            .collect();
576
577        let alerts_list = if alerts.is_empty() {
578            column![text("No alerts").size(14).color(colors::TEXT_DISABLED)]
579        } else {
580            Column::with_children(alerts).spacing(8)
581        };
582
583        container(
584            column![
585                header,
586                space().height(10),
587                scrollable(alerts_list).height(200),
588            ]
589            .spacing(5),
590        )
591        .padding(15)
592        .width(Length::Fill)
593        .style(|_theme| container::Style {
594            background: Some(colors::SURFACE.into()),
595            border: iced::Border {
596                color: colors::BORDER,
597                width: 1.0,
598                radius: 8.0.into(),
599            },
600            ..Default::default()
601        })
602        .into()
603    }
604
605    /// View: Single alert row.
606    fn view_alert_row(&self, idx: usize, alert: &MonitoringAlert) -> Element<'_, Message> {
607        let severity = alert.severity();
608        let alert_type = alert.alert_type();
609        let color = severity_color(severity);
610
611        let indicator = text(severity.indicator().to_string()).size(16).color(color);
612
613        let is_selected = self.selected_alert == Some(idx);
614        let bg_color = if is_selected {
615            colors::SURFACE_BRIGHT
616        } else {
617            colors::SURFACE
618        };
619
620        let content = column![
621            row![
622                indicator,
623                text(severity.name()).size(12).color(color),
624                space().width(Length::Fill),
625                text(format!("#{}", alert.alert_id % 10000))
626                    .size(10)
627                    .color(colors::TEXT_DISABLED),
628            ]
629            .spacing(8)
630            .align_y(Alignment::Center),
631            text(alert_type.name()).size(14).color(colors::TEXT_PRIMARY),
632            row![
633                text(format!("Cust: {}", alert.customer_id))
634                    .size(11)
635                    .color(colors::TEXT_SECONDARY),
636                text(alert.format_amount())
637                    .size(11)
638                    .color(colors::TEXT_SECONDARY),
639            ]
640            .spacing(15),
641        ]
642        .spacing(4);
643
644        button(content)
645            .padding(10)
646            .width(Length::Fill)
647            .on_press(Message::SelectAlert(idx))
648            .style(move |_theme, _status| button::Style {
649                background: Some(bg_color.into()),
650                border: iced::Border {
651                    color: color.scale_alpha(0.3),
652                    width: 1.0,
653                    radius: 6.0.into(),
654                },
655                text_color: colors::TEXT_PRIMARY,
656                ..Default::default()
657            })
658            .into()
659    }
660
661    /// Helper: Create a panel with title.
662    fn panel(
663        &self,
664        title: &'static str,
665        content: impl Into<Element<'static, Message>>,
666    ) -> Element<'static, Message> {
667        container(
668            column![
669                text(title).size(16).color(colors::TEXT_PRIMARY),
670                space().height(10),
671                content.into(),
672            ]
673            .spacing(5),
674        )
675        .padding(15)
676        .width(Length::Fill)
677        .style(|_theme| container::Style {
678            background: Some(colors::SURFACE.into()),
679            border: iced::Border {
680                color: colors::BORDER,
681                width: 1.0,
682                radius: 8.0.into(),
683            },
684            ..Default::default()
685        })
686        .into()
687    }
688
689    /// Helper: Create a stat item.
690    fn stat_item(&self, label: &'static str, value: String) -> Element<'static, Message> {
691        column![
692            text(label).size(11).color(colors::TEXT_SECONDARY),
693            text(value).size(16).color(colors::TEXT_PRIMARY),
694        ]
695        .spacing(2)
696        .into()
697    }
698
699    /// Helper: Create a colored stat item.
700    fn stat_item_colored(
701        &self,
702        label: &'static str,
703        value: String,
704        color: Color,
705    ) -> Element<'static, Message> {
706        column![
707            text(label).size(11).color(colors::TEXT_SECONDARY),
708            text(value).size(16).color(color),
709        ]
710        .spacing(2)
711        .into()
712    }
713
714    /// Helper: Create a stat row.
715    fn stat_row(&self, label: &'static str, value: String) -> Element<'static, Message> {
716        row![
717            text(label).size(14).color(colors::TEXT_SECONDARY),
718            space().width(Length::Fill),
719            text(value).size(14).color(colors::TEXT_PRIMARY),
720        ]
721        .into()
722    }
723
724    /// Get the theme for the application.
725    pub fn theme(&self) -> Theme {
726        Theme::Dark
727    }
728
729    /// Get subscriptions for the application.
730    pub fn subscription(&self) -> Subscription<Message> {
731        // 60 FPS tick for animation and processing
732        time::every(Duration::from_millis(16)).map(|_| Message::Tick)
733    }
734}
735
736impl Default for TxMonApp {
737    fn default() -> Self {
738        Self::new()
739    }
740}
741
742/// Format a timestamp as HH:MM:SS.
743fn format_timestamp(timestamp_ms: u64) -> String {
744    let secs = (timestamp_ms / 1000) % 86400;
745    let hours = secs / 3600;
746    let mins = (secs % 3600) / 60;
747    let secs = secs % 60;
748    format!("{:02}:{:02}:{:02}", hours, mins, secs)
749}
750
751/// Format a large number with K/M suffixes.
752fn format_number(n: u64) -> String {
753    if n >= 1_000_000 {
754        format!("{:.1}M", n as f64 / 1_000_000.0)
755    } else if n >= 1_000 {
756        format!("{:.1}K", n as f64 / 1_000.0)
757    } else {
758        format!("{}", n)
759    }
760}