1use std::sync::Arc;
2
3use tokio::runtime::Runtime;
4use tokio::sync::mpsc;
5
6use opcua::types::NodeId;
7
8use crate::client::{UaClient, parse_attribute_value, parse_variant};
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, AttributeEditState, ConnectionState, DetailTab, MethodCallState};
11use crate::types::{AuthSpec, EndpointInfo, SubscriptionRow, ValueTree};
12
13#[derive(Debug, Clone, Copy)]
14pub enum FilePickTarget {
15 CertPath,
16 KeyPath,
17}
18
19pub trait FrontendCtx: Clone + Send + Sync + 'static {
20 fn request_repaint(&self);
21 fn set_clipboard(&self, text: &str);
22 fn pick_file(
23 &self,
24 rt: &Runtime,
25 update_tx: &mpsc::UnboundedSender<UiUpdate>,
26 target: FilePickTarget,
27 title: &str,
28 default_dir: &str,
29 );
30}
31
32pub struct Engine {
33 pub model: AppModel,
34 pub client: Arc<UaClient>,
35 pub rt: Runtime,
36 pub update_tx: mpsc::UnboundedSender<UiUpdate>,
37}
38
39impl Engine {
40 pub fn new(
41 rt: Runtime,
42 log_rx: mpsc::UnboundedReceiver<UiUpdate>,
43 ) -> (Self, mpsc::UnboundedReceiver<UiUpdate>) {
44 let (update_tx, update_rx) = mpsc::unbounded_channel();
45 forward_logs(log_rx, update_tx.clone());
46 let engine = Self {
47 model: AppModel::default(),
48 client: Arc::new(UaClient::new()),
49 rt,
50 update_tx,
51 };
52 (engine, update_rx)
53 }
54
55 pub fn apply_update<C: FrontendCtx>(&mut self, ctx: &C, update: UiUpdate) {
56 match update {
57 UiUpdate::ConnectStarted => self.model.connection = ConnectionState::Connecting,
58 UiUpdate::ConnectFinished(Ok(())) => {
59 self.model.connection = ConnectionState::Connected;
60 self.model.record_successful_connection();
61 tracing::info!("connected to {}", self.model.endpoint_url);
62 let saved = self
63 .model
64 .last_selection_paths
65 .get(&self.model.endpoint_url)
66 .cloned();
67 match saved {
68 Some(path) if !path.is_empty() => {
69 tracing::info!(
70 "restoring previous selection ({} ancestors)",
71 path.len()
72 );
73 self.spawn_restore_selection(ctx, path);
74 }
75 _ => {
76 let root = self.model.root_node.clone();
77 self.ensure_expanded(ctx, root);
78 }
79 }
80 }
81 UiUpdate::ConnectFinished(Err(e)) => {
82 self.model.connection = ConnectionState::Disconnected;
83 tracing::error!("connect failed: {e}");
84 }
85 UiUpdate::ConnectionLost => {
86 self.model.connection = ConnectionState::Reconnecting;
87 tracing::warn!("connection lost — reconnecting…");
88 }
89 UiUpdate::Reconnected { fresh } => {
90 if self.model.connection == ConnectionState::Reconnecting {
91 self.model.connection = ConnectionState::Connected;
92 tracing::info!("reconnected to {}", self.model.endpoint_url);
93 if let Some(node) = self.model.selected.clone() {
94 self.spawn_node_summary(ctx, node);
95 }
96 if fresh {
97 self.resubscribe_all(ctx);
98 }
99 }
100 }
101 UiUpdate::ReconnectFailed(e) => tracing::debug!("reconnect attempt failed: {e}"),
102 UiUpdate::DisconnectStarted => self.model.connection = ConnectionState::Disconnecting,
103 UiUpdate::DisconnectFinished => {
104 self.model.connection = ConnectionState::Disconnected;
105 self.model.reset_session_state();
106 tracing::info!("disconnected");
107 }
108 UiUpdate::ChildrenLoaded { parent, children } => {
109 self.model.tree.loading.remove(&parent);
110 match children {
111 Ok(c) => {
112 self.model.tree.children.insert(parent.clone(), c);
113 self.model.tree.expanded.insert(parent);
114 }
115 Err(e) => tracing::error!("browse {parent} failed: {e}"),
116 }
117 }
118 UiUpdate::SummaryLoaded { node, summary } => {
119 if self.model.selected.as_ref() == Some(&node) {
120 match summary {
121 Ok(s) => self.model.node_summary = Some(s),
122 Err(e) => tracing::error!("read summary {node} failed: {e}"),
123 }
124 }
125 }
126 UiUpdate::ReferencesLoaded { node, refs } => {
127 if self.model.selected.as_ref() == Some(&node) {
128 self.model.references_loading = false;
129 match refs {
130 Ok(rs) => self.model.references = Some(rs),
131 Err(e) => tracing::error!("browse refs {node} failed: {e}"),
132 }
133 }
134 }
135 UiUpdate::SelectionPathResolved { url, path } => {
136 self.model.last_selection_paths.insert(url, path);
137 }
138 UiUpdate::RestoreSelection(node) => {
139 self.model.selected = Some(node.clone());
140 self.spawn_node_summary(ctx, node.clone());
141 if self.model.active_tab == DetailTab::References {
142 self.spawn_browse_references(ctx, node);
143 }
144 }
145 UiUpdate::PathReady { node, path } => match path {
146 Ok(p) => {
147 ctx.set_clipboard(&p);
148 tracing::info!("copied path: {p}");
149 }
150 Err(e) => tracing::error!("path for {node} failed: {e}"),
151 },
152 UiUpdate::CertPathPicked(p) => self.model.auth_cert_path = p,
153 UiUpdate::KeyPathPicked(p) => self.model.auth_key_path = p,
154 UiUpdate::FilePickerClosed => self.model.file_picker_open = false,
155 UiUpdate::EndpointsDiscovered { url, result } => {
156 if url != self.model.endpoint_url {
157 tracing::debug!("dropping endpoints result for stale url {url}");
158 } else {
159 self.model.endpoints_loading = false;
160 match result {
161 Ok(eps) => {
162 tracing::info!("discovered {} endpoint(s)", eps.len());
163 self.model.discovered_endpoints = Some(eps);
164 self.select_first_matching_endpoint();
165 }
166 Err(e) => {
167 tracing::error!("endpoint discovery failed: {e}");
168 self.model.discovered_endpoints = Some(Vec::new());
169 }
170 }
171 }
172 }
173 UiUpdate::MethodSignatureLoaded { node, result } => {
174 if !self.method_call_targets(&node) {
175 return;
176 }
177 match result {
178 Ok(signature) => {
179 let n_inputs = signature.inputs.len();
180 self.model.method_call = Some(MethodCallState::Inputs {
181 node,
182 signature,
183 edited: vec![String::new(); n_inputs],
184 field_errors: vec![None; n_inputs],
185 call_error: None,
186 });
187 }
188 Err(error) => {
189 tracing::error!("read method signature {node} failed: {error}");
190 self.model.method_call =
191 Some(MethodCallState::Failed { node, error });
192 }
193 }
194 }
195 UiUpdate::MethodCallFinished { node, result } => {
196 if !self.method_call_targets(&node) {
197 return;
198 }
199 let Some(MethodCallState::Calling {
200 node, signature, edited,
201 }) = self.model.method_call.take()
202 else {
203 return;
204 };
205 match result {
206 Ok(outcome) => {
207 self.model.method_call = Some(MethodCallState::Result {
208 node,
209 signature,
210 edited,
211 outcome,
212 });
213 }
214 Err(error) => {
215 tracing::error!("call method {node} failed: {error}");
216 let n_inputs = signature.inputs.len();
217 self.model.method_call = Some(MethodCallState::Inputs {
218 node,
219 signature,
220 edited,
221 field_errors: vec![None; n_inputs],
222 call_error: Some(error),
223 });
224 }
225 }
226 }
227 UiUpdate::SubscribeFinished { node, result } => match result {
228 Ok(display_name) => {
229 self.model.subscribing.remove(&node);
230 if !self.model.subscriptions.iter().any(|r| r.node_id == node) {
231 self.model.subscriptions.push(SubscriptionRow {
232 node_id: node,
233 display_name,
234 value: "<pending>".to_string(),
235 status: String::new(),
236 timestamp: None,
237 });
238 }
239 }
240 Err(e) => {
241 self.model.subscribing.remove(&node);
242 self.model.subscriptions.retain(|r| r.node_id != node);
243 tracing::error!("subscribe {node} failed: {e}");
244 }
245 },
246 UiUpdate::UnsubscribeFinished { node, result } => {
247 self.model.subscribing.remove(&node);
248 self.model.subscriptions.retain(|r| r.node_id != node);
249 if let Err(e) = result {
250 tracing::error!("unsubscribe {node} failed: {e}");
251 }
252 }
253 UiUpdate::DataChange {
254 node,
255 value,
256 status,
257 timestamp,
258 } => {
259 if let Some(row) = self
260 .model
261 .subscriptions
262 .iter_mut()
263 .find(|r| r.node_id == node)
264 {
265 row.value = value;
266 row.status = status;
267 row.timestamp = timestamp;
268 }
269 }
270 UiUpdate::AttributeEditTargetLoaded {
271 node,
272 attr_name,
273 result,
274 } => {
275 if !self.attr_edit_targets(&node, &attr_name) {
276 return;
277 }
278 match result {
279 Ok(target) => {
280 let edited = target.current_value.clone();
281 self.model.attr_edit = Some(AttributeEditState::Inputs {
282 node,
283 attr_name,
284 target,
285 edited,
286 field_error: None,
287 write_error: None,
288 });
289 }
290 Err(error) => {
291 tracing::error!(
292 "read write target {node} {attr_name} failed: {error}"
293 );
294 self.model.attr_edit = Some(AttributeEditState::Failed {
295 node,
296 attr_name,
297 error,
298 });
299 }
300 }
301 }
302 UiUpdate::AttributeWriteFinished {
303 node,
304 attr_name,
305 result,
306 } => {
307 if !self.attr_edit_targets(&node, &attr_name) {
308 return;
309 }
310 match result {
311 Ok(()) => {
312 self.model.attr_edit = None;
313 self.spawn_node_summary(ctx, node);
314 }
315 Err(error) => {
316 tracing::error!("write {node} {attr_name} failed: {error}");
317 let Some(AttributeEditState::Writing {
318 node,
319 attr_name,
320 target,
321 edited,
322 }) = self.model.attr_edit.take()
323 else {
324 return;
325 };
326 self.model.attr_edit = Some(AttributeEditState::Inputs {
327 node,
328 attr_name,
329 target,
330 edited,
331 field_error: None,
332 write_error: Some(error),
333 });
334 }
335 }
336 }
337 UiUpdate::Log(line) => self.model.push_log(line),
338 }
339 }
340
341 fn attr_edit_targets(&self, node: &NodeId, attr_name: &str) -> bool {
342 self.model
343 .attr_edit
344 .as_ref()
345 .map(|s| s.node() == node && s.attr_name() == attr_name)
346 .unwrap_or(false)
347 }
348
349 fn method_call_targets(&self, node: &NodeId) -> bool {
350 self.model
351 .method_call
352 .as_ref()
353 .map(|s| s.node() == node)
354 .unwrap_or(false)
355 }
356
357 pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
358 match action {
359 UiAction::EndpointEdited(s) => {
360 if s != self.model.endpoint_url {
361 self.model.endpoint_url = s;
362 self.model.discovered_endpoints = None;
363 self.model.selected_endpoint = None;
364 self.model.endpoints_loading = false;
365 self.model.apply_saved_connection_prefs();
366 }
367 }
368 UiAction::TabSelected(t) => {
369 self.model.active_tab = t;
370 if t == DetailTab::References
371 && let Some(node) = self.model.selected.clone()
372 && self.model.references.is_none()
373 && !self.model.references_loading
374 {
375 self.spawn_browse_references(ctx, node);
376 }
377 }
378 UiAction::ConnectClicked => {
379 if self.model.selected_endpoint.is_none() {
380 tracing::info!("no endpoint selected; opening picker");
381 self.open_endpoint_picker(ctx);
382 } else {
383 let ep = self.model.selected_endpoint.as_ref().unwrap();
384 tracing::info!(
385 "connecting with {} / {}",
386 ep.security_policy,
387 ep.security_mode.label()
388 );
389 self.spawn_connect(ctx);
390 }
391 }
392 UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
393 UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
394 UiAction::NodeSelected(n) => self.select_node(ctx, n),
395 UiAction::ClearSelection => {
396 self.model.selected = None;
397 self.model.node_summary = None;
398 self.model.references = None;
399 self.model.references_loading = false;
400 }
401 UiAction::RefreshClicked => {
402 if let Some(node) = self.model.selected.clone() {
403 self.spawn_node_summary(ctx, node.clone());
404 if self.model.active_tab == DetailTab::References {
405 self.spawn_browse_references(ctx, node);
406 }
407 }
408 }
409 UiAction::OpenEndpointPicker => {
410 self.open_endpoint_picker(ctx);
411 }
412 UiAction::CloseEndpointPicker => {
413 self.model.endpoints_dialog_open = false;
414 }
415 UiAction::ForceRefreshEndpoints => {
416 if !self.model.endpoints_loading {
417 self.spawn_discover_endpoints(ctx);
418 }
419 }
420 UiAction::SelectEndpoint(ep) => {
421 self.model.selected_endpoint = Some(ep);
422 }
423 UiAction::ClearSelectedEndpoint => {
424 self.model.selected_endpoint = None;
425 }
426 UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
427 UiAction::SetEndpointModeFilter(mode) => {
428 self.model.endpoint_mode_filter = mode;
429 self.select_first_matching_endpoint();
430 }
431 UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
432 UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
433 UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
434 UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
435 UiAction::PickAuthCertPath => {
436 if !self.model.file_picker_open {
437 self.model.file_picker_open = true;
438 let default_dir = self.model.auth_cert_path.clone();
439 ctx.pick_file(
440 &self.rt,
441 &self.update_tx,
442 FilePickTarget::CertPath,
443 "Pick client certificate",
444 &default_dir,
445 );
446 }
447 }
448 UiAction::PickAuthKeyPath => {
449 if !self.model.file_picker_open {
450 self.model.file_picker_open = true;
451 let default_dir = self.model.auth_key_path.clone();
452 ctx.pick_file(
453 &self.rt,
454 &self.update_tx,
455 FilePickTarget::KeyPath,
456 "Pick private key",
457 &default_dir,
458 );
459 }
460 }
461 UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
462 UiAction::CopyNodeId(node) => {
463 let text = node.to_string();
464 ctx.set_clipboard(&text);
465 tracing::info!("copied node id: {text}");
466 }
467 UiAction::CopyNodeValue => {
468 let Some(summary) = self.model.node_summary.as_ref() else {
469 tracing::warn!("no node summary loaded; nothing to copy");
470 return;
471 };
472 match summary.attributes.iter().find(|a| a.name == "Value") {
473 Some(attr) => {
474 let text = render_value_for_clipboard(&attr.value);
475 ctx.set_clipboard(&text);
476 tracing::info!("copied value of {}", summary.node_id);
477 }
478 None => tracing::warn!(
479 "selected node {} has no Value attribute",
480 summary.node_id
481 ),
482 }
483 }
484 UiAction::ConfirmConnect => {
485 if self.model.selected_endpoint.is_some() {
486 self.model.endpoints_dialog_open = false;
487 self.spawn_connect(ctx);
488 } else {
489 tracing::warn!("ConfirmConnect with no endpoint selected");
490 }
491 }
492 UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
493 UiAction::CloseMethodCall => {
494 self.model.method_call = None;
495 }
496 UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
497 Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
498 if let Some(slot) = edited.get_mut(index) {
499 *slot = value;
500 *call_error = None;
501 if let Some(err_slot) = field_errors.get_mut(index) {
502 *err_slot = None;
503 }
504 }
505 }
506 Some(MethodCallState::Result { edited, .. }) => {
507 if let Some(slot) = edited.get_mut(index) {
508 *slot = value;
509 }
510 }
511 _ => {}
512 },
513 UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
514 UiAction::Subscribe(node) => {
515 if self.model.subscribing.insert(node.clone()) {
516 self.spawn_subscribe(ctx, node);
517 }
518 }
519 UiAction::Unsubscribe(node) => {
520 if self.model.subscribing.insert(node.clone()) {
521 self.spawn_unsubscribe(ctx, node);
522 }
523 }
524 UiAction::OpenAttributeEdit { node, attr_name } => {
525 self.open_attribute_edit(ctx, node, attr_name);
526 }
527 UiAction::CloseAttributeEdit => {
528 self.model.attr_edit = None;
529 }
530 UiAction::AttributeValueEdited(s) => {
531 if let Some(AttributeEditState::Inputs {
532 edited,
533 field_error,
534 write_error,
535 ..
536 }) = self.model.attr_edit.as_mut()
537 {
538 *edited = s;
539 *field_error = None;
540 *write_error = None;
541 }
542 }
543 UiAction::ConfirmAttributeEdit => self.confirm_attribute_edit(ctx),
544 }
545 }
546
547 fn open_attribute_edit<C: FrontendCtx>(
548 &mut self,
549 ctx: &C,
550 node: NodeId,
551 attr_name: String,
552 ) {
553 self.model.attr_edit = Some(AttributeEditState::Loading {
554 node: node.clone(),
555 attr_name: attr_name.clone(),
556 });
557 self.spawn_read_write_target(ctx, node, attr_name);
558 }
559
560 fn confirm_attribute_edit<C: FrontendCtx>(&mut self, ctx: &C) {
561 let Some(AttributeEditState::Inputs {
562 node,
563 attr_name,
564 target,
565 edited,
566 ..
567 }) = self.model.attr_edit.as_ref()
568 else {
569 return;
570 };
571 let value = match parse_attribute_value(&target.spec, edited) {
572 Ok(v) => v,
573 Err(e) => {
574 if let Some(AttributeEditState::Inputs { field_error, .. }) =
575 self.model.attr_edit.as_mut()
576 {
577 *field_error = Some(e);
578 }
579 return;
580 }
581 };
582 let node = node.clone();
583 let attr_name = attr_name.clone();
584 let target = target.clone();
585 let edited = edited.clone();
586 self.model.attr_edit = Some(AttributeEditState::Writing {
587 node: node.clone(),
588 attr_name: attr_name.clone(),
589 target,
590 edited,
591 });
592 self.spawn_write_attribute(ctx, node, attr_name, value);
593 }
594
595 fn spawn_read_write_target<C: FrontendCtx>(
596 &self,
597 ctx: &C,
598 node: NodeId,
599 attr_name: String,
600 ) {
601 let client = self.client.clone();
602 let tx = self.update_tx.clone();
603 let ctx = ctx.clone();
604 self.rt.spawn(async move {
605 let result = client
606 .read_write_target(&node, &attr_name)
607 .await
608 .map_err(|e| e.to_string());
609 let _ = tx.send(UiUpdate::AttributeEditTargetLoaded {
610 node,
611 attr_name,
612 result,
613 });
614 ctx.request_repaint();
615 });
616 }
617
618 fn spawn_write_attribute<C: FrontendCtx>(
619 &self,
620 ctx: &C,
621 node: NodeId,
622 attr_name: String,
623 value: opcua::types::Variant,
624 ) {
625 let client = self.client.clone();
626 let tx = self.update_tx.clone();
627 let ctx = ctx.clone();
628 self.rt.spawn(async move {
629 let result = client
630 .write_attribute(&node, &attr_name, value)
631 .await
632 .map_err(|e| e.to_string());
633 let _ = tx.send(UiUpdate::AttributeWriteFinished {
634 node,
635 attr_name,
636 result,
637 });
638 ctx.request_repaint();
639 });
640 }
641
642 fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
643 self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
644 self.spawn_method_signature(ctx, node);
645 }
646
647 fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
648 let (node, signature, edited) = match self.model.method_call.as_ref() {
649 Some(MethodCallState::Inputs {
650 node, signature, edited, ..
651 })
652 | Some(MethodCallState::Result {
653 node, signature, edited, ..
654 }) => (node.clone(), signature.clone(), edited.clone()),
655 _ => return,
656 };
657
658 let mut variants = Vec::with_capacity(signature.inputs.len());
659 let mut field_errors = vec![None; signature.inputs.len()];
660 let mut any_error = false;
661 for (i, arg) in signature.inputs.iter().enumerate() {
662 let s = edited.get(i).cloned().unwrap_or_default();
663 match parse_variant(&s, &arg.data_type, arg.value_rank) {
664 Ok(v) => variants.push(v),
665 Err(e) => {
666 field_errors[i] = Some(e);
667 any_error = true;
668 }
669 }
670 }
671 if any_error {
672 self.model.method_call = Some(MethodCallState::Inputs {
673 node,
674 signature,
675 edited,
676 field_errors,
677 call_error: None,
678 });
679 return;
680 }
681
682 let parent = signature.parent_object.clone();
683 let method = signature.method_node.clone();
684 self.model.method_call = Some(MethodCallState::Calling {
685 node: node.clone(),
686 signature,
687 edited,
688 });
689 self.spawn_method_call(ctx, parent, method, variants, node);
690 }
691
692 fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
693 let client = self.client.clone();
694 let tx = self.update_tx.clone();
695 let ctx = ctx.clone();
696 self.rt.spawn(async move {
697 let result = client
698 .read_method_signature(&node)
699 .await
700 .map_err(|e| e.to_string());
701 let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
702 ctx.request_repaint();
703 });
704 }
705
706 fn spawn_method_call<C: FrontendCtx>(
707 &self,
708 ctx: &C,
709 parent: NodeId,
710 method: NodeId,
711 inputs: Vec<opcua::types::Variant>,
712 node: NodeId,
713 ) {
714 let client = self.client.clone();
715 let tx = self.update_tx.clone();
716 let ctx = ctx.clone();
717 self.rt.spawn(async move {
718 let result = client
719 .call_method(&parent, &method, inputs)
720 .await
721 .map_err(|e| e.to_string());
722 let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
723 ctx.request_repaint();
724 });
725 }
726
727 fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
728 if self.model.tree.expanded.contains(&node) {
729 self.model.tree.expanded.remove(&node);
730 } else if self.model.tree.children.contains_key(&node) {
731 self.model.tree.expanded.insert(node);
732 } else if !self.model.tree.loading.contains(&node) {
733 self.model.tree.loading.insert(node.clone());
734 self.spawn_browse_children(ctx, node);
735 }
736 }
737
738 fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
739 if self.model.tree.expanded.contains(&node) {
740 return;
741 }
742 if self.model.tree.children.contains_key(&node) {
743 self.model.tree.expanded.insert(node);
744 } else if !self.model.tree.loading.contains(&node) {
745 self.model.tree.loading.insert(node.clone());
746 self.spawn_browse_children(ctx, node);
747 }
748 }
749
750 fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
751 self.model.selected = Some(node.clone());
752 self.model.node_summary = None;
753 self.model.references = None;
754 self.spawn_node_summary(ctx, node.clone());
755 if self.model.active_tab == DetailTab::References {
756 self.spawn_browse_references(ctx, node.clone());
757 }
758 self.spawn_resolve_path(ctx, node);
759 }
760
761 fn select_first_matching_endpoint(&mut self) {
762 if let Some(eps) = self.model.discovered_endpoints.as_ref() {
763 let mut filtered: Vec<&EndpointInfo> = eps
764 .iter()
765 .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
766 .collect();
767 filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
768 self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
769 }
770 }
771
772 fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
773 self.model.endpoints_dialog_open = true;
774 if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
775 self.spawn_discover_endpoints(ctx);
776 }
777 }
778
779 fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
780 let client = self.client.clone();
781 let tx = self.update_tx.clone();
782 let url = self.model.endpoint_url.clone();
783 let ctx = ctx.clone();
784 self.rt.spawn(async move {
785 match client.node_path(&node).await {
786 Ok(path) => {
787 let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
788 ctx.request_repaint();
789 }
790 Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
791 }
792 });
793 }
794
795 pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
796 let client = self.client.clone();
797 let tx = self.update_tx.clone();
798 let ctx = ctx.clone();
799 self.rt.spawn(async move {
800 let target = match client.resolve_browse_path(&path).await {
801 Ok(t) => t,
802 Err(e) => {
803 tracing::warn!("resolve path '{path}' failed: {e}");
804 return;
805 }
806 };
807 let chain = match client.node_path(&target).await {
808 Ok(c) => c,
809 Err(e) => {
810 tracing::warn!("node_path for '{path}' failed: {e}");
811 return;
812 }
813 };
814 if chain.is_empty() {
815 return;
816 }
817 let final_target = chain.last().cloned().unwrap();
818 for parent in chain.iter().take(chain.len() - 1) {
819 match client.browse_children(parent).await {
820 Ok(children) => {
821 let _ = tx.send(UiUpdate::ChildrenLoaded {
822 parent: parent.clone(),
823 children: Ok(children),
824 });
825 }
826 Err(e) => {
827 tracing::warn!("navigate: browse_children({parent}) failed: {e}");
828 ctx.request_repaint();
829 return;
830 }
831 }
832 }
833 let _ = tx.send(UiUpdate::RestoreSelection(final_target));
834 ctx.request_repaint();
835 });
836 }
837
838 fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
839 let client = self.client.clone();
840 let tx = self.update_tx.clone();
841 let ctx = ctx.clone();
842 self.rt.spawn(async move {
843 if path.is_empty() {
844 return;
845 }
846 let target = path.last().cloned().unwrap();
847 for parent in path.iter().take(path.len() - 1) {
848 match client.browse_children(parent).await {
849 Ok(children) => {
850 let _ = tx.send(UiUpdate::ChildrenLoaded {
851 parent: parent.clone(),
852 children: Ok(children),
853 });
854 }
855 Err(e) => {
856 tracing::warn!("restore: browse_children({parent}) failed: {e}");
857 ctx.request_repaint();
858 return;
859 }
860 }
861 }
862 let _ = tx.send(UiUpdate::RestoreSelection(target));
863 ctx.request_repaint();
864 });
865 }
866
867 fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
868 let client = self.client.clone();
869 let tx = self.update_tx.clone();
870 let url = self.model.endpoint_url.clone();
871 let endpoint = self.model.selected_endpoint.clone();
872 let auth = AuthSpec {
873 mode: self.model.auth_mode,
874 username: self.model.auth_username.clone(),
875 password: self.model.auth_password.clone(),
876 cert_path: self.model.auth_cert_path.clone(),
877 key_path: self.model.auth_key_path.clone(),
878 };
879 let ctx = ctx.clone();
880 let _ = tx.send(UiUpdate::ConnectStarted);
881 self.rt.spawn(async move {
882 let r = client
883 .connect(&url, endpoint.as_ref(), &auth, tx.clone())
884 .await
885 .map_err(|e| e.to_string());
886 let _ = tx.send(UiUpdate::ConnectFinished(r));
887 ctx.request_repaint();
888 });
889 }
890
891 fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
892 self.model.endpoints_loading = true;
893 self.model.discovered_endpoints = None;
894 let client = self.client.clone();
895 let tx = self.update_tx.clone();
896 let url = self.model.endpoint_url.clone();
897 let ctx = ctx.clone();
898 self.rt.spawn(async move {
899 let r = client
900 .discover_endpoints(&url)
901 .await
902 .map_err(|e| e.to_string());
903 let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
904 ctx.request_repaint();
905 });
906 }
907
908 fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
909 let client = self.client.clone();
910 let tx = self.update_tx.clone();
911 let ctx = ctx.clone();
912 let _ = tx.send(UiUpdate::DisconnectStarted);
913 self.rt.spawn(async move {
914 if let Err(e) = client.disconnect().await {
915 tracing::warn!("disconnect: {e}");
916 }
917 let _ = tx.send(UiUpdate::DisconnectFinished);
918 ctx.request_repaint();
919 });
920 }
921
922 fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
923 let client = self.client.clone();
924 let tx = self.update_tx.clone();
925 let ctx = ctx.clone();
926 self.rt.spawn(async move {
927 let r = client.browse_children(&node).await.map_err(|e| e.to_string());
928 let _ = tx.send(UiUpdate::ChildrenLoaded {
929 parent: node,
930 children: r,
931 });
932 ctx.request_repaint();
933 });
934 }
935
936 fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
937 let client = self.client.clone();
938 let tx = self.update_tx.clone();
939 let ctx = ctx.clone();
940 self.rt.spawn(async move {
941 let r = client
942 .read_node_summary(&node)
943 .await
944 .map_err(|e| e.to_string());
945 let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
946 ctx.request_repaint();
947 });
948 }
949
950 fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
951 let client = self.client.clone();
952 let tx = self.update_tx.clone();
953 let ctx = ctx.clone();
954 self.rt.spawn(async move {
955 let r = client.browse_path(&node).await.map_err(|e| e.to_string());
956 let _ = tx.send(UiUpdate::PathReady { node, path: r });
957 ctx.request_repaint();
958 });
959 }
960
961 fn spawn_subscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
962 let client = self.client.clone();
963 let tx = self.update_tx.clone();
964 let ctx = ctx.clone();
965 let data_tx = self.update_tx.clone();
966 self.rt.spawn(async move {
967 let result = client
968 .subscribe(node.clone(), data_tx)
969 .await
970 .map_err(|e| e.to_string());
971 let _ = tx.send(UiUpdate::SubscribeFinished { node, result });
972 ctx.request_repaint();
973 });
974 }
975
976 fn resubscribe_all<C: FrontendCtx>(&mut self, ctx: &C) {
977 let nodes: Vec<NodeId> = self
978 .model
979 .subscriptions
980 .iter()
981 .map(|r| r.node_id.clone())
982 .collect();
983 if nodes.is_empty() {
984 return;
985 }
986 for node in &nodes {
987 self.model.subscribing.insert(node.clone());
988 }
989 let client = self.client.clone();
990 let tx = self.update_tx.clone();
991 let data_tx = self.update_tx.clone();
992 let ctx = ctx.clone();
993 self.rt.spawn(async move {
994 client.reset_subscription_state().await;
995 for node in nodes {
996 let result = client
997 .subscribe(node.clone(), data_tx.clone())
998 .await
999 .map_err(|e| e.to_string());
1000 let _ = tx.send(UiUpdate::SubscribeFinished { node, result });
1001 }
1002 ctx.request_repaint();
1003 });
1004 }
1005
1006 fn spawn_unsubscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
1007 let client = self.client.clone();
1008 let tx = self.update_tx.clone();
1009 let ctx = ctx.clone();
1010 self.rt.spawn(async move {
1011 let result = client.unsubscribe(&node).await.map_err(|e| e.to_string());
1012 let _ = tx.send(UiUpdate::UnsubscribeFinished { node, result });
1013 ctx.request_repaint();
1014 });
1015 }
1016
1017 fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
1018 self.model.references_loading = true;
1019 let client = self.client.clone();
1020 let tx = self.update_tx.clone();
1021 let ctx = ctx.clone();
1022 self.rt.spawn(async move {
1023 let r = client
1024 .browse_references(&node)
1025 .await
1026 .map_err(|e| e.to_string());
1027 let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
1028 ctx.request_repaint();
1029 });
1030 }
1031}
1032
1033fn render_value_for_clipboard(value: &ValueTree) -> String {
1034 use std::fmt::Write as _;
1035 fn render(v: &ValueTree, indent: usize, out: &mut String) {
1036 let pad = " ".repeat(indent);
1037 match v {
1038 ValueTree::Null => {
1039 let _ = write!(out, "{pad}<null>");
1040 }
1041 ValueTree::Leaf(s) => {
1042 let _ = write!(out, "{pad}{s}");
1043 }
1044 ValueTree::Array(items) => {
1045 for (i, item) in items.iter().enumerate() {
1046 if i > 0 {
1047 out.push('\n');
1048 }
1049 let _ = write!(out, "{pad}[{i}]");
1050 out.push('\n');
1051 render(item, indent + 1, out);
1052 }
1053 }
1054 ValueTree::Object(fields) => {
1055 for (i, (k, val)) in fields.iter().enumerate() {
1056 if i > 0 {
1057 out.push('\n');
1058 }
1059 let _ = write!(out, "{pad}{k}:");
1060 out.push('\n');
1061 render(val, indent + 1, out);
1062 }
1063 }
1064 }
1065 }
1066 let mut out = String::new();
1067 render(value, 0, &mut out);
1068 out
1069}
1070
1071fn forward_logs(
1072 mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
1073 update_tx: mpsc::UnboundedSender<UiUpdate>,
1074) {
1075 std::thread::spawn(move || {
1076 while let Some(msg) = log_rx.blocking_recv() {
1077 if update_tx.send(msg).is_err() {
1078 break;
1079 }
1080 }
1081 });
1082}