1use 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
124pub(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}