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