1use 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
16const MAX_RECENT_TRANSACTIONS: usize = 50;
18const MAX_RECENT_ALERTS: usize = 30;
20
21pub struct TxMonApp {
23 factory_state: FactoryState,
25 generator: TransactionGenerator,
26
27 engine: MonitoringEngine,
29
30 recent_transactions: VecDeque<Transaction>,
32 recent_alerts: VecDeque<MonitoringAlert>,
33
34 total_transactions: u64,
36 total_alerts: u64,
37 flagged_transactions: u64, transactions_per_second: f32,
39 alerts_per_second: f32,
40 customer_stats: CustomerStats,
41
42 last_stats_update: Instant,
44 transactions_since_update: u64,
45 alerts_since_update: u64,
46
47 transactions_per_second_slider: u32,
49 suspicious_rate_slider: u8,
50 selected_alert: Option<usize>,
51
52 customer_alert_counts: HashMap<u64, u32>,
54}
55
56#[derive(Debug, Clone)]
58pub enum Message {
59 StartFactory,
61 PauseFactory,
62 StopFactory,
63 SetTransactionRate(u32),
64 SetSuspiciousRate(u8),
65
66 Tick,
68
69 SelectAlert(usize),
71 ClearAlerts,
72}
73
74impl TxMonApp {
75 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 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 fn process_tick(&mut self) {
154 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 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 let mut flagged_tx_ids: HashSet<u64> = HashSet::new();
169 for alert in &alerts {
170 flagged_tx_ids.insert(alert.transaction_id);
171 *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 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 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 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 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 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 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 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 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 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 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 fn view_transaction_feed(&self) -> Element<'_, Message> {
389 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 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 fn view_statistics(&self) -> Element<'_, Message> {
451 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 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 fn view_high_risk_accounts(&self) -> Element<'_, Message> {
515 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 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 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 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 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 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 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 pub fn theme(&self) -> Theme {
729 Theme::Dark
730 }
731
732 pub fn subscription(&self) -> Subscription<Message> {
734 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
745fn 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
754fn 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}