Skip to main content

node_gui/
app.rs

1//! UI-facing node lifecycle, log filtering, statistics, and chart history.
2
3use std::collections::VecDeque;
4
5use tokio::sync::mpsc;
6use wowlab_common::time::Instant;
7use wowlab_node::{
8    ConnectionStatus, LogEntry, LogLevel, NodeApplication, NodeConfig, NodeCore, NodeCoreEvent,
9    NodeEvents, NodeState, UiLogEntry,
10};
11
12const HISTORY_SIZE: usize = 60;
13const MAX_LOGS: usize = 500;
14const CHART_WIDTH: u32 = 460;
15const CHART_HEIGHT: u32 = 180;
16const SAMPLE_INTERVAL: u32 = 10;
17const CHART_RENDER_INTERVAL: u32 = 5;
18const LOG_REFRESH_INTERVAL: u32 = 10;
19
20#[derive(Debug, thiserror::Error)]
21pub(crate) enum AppInitError {
22    #[error(transparent)]
23    Config(#[from] wowlab_node::NodeConfigError),
24    #[error(transparent)]
25    Core(#[from] wowlab_node::SentinelError),
26    #[error(transparent)]
27    EngineComposition(#[from] wowlab_engine_ports::ContentCatalogError),
28}
29
30mod colors {
31    use slint::Color;
32
33    pub(super) const TEXT_PRIMARY: Color = Color::from_rgb_u8(0xfa, 0xfa, 0xfa);
34    pub(super) const TEXT_SECONDARY: Color = Color::from_rgb_u8(0x88, 0x88, 0x88);
35    pub(super) const TEXT_MUTED: Color = Color::from_rgb_u8(0x55, 0x55, 0x55);
36    pub(super) const WARN: Color = Color::from_rgb_u8(0xf5, 0x9e, 0x0b);
37    pub(super) const DANGER: Color = Color::from_rgb_u8(0xef, 0x44, 0x44);
38}
39
40fn new_history<T>(size: usize) -> VecDeque<T>
41where
42    T: Default + Copy,
43{
44    let mut deque = VecDeque::with_capacity(size);
45
46    deque.resize(size, T::default());
47
48    deque
49}
50
51fn push_sample<T>(history: &mut VecDeque<T>, value: T, max: usize) {
52    if history.len() >= max {
53        history.pop_front();
54    }
55
56    history.push_back(value);
57}
58
59pub(crate) fn validate_token(token: &str) -> (String, bool) {
60    match wowlab_node::validate_claim_token(token) {
61        Ok(()) => (String::new(), true),
62        Err(error) => (error.to_string(), false),
63    }
64}
65
66fn matches_filter(filter: i32, level: LogLevel) -> bool {
67    match filter {
68        1 => !matches!(level, LogLevel::Debug),
69        2 => matches!(level, LogLevel::Warn | LogLevel::Error),
70        3 => matches!(level, LogLevel::Error),
71        _ => true,
72    }
73}
74
75const fn node_state_slug(state: &NodeState) -> &'static str {
76    match state {
77        NodeState::Setup | NodeState::NotFound => "setup",
78        NodeState::Verifying => "verifying",
79        NodeState::Registering => "registering",
80        NodeState::Unavailable => "unavailable",
81        NodeState::Running => "running",
82        NodeState::Unlinking => "unlinking",
83        NodeState::Unlinked => "unlinked",
84    }
85}
86
87#[derive(Debug, Eq, PartialEq)]
88enum UnlinkPresentation {
89    Started,
90    Succeeded,
91    Failed(String),
92}
93
94fn unlink_presentation(event: &NodeCoreEvent) -> Option<UnlinkPresentation> {
95    match event {
96        NodeCoreEvent::StateChanged(NodeState::Unlinking) => Some(UnlinkPresentation::Started),
97        NodeCoreEvent::UnlinkCompleted(Ok(_)) => Some(UnlinkPresentation::Succeeded),
98        NodeCoreEvent::UnlinkCompleted(Err(error)) => Some(unlink_failure_presentation(error)),
99        _ => None,
100    }
101}
102
103fn unlink_failure_presentation(error: &impl std::fmt::Display) -> UnlinkPresentation {
104    UnlinkPresentation::Failed(error.to_string())
105}
106
107const fn connection_status_slug(status: ConnectionStatus) -> &'static str {
108    match status {
109        ConnectionStatus::Connected => "connected",
110        ConnectionStatus::Connecting => "connecting",
111        ConnectionStatus::Disconnected => "disconnected",
112    }
113}
114
115const fn log_level_colors(level: LogLevel) -> (&'static str, slint::Color, slint::Color) {
116    match level {
117        LogLevel::Info => ("INFO", colors::TEXT_SECONDARY, colors::TEXT_PRIMARY),
118        LogLevel::Warn => ("WARN", colors::WARN, colors::WARN),
119        LogLevel::Error => ("ERR", colors::DANGER, colors::DANGER),
120        LogLevel::Debug => ("DBG", colors::TEXT_MUTED, colors::TEXT_PRIMARY),
121    }
122}
123
124/// Holds the node core, event/log receivers, and presentation history.
125pub(crate) struct App {
126    core: NodeCore,
127    event_rx: NodeEvents,
128    log_rx: mpsc::Receiver<UiLogEntry>,
129    connection_status: ConnectionStatus,
130    cpu_cores: usize,
131    logs: VecDeque<LogEntry>,
132    cpu_history: VecDeque<f32>,
133    throughput_history: VecDeque<f64>,
134    sims_per_second: f64,
135    cpu_usage: f32,
136    filter: i32,
137    search_text: String,
138    node_name: String,
139    public_key: String,
140    poll_counter: u32,
141    sample_counter: u32,
142    log_refresh_counter: u32,
143}
144
145impl App {
146    pub(crate) fn new(log_rx: mpsc::Receiver<UiLogEntry>) -> Result<Self, AppInitError> {
147        let config = NodeConfig::load()?;
148        let application = NodeApplication::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
149        let catalog = wowlab_engine_ports::content_catalog()?;
150        let (mut core, event_rx) = NodeCore::with_supabase(config, application, catalog)?;
151
152        core.start();
153
154        let node_name = core.node_name().to_string();
155        let public_key = core.public_key().to_string();
156        let cpu_cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
157
158        Ok(Self {
159            core,
160            event_rx,
161            log_rx,
162            connection_status: ConnectionStatus::Connecting,
163            cpu_cores,
164            logs: VecDeque::with_capacity(MAX_LOGS),
165            cpu_history: new_history(HISTORY_SIZE),
166            throughput_history: new_history(HISTORY_SIZE),
167            sims_per_second: 0.0,
168            cpu_usage: 0.0,
169            filter: 0,
170            search_text: String::new(),
171            node_name,
172            public_key,
173            poll_counter: 0,
174            sample_counter: 0,
175            log_refresh_counter: 0,
176        })
177    }
178
179    pub(crate) fn cpu_cores(&self) -> usize {
180        self.cpu_cores
181    }
182
183    pub(crate) fn node_name(&self) -> &str {
184        &self.node_name
185    }
186
187    pub(crate) fn public_key(&self) -> &str {
188        &self.public_key
189    }
190
191    pub(crate) fn state_name(&self) -> &'static str {
192        node_state_slug(self.core.state())
193    }
194
195    pub(crate) const fn connection_status_name(&self) -> &'static str {
196        connection_status_slug(self.connection_status)
197    }
198
199    pub(crate) fn register(&mut self, token: String) {
200        self.core.set_token_claim(token);
201    }
202
203    pub(crate) fn request_unlink(&mut self) {
204        self.core.request_unlink();
205    }
206
207    pub(crate) fn unlink_in_progress(&self) -> bool {
208        self.core.is_unlinking()
209    }
210
211    pub(crate) fn disconnect(&mut self) {
212        self.core.disconnect();
213    }
214
215    pub(crate) fn reconnect(&mut self) {
216        self.core.reconnect();
217    }
218
219    pub(crate) fn set_filter(&mut self, filter: i32) {
220        self.filter = filter;
221    }
222
223    pub(crate) fn set_search_text(&mut self, text: String) {
224        self.search_text = text;
225    }
226
227    pub(crate) fn update_logs(&self, window: &crate::AppWindow) {
228        let search_lower = self.search_text.to_lowercase();
229        let matching_logs = self
230            .logs
231            .iter()
232            .filter(|e| matches_filter(self.filter, e.level))
233            .filter(|e| {
234                search_lower.is_empty() || e.message.to_lowercase().contains(&search_lower)
235            });
236        let filtered: Vec<crate::LogEntry> = matching_logs
237            .rev()
238            .map(|e| {
239                let (level_text, level_color, msg_color) = log_level_colors(e.level);
240
241                crate::LogEntry {
242                    level_text: level_text.into(),
243                    level_color,
244                    msg_color,
245                    time_str: wowlab_common::fmt::format_duration_secs(
246                        e.timestamp.elapsed().as_secs(),
247                    )
248                    .into(),
249                    message: e.message.as_str().into(),
250                }
251            })
252            .collect();
253
254        let count = filtered.len();
255
256        window.set_log_count(format!("{count} entries").into());
257        window.set_log_model(slint::ModelRc::new(slint::VecModel::from(filtered)));
258    }
259
260    pub(crate) fn poll(&mut self, window: &crate::AppWindow) -> bool {
261        let mut changed = false;
262        let mut close_after_unlink = false;
263
264        self.core.poll();
265
266        while let Some(event) = self.event_rx.try_recv() {
267            let (event_changed, event_close) = self.handle_node_event(window, event);
268
269            changed |= event_changed;
270            close_after_unlink |= event_close;
271            std::thread::yield_now();
272        }
273
274        while let Ok(entry) = self.log_rx.try_recv() {
275            let log_entry = LogEntry {
276                timestamp: Instant::now(),
277                level: entry.level,
278                message: entry.message,
279            };
280
281            push_sample(&mut self.logs, log_entry, MAX_LOGS);
282            changed = true;
283            std::thread::yield_now();
284        }
285
286        if matches!(self.core.state(), NodeState::Running) {
287            let stats = self.core.stats();
288
289            self.sims_per_second = stats.sims_per_second;
290            self.cpu_usage = stats.cpu_usage;
291
292            self.sample_counter += 1;
293
294            if self.sample_counter >= SAMPLE_INTERVAL {
295                self.sample_counter = 0;
296                push_sample(&mut self.cpu_history, self.cpu_usage, HISTORY_SIZE);
297                push_sample(
298                    &mut self.throughput_history,
299                    self.sims_per_second,
300                    HISTORY_SIZE,
301                );
302            }
303
304            window.set_sims_per_second(format!("{:.1}", self.sims_per_second).into());
305            window.set_cpu_usage(format!("{:.0}%", self.cpu_usage).into());
306            window.set_cpu_cores(format!("{}/{}", stats.max_workers, stats.total_cores).into());
307
308            self.poll_counter += 1;
309
310            if self.poll_counter >= CHART_RENDER_INTERVAL {
311                self.poll_counter = 0;
312                self.render_charts(window);
313            }
314        }
315
316        self.log_refresh_counter += 1;
317
318        if changed || self.log_refresh_counter >= LOG_REFRESH_INTERVAL {
319            self.log_refresh_counter = 0;
320            self.update_logs(window);
321        }
322
323        close_after_unlink
324    }
325
326    fn render_charts(&self, window: &crate::AppWindow) {
327        let throughput_data: Vec<f64> = self.throughput_history.iter().copied().collect();
328        let cpu_data: Vec<f32> = self.cpu_history.iter().copied().collect();
329
330        window.set_throughput_chart(crate::charts::render_throughput_chart(
331            &throughput_data,
332            CHART_WIDTH,
333            CHART_HEIGHT,
334        ));
335        window.set_resource_chart(crate::charts::render_resource_chart(
336            &cpu_data,
337            CHART_WIDTH,
338            CHART_HEIGHT,
339        ));
340    }
341
342    fn handle_node_event(
343        &mut self,
344        window: &crate::AppWindow,
345        event: NodeCoreEvent,
346    ) -> (bool, bool) {
347        let mut close_after_unlink = false;
348
349        if let Some(presentation) = unlink_presentation(&event) {
350            match presentation {
351                UnlinkPresentation::Started => {
352                    window.set_unlink_busy(true);
353                    window.set_unlink_error("".into());
354                }
355                UnlinkPresentation::Succeeded => {
356                    window.set_unlink_busy(false);
357                    window.set_unlink_error("".into());
358                    window.set_show_unlink_confirm(false);
359                    close_after_unlink = true;
360                }
361                UnlinkPresentation::Failed(error) => {
362                    window.set_unlink_busy(false);
363                    window.set_unlink_error(error.into());
364                    window.set_show_unlink_confirm(true);
365                }
366            }
367        }
368
369        let changed = match event {
370            NodeCoreEvent::StateChanged(state) => {
371                if matches!(state, NodeState::Running) {
372                    self.refresh_identity(window);
373                }
374
375                window.set_app_state(node_state_slug(&state).into());
376
377                true
378            }
379            NodeCoreEvent::ConnectionChanged(status) => {
380                self.connection_status = status;
381                window.set_connection_status(connection_status_slug(status).into());
382
383                true
384            }
385            _ => false,
386        };
387
388        (changed, close_after_unlink)
389    }
390
391    fn refresh_identity(&mut self, window: &crate::AppWindow) {
392        self.node_name = self.core.node_name().to_string();
393        self.public_key = self.core.public_key().to_string();
394        window.set_node_name(self.node_name.as_str().into());
395        window.set_public_key(self.public_key.as_str().into());
396        window.set_token_text("".into());
397        window.set_token_error("".into());
398        window.set_token_valid(false);
399    }
400}
401
402impl std::fmt::Debug for App {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.debug_struct("App")
405            .field("core", &"<NodeCore>")
406            .field("connection_status", &self.connection_status)
407            .field("cpu_cores", &self.cpu_cores)
408            .field("sims_per_second", &self.sims_per_second)
409            .field("cpu_usage", &self.cpu_usage)
410            .field("node_name", &self.node_name)
411            .field("public_key", &self.public_key)
412            .finish_non_exhaustive()
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use googletest::prelude::*;
419
420    use super::*;
421
422    #[gtest]
423    fn history_starts_filled_and_remains_bounded() -> Result<()> {
424        let mut history = new_history::<u32>(3);
425
426        verify_that!(history, container_eq([0, 0, 0]))?;
427
428        push_sample(&mut history, 4, 3);
429        push_sample(&mut history, 5, 3);
430
431        verify_that!(history, container_eq([0, 4, 5]))
432    }
433
434    #[gtest]
435    fn log_filters_match_the_visible_severity_contract() -> Result<()> {
436        verify_eq!(
437            [
438                matches_filter(0, LogLevel::Debug),
439                matches_filter(1, LogLevel::Debug),
440                matches_filter(1, LogLevel::Info),
441                matches_filter(2, LogLevel::Info),
442                matches_filter(2, LogLevel::Warn),
443                matches_filter(3, LogLevel::Warn),
444                matches_filter(3, LogLevel::Error),
445            ],
446            [true, false, true, false, true, false, true]
447        )
448    }
449
450    #[gtest]
451    fn lifecycle_states_keep_the_slint_string_contract() -> Result<()> {
452        verify_eq!(
453            [
454                node_state_slug(&NodeState::Setup),
455                node_state_slug(&NodeState::NotFound),
456                node_state_slug(&NodeState::Verifying),
457                node_state_slug(&NodeState::Registering),
458                node_state_slug(&NodeState::Unavailable),
459                node_state_slug(&NodeState::Running),
460                node_state_slug(&NodeState::Unlinking),
461                node_state_slug(&NodeState::Unlinked),
462            ],
463            [
464                "setup",
465                "setup",
466                "verifying",
467                "registering",
468                "unavailable",
469                "running",
470                "unlinking",
471                "unlinked",
472            ]
473        )
474    }
475
476    #[gtest]
477    fn connection_states_keep_the_slint_string_contract() -> Result<()> {
478        verify_eq!(
479            [
480                connection_status_slug(ConnectionStatus::Connecting),
481                connection_status_slug(ConnectionStatus::Connected),
482                connection_status_slug(ConnectionStatus::Disconnected),
483            ],
484            ["connecting", "connected", "disconnected"]
485        )
486    }
487
488    #[gtest]
489    fn token_validation_preserves_messages_and_validity() -> Result<()> {
490        verify_that!(validate_token(""), eq(&(String::new(), false)))?;
491
492        let valid = format!("wlab_claim_{}", "a".repeat(32));
493
494        verify_that!(validate_token(&valid), eq(&(String::new(), true)))?;
495
496        let (message, valid) = validate_token("not-a-claim-token");
497
498        verify_false!(valid)?;
499
500        verify_that!(message, contains_substring("wlab_claim_"))
501    }
502
503    #[gtest]
504    fn unlink_status_order_is_started_then_terminal_success() -> Result<()> {
505        let events = [
506            NodeCoreEvent::StateChanged(NodeState::Unlinking),
507            NodeCoreEvent::StateChanged(NodeState::Unlinked),
508            NodeCoreEvent::UnlinkCompleted(Ok(wowlab_node::UnlinkOutcome::new(
509                wowlab_node::RemoteUnlinkOutcome::Removed,
510                wowlab_node::LocalIdentityOutcome::Removed,
511            ))),
512        ];
513
514        let presentation: Vec<_> = events.iter().filter_map(unlink_presentation).collect();
515
516        verify_that!(
517            presentation,
518            container_eq([UnlinkPresentation::Started, UnlinkPresentation::Succeeded,])
519        )
520    }
521
522    #[gtest]
523    fn unlink_failure_is_terminal_without_requesting_close() -> Result<()> {
524        verify_that!(
525            unlink_failure_presentation(&std::io::Error::other("unlink failed")),
526            eq(&UnlinkPresentation::Failed("unlink failed".to_string()))
527        )
528    }
529}