Skip to main content

netget/tui/
actions.rs

1//! Every instance action, executed in one place.
2//!
3//! A letter on the list, Enter on an action-bar button, a click on that
4//! button, a click and Enter on a row all produce an [`InstanceAction`]
5//! and land here, so what they do cannot drift apart. Anything that touches
6//! the network is spawned and reports back through `uimsg` — see that
7//! module for why nothing here may await a connect.
8
9use crate::state::app_state::AppState;
10use crate::tui::app::{DashboardApp, Section, UiKey};
11use crate::tui::cards::InstanceAction;
12use crate::tui::modal::{confirm, Modal, PendingAction};
13use crate::tui::uimsg::{ActionOrigin, UiMsg};
14
15/// Run `action` against `key`.
16pub async fn run(app: &mut DashboardApp, key: UiKey, action: InstanceAction, state: &AppState) {
17    app.dirty = true;
18    match action {
19        InstanceAction::Stop => stop_instance(app, key, state).await,
20        InstanceAction::Edit => open_editor(app, key),
21        InstanceAction::Rules => open_routing_at(app, key, state, RouteTarget::List),
22        InstanceAction::AddRule => open_routing_at(app, key, state, RouteTarget::New),
23        InstanceAction::EditRule(index) => {
24            open_routing_at(app, key, state, RouteTarget::Edit(index))
25        }
26        InstanceAction::DeleteRule(index) => edit_rules_headless(app, key, state, |model| {
27            if index < model.handlers.len() {
28                model.selected = index;
29                model.delete_selected();
30                Some(format!("deleted rule {}", index + 1))
31            } else {
32                None
33            }
34        }),
35        InstanceAction::MoveRule(index, delta) => edit_rules_headless(app, key, state, |model| {
36            if index < model.handlers.len() {
37                model.selected = index;
38                let before = model.selected;
39                model.reorder(delta as isize);
40                if model.selected != before {
41                    Some(format!(
42                        "moved rule {} to position {}",
43                        index + 1,
44                        model.selected + 1
45                    ))
46                } else {
47                    None
48                }
49            } else {
50                None
51            }
52        }),
53        InstanceAction::CycleDriver => cycle_driver(app, key, state),
54        InstanceAction::ConnectClient => {
55            if let UiKey::Server(id) = key {
56                open_client_for_server(app, id, state).await;
57            }
58        }
59        InstanceAction::Send => {
60            if let UiKey::Client(id) = key {
61                open_composer(app, id, None, state).await;
62            }
63        }
64        InstanceAction::SendAction(index) => {
65            if let UiKey::Client(id) = key {
66                open_composer(app, id, Some(index), state).await;
67            }
68        }
69        InstanceAction::MessagePeer(conn) => {
70            if let UiKey::Server(id) = key {
71                open_peer_composer(app, id, conn);
72            }
73        }
74        InstanceAction::DisconnectPeer(conn) => {
75            if let UiKey::Server(id) = key {
76                disconnect_peer(app, id, conn, state);
77            }
78        }
79        InstanceAction::Disconnect => {
80            if let UiKey::Client(id) = key {
81                disconnect_client(app, id, state).await;
82            }
83        }
84        InstanceAction::Connect => {
85            if let UiKey::Client(id) = key {
86                connect_client(app, id, state);
87            }
88        }
89        InstanceAction::Wireshark => open_wireshark(app, key),
90        InstanceAction::Docs => show_docs(app, key),
91        InstanceAction::Answer(id) => open_intercept(app, key, id, state),
92        InstanceAction::OpenRequest(id) => open_request(app, key, id),
93    }
94}
95
96/// Stop an instance immediately — no confirmation. Stopping is cheap to redo
97/// (recreate from the picker) and the dialog was pure friction; only the bulk
98/// actions (stop all, quit) keep a confirm.
99async fn stop_instance(app: &mut DashboardApp, key: UiKey, state: &AppState) {
100    let action = match key {
101        UiKey::Server(id) => PendingAction::StopServer(id),
102        UiKey::Client(id) => PendingAction::StopClient(id),
103    };
104    let line = confirm::execute(&action, state).await;
105    app.push_system(line);
106}
107
108/// Hang up a client's connection, keeping the row for `[ connect ]` later.
109async fn disconnect_client(app: &mut DashboardApp, id: crate::state::ClientId, state: &AppState) {
110    if state.disconnect_client(id).await {
111        app.push_system(format!(
112            "client #{} disconnected — [ connect ] re-establishes it",
113            id.as_u32()
114        ));
115    } else {
116        app.push_system(format!("client #{} is already gone", id.as_u32()));
117    }
118}
119
120/// (Re)connect a disconnected client. Spawned: connecting is network I/O and
121/// must not block the event loop (see `crate::tui::uimsg`).
122fn connect_client(app: &mut DashboardApp, id: crate::state::ClientId, state: &AppState) {
123    app.push_system(format!("connecting client #{}…", id.as_u32()));
124    let llm = app.llm_client.clone();
125    let status_tx = app.status_tx.clone();
126    let ui_tx = app.ui_tx.clone();
127    let state = state.clone();
128    tokio::spawn(async move {
129        let message = match crate::cli::client_startup::start_client_by_id(
130            &state, id, &llm, &status_tx,
131        )
132        .await
133        {
134            Ok(_) => format!("client #{} connected", id.as_u32()),
135            Err(e) => format!("client #{} failed to connect: {e}", id.as_u32()),
136        };
137        let _ = ui_tx.send(UiMsg::Chat(message));
138    });
139}
140
141fn show_docs(app: &mut DashboardApp, key: UiKey) {
142    let Some(instance) = app.instance(key) else {
143        return;
144    };
145    let protocol = instance.protocol().to_string();
146    let text = match key {
147        UiKey::Server(_) => crate::protocol::server_registry::registry()
148            .resolve(&protocol)
149            .map(|p| {
150                format!(
151                    "{} — {}\n{}",
152                    p.protocol_name(),
153                    p.description(),
154                    p.metadata().summary()
155                )
156            })
157            .unwrap_or_else(|e| e.to_string()),
158        UiKey::Client(_) => crate::protocol::CLIENT_REGISTRY
159            .resolve(&protocol)
160            .map(|p| {
161                format!(
162                    "{} client — {}\n{}",
163                    p.protocol_name(),
164                    p.description(),
165                    p.metadata().summary()
166                )
167            })
168            .unwrap_or_else(|e| e.to_string()),
169    };
170    app.push_system(text);
171}
172
173/// Open the full request/response for one access-log entry.
174fn open_request(app: &mut DashboardApp, key: UiKey, id: u64) {
175    let entry = app
176        .instance(key)
177        .and_then(|i| i.requests().iter().find(|r| r.id == id).cloned());
178    match entry {
179        Some(entry) => app.modals.push(Modal::RequestDetail {
180            entry: Box::new(entry),
181            scroll: 0,
182        }),
183        None => app.push_system(format!(
184            "request #{id} is no longer in the log (the per-instance log keeps the latest 100)"
185        )),
186    }
187}
188
189/// Open the answer modal for a pending intercept.
190pub fn open_intercept(app: &mut DashboardApp, key: UiKey, intercept_id: u64, state: &AppState) {
191    use crate::tui::modal::intercept::InterceptModel;
192
193    let Some(instance) = app.instance(key) else {
194        return;
195    };
196    let protocol = instance.protocol().to_string();
197    let view = instance
198        .intercepts()
199        .iter()
200        .find(|v| v.id == intercept_id)
201        .cloned();
202    let Some(view) = view else {
203        app.push_system(format!(
204            "request #{intercept_id} is no longer waiting (answered or timed out)"
205        ));
206        return;
207    };
208    let (_events, vocabulary) = crate::tui::modal::routing::vocabulary(key, &protocol, state);
209    app.modals.push(Modal::Intercept(Box::new(InterceptModel {
210        id: view.id,
211        owner: key,
212        protocol,
213        event_type: view.event_type,
214        description: view.description,
215        event_data: view.event_data,
216        vocabulary,
217        error: None,
218        focused: 0,
219    })));
220}
221
222/// Which handler the routing editor should open on.
223pub enum RouteTarget {
224    /// The table itself, nothing opened.
225    List,
226    /// Edit an existing handler by index.
227    Edit(usize),
228    /// Start a new handler.
229    New,
230}
231
232fn open_editor(app: &mut DashboardApp, key: UiKey) {
233    use crate::tui::modal::form::FormModel;
234    let model = match key {
235        UiKey::Server(id) => app.server_row(id).map(FormModel::for_edit_server),
236        UiKey::Client(id) => app.client_row(id).map(FormModel::for_edit_client),
237    };
238    if let Some(model) = model {
239        app.modals.push(Modal::Form(Box::new(model)));
240    }
241}
242
243/// Open the routing editor, optionally landing straight on one handler.
244pub fn open_routing_at(app: &mut DashboardApp, key: UiKey, state: &AppState, target: RouteTarget) {
245    use crate::tui::modal::routing::RoutingModel;
246    let model = app
247        .instance(key)
248        .map(|i| RoutingModel::new(key, i.protocol(), i.routing(), state));
249    if let Some(mut model) = model {
250        match target {
251            RouteTarget::List => {}
252            RouteTarget::New => model.add(),
253            RouteTarget::Edit(index) => {
254                if index < model.handlers.len() {
255                    model.selected = index;
256                    model.edit_selected();
257                }
258            }
259        }
260        app.modals.push(Modal::Routing(Box::new(model)));
261    }
262}
263
264/// Change the handler table without opening the editor: build the same
265/// model the editor uses, apply `change`, and submit it the same way. The
266/// change is described in chat; a failure lands there too.
267fn edit_rules_headless(
268    app: &mut DashboardApp,
269    key: UiKey,
270    state: &AppState,
271    change: impl FnOnce(&mut crate::tui::modal::routing::RoutingModel) -> Option<String>,
272) {
273    use crate::tui::modal::routing::RoutingModel;
274    let Some(mut model) = app
275        .instance(key)
276        .map(|i| RoutingModel::new(key, i.protocol(), i.routing(), state))
277    else {
278        return;
279    };
280    let Some(description) = change(&mut model) else {
281        return;
282    };
283    app.push_system(format!("{}: {description}…", key.describe()));
284    spawn_routing_apply(app, model, state);
285}
286
287fn spawn_routing_apply(
288    app: &mut DashboardApp,
289    model: crate::tui::modal::routing::RoutingModel,
290    state: &AppState,
291) {
292    let llm = app.llm_client.clone();
293    let status_tx = app.status_tx.clone();
294    let ui_tx = app.ui_tx.clone();
295    let state = state.clone();
296    tokio::spawn(async move {
297        let message = match model.apply(&state, llm, &status_tx).await {
298            Ok(summary) => summary,
299            Err(e) => format!("✗ {e}"),
300        };
301        let _ = ui_tx.send(UiMsg::Chat(message));
302    });
303}
304
305/// MANUAL → LLM → SILENT → MANUAL, applied as a hot handler-table swap.
306fn cycle_driver(app: &mut DashboardApp, key: UiKey, state: &AppState) {
307    use crate::cli::management::{self, ClientForm, ServerForm};
308    use crate::tui::driver::{driver_of, handlers_with_driver};
309
310    let Some(instance) = app.instance(key) else {
311        return;
312    };
313    let current = driver_of(instance.routing());
314    let next = current.next();
315    let handlers = handlers_with_driver(instance.routing(), next);
316    let protocol = instance.protocol().to_string();
317    app.push_system(format!(
318        "{}: driver {} → {} — {}",
319        key.describe(),
320        current.label(),
321        next.label(),
322        next.describe()
323    ));
324
325    let llm = app.llm_client.clone();
326    let status_tx = app.status_tx.clone();
327    let ui_tx = app.ui_tx.clone();
328    let state = state.clone();
329    tokio::spawn(async move {
330        let result = match key {
331            UiKey::Server(id) => management::update_server(
332                &state,
333                id,
334                ServerForm {
335                    protocol,
336                    event_handlers: Some(handlers),
337                    ..Default::default()
338                },
339                status_tx,
340            )
341            .await
342            .map(|o| o.summary),
343            UiKey::Client(id) => management::update_client(
344                &state,
345                id,
346                ClientForm {
347                    protocol,
348                    event_handlers: Some(handlers),
349                    ..Default::default()
350                },
351                llm,
352                status_tx,
353            )
354            .await
355            .map(|o| o.summary),
356        };
357        let message = match result {
358            Ok(summary) => summary,
359            Err(e) => format!("✗ could not change the driver: {e}"),
360        };
361        let _ = ui_tx.send(UiMsg::Chat(message));
362    });
363}
364
365/// The create form `[ + <proto> client ]` applies for a server: a client of the server
366/// protocol's compiled counterpart (`protocol::compiled_client_protocol_for_server`), pointed
367/// at 127.0.0.1 on the port the server actually bound, with an instruction naming the pair.
368/// Copies usable server configuration (OpenAPI spec) and supplies known local endpoints.
369/// `Err` says why there is none. Shared with the browser build's page API, so a page that
370/// connects a client to one of its servers goes through exactly what the button does.
371pub fn client_form_for_server(
372    server_id: crate::state::ServerId,
373    server_protocol: &str,
374    local_addr: Option<&str>,
375    port: u16,
376    server_startup_params: Option<&serde_json::Value>,
377) -> Result<crate::tui::modal::form::FormModel, String> {
378    use crate::tui::modal::form::{FieldTarget, FormModel};
379    let Some(client_protocol) =
380        crate::protocol::compiled_client_protocol_for_server(server_protocol)
381    else {
382        return Err(format!(
383            "{server_protocol} has no client implementation compiled into this build"
384        ));
385    };
386    let address = loopback_target(local_addr, port);
387    let http_url = format!("http://{address}");
388    // These clients either default a bare address to HTTPS or require an absolute URL.
389    // Our paired servers listen on plain HTTP, so make that choice explicit.
390    let remote = match client_protocol.to_ascii_lowercase().as_str() {
391        "npm" | "pypi" | "maven" | "openidconnect" => http_url.clone(),
392        _ => address,
393    };
394
395    let mut model = FormModel::for_create(Section::Clients, &client_protocol, None);
396    model.set_field_value(&FieldTarget::RemoteAddr, remote.clone());
397    match client_protocol.to_ascii_lowercase().as_str() {
398        "openapi" => {
399            if let Some(spec) = server_startup_params
400                .and_then(|params| params.get("spec"))
401                .and_then(|spec| spec.as_str())
402            {
403                model.set_field_value(&FieldTarget::StartupParam("spec".into()), spec.into());
404            }
405            model.set_field_value(&FieldTarget::StartupParam("base_url".into()), http_url);
406        }
407        "oauth2" => {
408            model.set_field_value(
409                &FieldTarget::StartupParam("auth_url".into()),
410                format!("{http_url}/authorize"),
411            );
412            model.set_field_value(
413                &FieldTarget::StartupParam("token_url".into()),
414                format!("{http_url}/token"),
415            );
416        }
417        _ => {}
418    }
419    model.set_field_value(
420        &FieldTarget::Instruction,
421        format!(
422            "You are a {client_protocol} client connected to our own server #{} at {remote}.",
423            server_id.as_u32()
424        ),
425    );
426    Ok(model)
427}
428
429/// Where a client of our own server connects: 127.0.0.1 on the port the server actually
430/// bound (its configured port may be 0).
431fn loopback_target(local_addr: Option<&str>, port: u16) -> String {
432    let port = local_addr
433        .and_then(|a| a.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()))
434        .unwrap_or(port);
435    format!("127.0.0.1:{port}")
436}
437
438/// `[ + client ]` on a server: create a client of the counterpart protocol
439/// pointed at that very server, so the pair can talk to each other.
440async fn open_client_for_server(
441    app: &mut DashboardApp,
442    server_id: crate::state::ServerId,
443    state: &AppState,
444) {
445    let Some(row) = app.server_row(server_id) else {
446        return;
447    };
448    let mut model = match client_form_for_server(
449        server_id,
450        &row.protocol,
451        row.local_addr.as_deref(),
452        row.port,
453        row.startup_params.as_ref(),
454    ) {
455        Ok(model) => model,
456        Err(why) => {
457            app.push_system(why);
458            return;
459        }
460    };
461    let client_protocol = model.protocol.clone();
462    let remote = loopback_target(row.local_addr.as_deref(), row.port);
463
464    // When everything this client needs is known, connect it rather than
465    // showing a form with nothing left to fill in. A client whose protocol
466    // declares a required startup parameter (openai's api_key, say) cannot be
467    // defaulted: show the form pre-filled, the cursor on the missing field.
468    if let Some(missing) = model.missing_required() {
469        model.focus_first_missing_required();
470        model.error = Some(format!(
471            "{client_protocol} needs {missing} before it can connect — fill it in and press [ Apply ]"
472        ));
473        app.modals.push(Modal::Form(Box::new(model)));
474        return;
475    }
476    app.push_system(format!(
477        "Connecting a {client_protocol} client to {remote}…"
478    ));
479    spawn_form_apply(app, model, state);
480}
481
482/// Apply a create/edit form off the event loop; the result closes the form
483/// (success) or shows in it (failure) via `handle_ui_msg`.
484pub fn spawn_form_apply(
485    app: &mut DashboardApp,
486    model: crate::tui::modal::form::FormModel,
487    state: &AppState,
488) {
489    let llm = app.llm_client.clone();
490    let status_tx = app.status_tx.clone();
491    let ui_tx = app.ui_tx.clone();
492    let state = state.clone();
493    tokio::spawn(async move {
494        let result = model
495            .apply(&state, llm, &status_tx)
496            .await
497            .map_err(|e| e.to_string());
498        let _ = ui_tx.send(UiMsg::ActionDone {
499            origin: ActionOrigin::Form,
500            result,
501        });
502    });
503}
504
505/// Compose an action for one live server connection. The vocabulary is the
506/// server protocol's sync actions — its wire verbs (send_tcp_data and
507/// friends), not its management ones.
508fn open_peer_composer(
509    app: &mut DashboardApp,
510    server_id: crate::state::ServerId,
511    connection_id: u32,
512) {
513    use crate::tui::modal::composer::ComposerModel;
514    let Some(row) = app.server_row(server_id) else {
515        return;
516    };
517    let Ok(protocol) = crate::protocol::server_registry::registry().resolve(&row.protocol) else {
518        app.push_system(format!("{} is not a registered protocol", row.protocol));
519        return;
520    };
521    let actions = protocol.get_sync_actions();
522    if actions.is_empty() {
523        app.push_system(format!("{} declares no wire actions to send", row.protocol));
524        return;
525    }
526    let protocol_name = row.protocol.clone();
527    app.modals
528        .push(Modal::Composer(Box::new(ComposerModel::for_peer(
529            server_id,
530            connection_id,
531            &protocol_name,
532            actions,
533        ))));
534}
535
536/// Close one live server connection from the server's side, through the
537/// peer handle with the protocol's own `close_connection` action.
538fn disconnect_peer(
539    app: &mut DashboardApp,
540    server_id: crate::state::ServerId,
541    connection_id: u32,
542    state: &AppState,
543) {
544    use crate::tui::modal::composer::{ComposerModel, ComposerTarget};
545    let target = ComposerTarget::Peer {
546        server: server_id,
547        connection: connection_id,
548    };
549    app.push_system(format!("{}: disconnecting…", target.describe()));
550    let ui_tx = app.ui_tx.clone();
551    let state = state.clone();
552    tokio::spawn(async move {
553        let message = match ComposerModel::deliver(
554            target,
555            &state,
556            serde_json::json!({"type": "close_connection"}),
557        )
558        .await
559        {
560            Ok(outcome) => format!(
561                "{}: {}",
562                target.describe(),
563                crate::tui::modal::composer::describe(&outcome)
564            ),
565            Err(e) => e.to_string(),
566        };
567        let _ = ui_tx.send(UiMsg::Chat(message));
568    });
569}
570
571/// Open the send composer for a client, optionally straight on one verb.
572async fn open_composer(
573    app: &mut DashboardApp,
574    client_id: crate::state::ClientId,
575    action_index: Option<usize>,
576    state: &AppState,
577) {
578    let Some(row) = app.client_row(client_id) else {
579        return;
580    };
581    match row.send_state {
582        crate::tui::projection::SendState::Ready => {}
583        crate::tui::projection::SendState::NotConnected => {
584            app.push_system(format!(
585                "client #{} is not connected — nothing to send through",
586                client_id.as_u32()
587            ));
588            return;
589        }
590        crate::tui::projection::SendState::ProtocolUnsupported => {
591            app.push_system(format!(
592                "the {} client cannot take injected actions yet: its connection loop has not \
593                 adopted the command channel (see src/client/command_support.rs)",
594                row.protocol
595            ));
596            return;
597        }
598    }
599    use crate::tui::modal::composer::ComposerModel;
600    let protocol = row.protocol.clone();
601    let wanted = action_index.and_then(|i| row.send_actions.get(i).map(|v| v.name.clone()));
602    let actions = ComposerModel::vocabulary(&protocol, state);
603    if actions.is_empty() {
604        app.push_system(format!("{protocol} declares no client actions"));
605        return;
606    }
607    let mut model = ComposerModel::new(client_id, &protocol, actions);
608    if let Some(name) = wanted {
609        // Look the verb up by name: the send section's rows are the vocabulary
610        // minus response-only verbs, so their indices are not the composer's.
611        if let Some(index) = model.actions.iter().position(|a| a.name == name) {
612            model.selected = index;
613            model.choose();
614        }
615    }
616    app.modals.push(Modal::Composer(Box::new(model)));
617}
618
619/// Open the Wireshark recipe for a running instance, from the snapshot.
620fn open_wireshark(app: &mut DashboardApp, key: UiKey) {
621    use crate::tui::wireshark::{CapturePlan, CaptureTarget, Platform, Role};
622
623    let target = match key {
624        UiKey::Server(id) => {
625            let Some(row) = app.server_row(id) else {
626                return;
627            };
628            let bound: Option<std::net::SocketAddr> =
629                row.local_addr.as_deref().and_then(|a| a.parse().ok());
630            let param = |name: &str| -> Option<String> {
631                row.startup_params
632                    .as_ref()
633                    .and_then(|p| p.get(name))
634                    .and_then(|v| v.as_str())
635                    .map(str::to_string)
636            };
637            CaptureTarget {
638                protocol: row.protocol.clone(),
639                role: Role::Server,
640                host: bound.map(|a| a.ip().to_string()).or_else(|| param("host")),
641                port: bound.map(|a| a.port()).or(Some(row.port)),
642                interface: param("interface"),
643            }
644        }
645        UiKey::Client(id) => {
646            let Some(row) = app.client_row(id) else {
647                return;
648            };
649            CaptureTarget::client(&row.protocol, Some(&row.remote_addr))
650        }
651    };
652    let plan = CapturePlan::build(target, Platform::current());
653    app.modals.push(Modal::Wireshark {
654        plan: Box::new(plan),
655        scroll: 0,
656    });
657}
658
659/// Open the protocol picker — servers and clients in one list. `prefill_remote`
660/// aims a new client at a specific address.
661pub async fn open_protocol_picker(
662    app: &mut DashboardApp,
663    prefill_remote: Option<String>,
664    state: &AppState,
665) {
666    let caps = state.get_system_capabilities().await;
667    let entries = crate::tui::modal::protocol_picker::all_entries(&caps);
668    if entries.is_empty() {
669        app.push_system("No protocols compiled into this build.");
670        return;
671    }
672    app.modals.push(Modal::ProtocolPicker {
673        entries,
674        filter: String::new(),
675        selected: 0,
676        prefill_remote,
677    });
678}
679
680/// Jump to the oldest parked request anywhere and open its answer modal.
681pub fn answer_next_waiting(app: &mut DashboardApp, state: &AppState) {
682    let mut oldest: Option<(u64, UiKey, u64)> = None;
683    for s in &app.snapshot.servers {
684        for v in &s.intercepts {
685            if oldest
686                .map(|(t, _, _)| v.created_unix_ms < t)
687                .unwrap_or(true)
688            {
689                oldest = Some((v.created_unix_ms, UiKey::Server(s.id), v.id));
690            }
691        }
692    }
693    for c in &app.snapshot.clients {
694        for v in &c.intercepts {
695            if oldest
696                .map(|(t, _, _)| v.created_unix_ms < t)
697                .unwrap_or(true)
698            {
699                oldest = Some((v.created_unix_ms, UiKey::Client(c.id), v.id));
700            }
701        }
702    }
703    match oldest {
704        Some((_, key, id)) => {
705            app.focus_card(key);
706            open_intercept(app, key, id, state);
707        }
708        None => app.push_system("nothing is waiting for you"),
709    }
710}