Skip to main content

netget/tui/
projection.rs

1//! Poll-based projection of `AppState` into owned display data for the rail.
2//!
3//! Mirrors the rolling TUI's `update_ui_from_state`: read everything under
4//! short lock windows, clone out, render from the owned snapshot. Re-polled on
5//! the `__UPDATE_UI__` sentinel, on the 1s stats tick, and immediately after
6//! any UI-initiated mutation.
7
8use std::collections::HashMap;
9
10use crate::state::app_state::{AccessLogEntry, AppState};
11use crate::state::client::{ClientConnectionAttempt, ClientStatus};
12use crate::state::server::{ClosedConnectionSummary, ServerStatus};
13use crate::state::{AccessLogOwner, ClientId, ServerId};
14
15#[derive(Debug, Clone)]
16pub struct ConnRow {
17    pub id: u32,
18    pub remote_addr: String,
19    pub bytes_received: u64,
20    pub bytes_sent: u64,
21    pub active: bool,
22    /// Whether this connection accepts injected actions ("message this
23    /// peer") — true only where the protocol registered a peer handle.
24    pub can_message: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct ServerRow {
29    pub id: ServerId,
30    pub protocol: String,
31    pub port: u16,
32    pub local_addr: Option<String>,
33    pub status: ServerStatus,
34    pub instruction: String,
35    pub memory_len: usize,
36    pub startup_params: Option<serde_json::Value>,
37    pub routing: Option<crate::scripting::EventHandlerConfig>,
38    pub conns: Vec<ConnRow>,
39    pub recent: Vec<ClosedConnectionSummary>,
40    pub requests: Vec<AccessLogEntry>,
41    pub task_count: usize,
42    /// Seconds since the instance was created.
43    pub uptime_secs: u64,
44    /// Canonical client protocol name when this server's protocol has a
45    /// compiled client counterpart (drives the [+client] button).
46    pub client_counterpart: Option<String>,
47    /// Why this server's protocol can only answer its peers, when it declares that
48    /// ([`crate::protocol::metadata::ProtocolMetadataV2::request_only`]): the reason a peer's
49    /// disabled `[ send message ]` gives instead of "not implemented here yet".
50    pub request_only: Option<String>,
51    /// Requests a `manual` rule parked, waiting for the operator's answer.
52    pub intercepts: Vec<crate::state::intercepts::InterceptView>,
53}
54
55#[derive(Debug, Clone)]
56pub struct ClientRow {
57    pub id: ClientId,
58    pub protocol: String,
59    pub remote_addr: String,
60    pub status: ClientStatus,
61    pub instruction: String,
62    pub memory_len: usize,
63    pub startup_params: Option<serde_json::Value>,
64    pub routing: Option<crate::scripting::EventHandlerConfig>,
65    pub connection: Option<ConnRow>,
66    pub history: Vec<ClientConnectionAttempt>,
67    pub requests: Vec<AccessLogEntry>,
68    pub task_count: usize,
69    /// Seconds since the instance was created.
70    pub uptime_secs: u64,
71    /// Whether [send] can be used, and if not, why — "not connected" and "this
72    /// protocol has no command channel yet" are different problems and must
73    /// not be shown as the same one.
74    pub send_state: SendState,
75    /// The client protocol's own verbs, in vocabulary order — the telnet
76    /// client's `send_command` / `send_text`, TCP's `send_tcp_data`. The
77    /// send section renders one row per entry, and a row's index selects the
78    /// action in the composer, so the order must match
79    /// `ComposerModel::vocabulary` exactly (both come from it).
80    pub send_actions: Vec<SendVerb>,
81    /// Replies a `manual` rule parked, waiting for the operator's answer.
82    pub intercepts: Vec<crate::state::intercepts::InterceptView>,
83}
84
85/// One verb a client can send, as the send section lists it.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct SendVerb {
88    pub name: String,
89    pub description: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SendState {
94    /// The client's loop is live and accepts injected actions.
95    Ready,
96    /// The client is not currently connected.
97    NotConnected,
98    /// Connected, but this protocol's loop has not adopted the command
99    /// channel (see `client/command_support.rs`).
100    ProtocolUnsupported,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct RailSnapshot {
105    pub servers: Vec<ServerRow>,
106    pub clients: Vec<ClientRow>,
107    pub pipe_count: usize,
108    pub active_conversations: usize,
109    pub llm_activity: Vec<crate::state::llm_activity::LlmActivity>,
110    pub total_input_tokens: u64,
111    pub total_output_tokens: u64,
112    pub total_llm_calls: u64,
113}
114
115/// Requests kept per band (the full scoped log stays reachable via drill-in).
116const REQUESTS_PER_BAND: usize = 100;
117
118/// Whether an action is worth its own row in a client's send section.
119///
120/// The vocabulary is async ∪ sync, so it also contains verbs that only make
121/// sense as *answers* to a received event, or that the action bar already
122/// offers better. Those are filtered from the rows only — `[ pick a verb… ]`
123/// still opens the composer on the complete list, so nothing is unreachable.
124pub fn is_initiable_action(name: &str) -> bool {
125    match name {
126        // A response-only verb: "I am not answering yet, send me more". Sent
127        // on demand it writes nothing and reports "executed".
128        "wait_for_more" => false,
129        // The client's own `[ disconnect ]` row does this and is strictly
130        // better — it keeps the instance so `[ connect ]` can redial, where
131        // the protocol action just ends the loop.
132        "disconnect" => false,
133        _ => true,
134    }
135}
136
137pub async fn build_snapshot(state: &AppState) -> RailSnapshot {
138    let servers = state.get_all_servers().await;
139    let clients = state.get_all_clients().await;
140    let tasks = state.get_all_tasks().await;
141    let llm_activity = state.llm_activity.snapshot();
142    let (total_input_tokens, total_output_tokens, total_llm_calls) = state.get_llm_stats().await;
143    let pipe_count = state.list_pipes().await.len();
144
145    // Pending manual-handler intercepts, bucketed per owner.
146    let mut server_intercepts: HashMap<u32, Vec<crate::state::intercepts::InterceptView>> =
147        HashMap::new();
148    let mut client_intercepts: HashMap<u32, Vec<crate::state::intercepts::InterceptView>> =
149        HashMap::new();
150    for view in state.list_intercepts().await {
151        match view.owner {
152            crate::state::intercepts::InterceptOwner::Server(id) => {
153                server_intercepts.entry(id.as_u32()).or_default().push(view)
154            }
155            crate::state::intercepts::InterceptOwner::Client(id) => {
156                client_intercepts.entry(id.as_u32()).or_default().push(view)
157            }
158        }
159    }
160
161    // One pass over the (global, capped) access log, bucketed per owner.
162    let mut server_requests: HashMap<u32, Vec<AccessLogEntry>> = HashMap::new();
163    let mut client_requests: HashMap<u32, Vec<AccessLogEntry>> = HashMap::new();
164    for entry in state.list_access_logs(None).await {
165        match entry.owner() {
166            Some(AccessLogOwner::Server(id)) => {
167                let bucket = server_requests.entry(id).or_default();
168                if bucket.len() < REQUESTS_PER_BAND {
169                    bucket.push(entry);
170                }
171            }
172            Some(AccessLogOwner::Client(id)) => {
173                let bucket = client_requests.entry(id).or_default();
174                if bucket.len() < REQUESTS_PER_BAND {
175                    bucket.push(entry);
176                }
177            }
178            None => {}
179        }
180    }
181
182    let mut server_rows = Vec::with_capacity(servers.len());
183    for server in servers {
184        let mut conns: Vec<ConnRow> = Vec::with_capacity(server.connections.len());
185        for c in server.connections.values() {
186            conns.push(ConnRow {
187                id: c.id.as_u32(),
188                remote_addr: c.remote_addr.to_string(),
189                bytes_received: c.bytes_received,
190                bytes_sent: c.bytes_sent,
191                active: c.status == crate::state::server::ConnectionStatus::Active,
192                can_message: state.has_peer_handle(server.id, c.id.as_u32()).await,
193            });
194        }
195        conns.sort_by_key(|c| c.id);
196
197        let task_count = tasks
198            .iter()
199            .filter(|t| match t.scope {
200                crate::state::task::TaskScope::Server(sid) => sid == server.id,
201                crate::state::task::TaskScope::Connection(sid, _) => sid == server.id,
202                _ => false,
203            })
204            .count();
205
206        server_rows.push(ServerRow {
207            id: server.id,
208            protocol: server.protocol_name.clone(),
209            port: server.port,
210            local_addr: server.local_addr.map(|a| a.to_string()),
211            status: server.status.clone(),
212            instruction: server.instruction.clone(),
213            memory_len: server.memory.len(),
214            startup_params: server.startup_params.clone(),
215            routing: server.event_handler_config.clone(),
216            recent: server.recent_connections.iter().cloned().collect(),
217            requests: server_requests
218                .remove(&server.id.as_u32())
219                .unwrap_or_default(),
220            conns,
221            task_count,
222            uptime_secs: server.created_at.elapsed().as_secs(),
223            client_counterpart: crate::protocol::compiled_client_protocol_for_server(
224                &server.protocol_name,
225            ),
226            request_only: crate::protocol::server_registry::registry()
227                .request_only_reason(&server.protocol_name)
228                .map(str::to_string),
229            intercepts: server_intercepts
230                .remove(&server.id.as_u32())
231                .unwrap_or_default(),
232        });
233    }
234    server_rows.sort_by_key(|s| s.id.as_u32());
235
236    let mut client_rows = Vec::with_capacity(clients.len());
237    for client in clients {
238        let connected = client.status == ClientStatus::Connected;
239        let send_state = match (connected, state.has_client_handle(client.id).await) {
240            (true, true) => SendState::Ready,
241            (true, false) => SendState::ProtocolUnsupported,
242            (false, _) => SendState::NotConnected,
243        };
244        let task_count = tasks
245            .iter()
246            .filter(|t| matches!(t.scope, crate::state::task::TaskScope::Client(cid) if cid == client.id))
247            .count();
248        client_rows.push(ClientRow {
249            id: client.id,
250            protocol: client.protocol_name.clone(),
251            remote_addr: client.remote_addr.clone(),
252            status: client.status.clone(),
253            instruction: client.instruction.clone(),
254            memory_len: client.memory.len(),
255            startup_params: client.startup_params.clone(),
256            routing: client.event_handler_config.clone(),
257            connection: client.connection.as_ref().map(|c| ConnRow {
258                id: c.id.as_u32(),
259                remote_addr: c.remote_addr.clone(),
260                bytes_received: c.bytes_received,
261                bytes_sent: c.bytes_sent,
262                active: c.status == ClientStatus::Connected,
263                can_message: false,
264            }),
265            history: client.connection_history.iter().cloned().collect(),
266            requests: client_requests
267                .remove(&client.id.as_u32())
268                .unwrap_or_default(),
269            task_count,
270            uptime_secs: client.created_at.elapsed().as_secs(),
271            send_state,
272            send_actions: crate::tui::modal::composer::ComposerModel::vocabulary(
273                &client.protocol_name,
274                state,
275            )
276            .into_iter()
277            .filter(|action| is_initiable_action(&action.name))
278            .map(|action| SendVerb {
279                name: action.name,
280                description: action.description,
281            })
282            .collect(),
283            intercepts: client_intercepts
284                .remove(&client.id.as_u32())
285                .unwrap_or_default(),
286        });
287    }
288    client_rows.sort_by_key(|c| c.id.as_u32());
289
290    RailSnapshot {
291        servers: server_rows,
292        clients: client_rows,
293        pipe_count,
294        active_conversations: llm_activity.len(),
295        llm_activity,
296        total_input_tokens,
297        total_output_tokens,
298        total_llm_calls,
299    }
300}