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::{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
13const MAX_RECENT_TRANSACTIONS: usize = 50;
15const MAX_RECENT_ALERTS: usize = 30;
17
18pub struct TxMonApp {
20 factory_state: FactoryState,
22 generator: TransactionGenerator,
23
24 engine: MonitoringEngine,
26
27 recent_transactions: VecDeque<Transaction>,
29 recent_alerts: VecDeque<MonitoringAlert>,
30
31 total_transactions: u64,
33 total_alerts: u64,
34 flagged_transactions: u64, transactions_per_second: f32,
36 alerts_per_second: f32,
37 customer_stats: CustomerStats,
38
39 last_stats_update: Instant,
41 transactions_since_update: u64,
42 alerts_since_update: u64,
43
44 transactions_per_second_slider: u32,
46 suspicious_rate_slider: u8,
47 selected_alert: Option<usize>,
48
49 customer_alert_counts: HashMap<u64, u32>,
51}
52
53#[derive(Debug, Clone)]
55pub enum Message {
56 StartFactory,
58 PauseFactory,
59 StopFactory,
60 SetTransactionRate(u32),
61 SetSuspiciousRate(u8),
62
63 Tick,
65
66 SelectAlert(usize),
68 ClearAlerts,
69}
70
71impl TxMonApp {
72 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 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 fn process_tick(&mut self) {
151 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 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 let mut flagged_tx_ids: HashSet<u64> = HashSet::new();
166 for alert in &alerts {
167 flagged_tx_ids.insert(alert.transaction_id);
168 *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 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 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 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 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 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 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 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 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 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 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 fn view_transaction_feed(&self) -> Element<'_, Message> {
386 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 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 fn view_statistics(&self) -> Element<'_, Message> {
448 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 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 fn view_high_risk_accounts(&self) -> Element<'_, Message> {
512 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 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 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 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 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 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 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 pub fn theme(&self) -> Theme {
726 Theme::Dark
727 }
728
729 pub fn subscription(&self) -> Subscription<Message> {
731 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
742fn 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
751fn 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}