Skip to main content

netget/tui/
activity.rs

1//! The activity feed: what the machine is doing, as it happens.
2//!
3//! Two sources. The status channel's `[LEVEL]` lines arrive as log entries
4//! and are filtered by the log level at render time. Everything structural —
5//! an instance starting, a peer connecting, a request being answered, a
6//! question parked for the human — is derived by [`Tracker::diff`] from the
7//! difference between two successive snapshots, so it is exact rather than
8//! parsed out of log text, and each entry can carry a link back to the thing
9//! it describes (Enter opens it).
10
11use std::collections::{HashMap, HashSet, VecDeque};
12
13use crate::state::{ClientId, ServerId};
14use crate::tui::app::UiKey;
15use crate::tui::chat::ScrollPos;
16use crate::tui::metrics::{clock, clock_now, human_bytes};
17use crate::tui::projection::RailSnapshot;
18use crate::ui::app::LogLevel;
19
20pub const ACTIVITY_CAPACITY: usize = 5_000;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ActivityKind {
24    /// A `[LEVEL]` status line.
25    Log(LogLevel),
26    /// An instance started, changed status or stopped.
27    Lifecycle,
28    /// A peer connected or closed.
29    Peer,
30    /// A request arrived and was answered (or not).
31    Request,
32    /// A request is parked for the human.
33    Waiting,
34    /// A lifecycle event that went wrong (bind failed, connect refused).
35    Failure,
36    /// The conversation: what you typed, the model's reasoning and replies,
37    /// command output. Never filtered by log level; wraps rather than
38    /// truncates.
39    Chat(crate::tui::chat::EntryKind),
40}
41
42/// What an entry points at, for Enter.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Link {
45    Request(UiKey, u64),
46    Intercept(UiKey, u64),
47    Instance(UiKey),
48}
49
50/// An event before it is sequenced into the feed.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Activity {
53    pub kind: ActivityKind,
54    pub owner: Option<UiKey>,
55    /// `http#1`, `telnet#4`; empty for global lines.
56    pub tag: String,
57    pub text: String,
58    pub link: Option<Link>,
59    /// Unix ms the event happened at, when known; else "now".
60    pub at_unix_ms: Option<u64>,
61}
62
63#[derive(Debug, Clone)]
64pub struct ActivityEntry {
65    pub seq: u64,
66    pub time: String,
67    pub event: Activity,
68}
69
70pub struct ActivityFeed {
71    pub entries: VecDeque<ActivityEntry>,
72    pub scroll: ScrollPos,
73    /// Entries that arrived while scrolled away from the tail.
74    pub unseen: usize,
75    /// Stable entry sequence selected while the feed has focus.
76    pub cursor: Option<u64>,
77    /// Top entry and wrapped-line offset at the last scrolled paint.
78    pub(crate) scroll_anchor: Option<(u64, usize)>,
79    /// Show only the selected instance's entries (and global ones).
80    pub only_selected: bool,
81    next_seq: u64,
82}
83
84impl Default for ActivityFeed {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl ActivityFeed {
91    pub fn new() -> Self {
92        Self {
93            entries: VecDeque::new(),
94            scroll: ScrollPos::Follow,
95            unseen: 0,
96            cursor: None,
97            scroll_anchor: None,
98            only_selected: false,
99            next_seq: 1,
100        }
101    }
102
103    pub fn push(&mut self, event: Activity) {
104        // Everything in the stream ends up as cells on the operator's real terminal, and
105        // ratatui's `Paragraph` writes ESC, C1 and BEL through verbatim (only `set_string`
106        // filters controls). Peer text reaches here as `[LEVEL]` lines, error strings,
107        // the model's replies quoting a peer, and command output, so this is the one
108        // place to make a `\x1b]52;…\x07` clipboard write or a DSR query into plain text.
109        let mut event = event;
110        event.text = crate::utils::sanitize::multiline(&event.text);
111        event.tag = crate::utils::sanitize::line_field(&event.tag);
112        let time = event.at_unix_ms.map(clock).unwrap_or_else(clock_now);
113        self.entries.push_back(ActivityEntry {
114            seq: self.next_seq,
115            time,
116            event,
117        });
118        self.next_seq = self.next_seq.saturating_add(1);
119        if self.entries.len() > ACTIVITY_CAPACITY {
120            let removed = self.entries.pop_front();
121            if removed
122                .as_ref()
123                .is_some_and(|entry| Some(entry.seq) == self.cursor)
124            {
125                self.cursor = self.entries.front().map(|entry| entry.seq);
126            }
127        }
128        if self.scroll != ScrollPos::Follow {
129            self.unseen = self.unseen.saturating_add(1).min(ACTIVITY_CAPACITY);
130        }
131    }
132
133    pub fn push_log(&mut self, level: LogLevel, text: String) {
134        self.push(Activity {
135            kind: ActivityKind::Log(level),
136            owner: None,
137            tag: String::new(),
138            text,
139            link: None,
140            at_unix_ms: None,
141        });
142    }
143
144    /// Whether an entry is shown under the current log level and instance
145    /// filter. Structural entries always pass the level filter — they are the
146    /// point of the feed — and global log lines always pass the instance one.
147    pub fn passes(entry: &ActivityEntry, level: LogLevel, only: Option<UiKey>) -> bool {
148        if let ActivityKind::Log(entry_level) = entry.event.kind {
149            if entry_level > level {
150                return false;
151            }
152        }
153        match (only, entry.event.owner) {
154            (Some(wanted), Some(owner)) => wanted == owner,
155            _ => true,
156        }
157    }
158
159    pub fn scroll_to_follow(&mut self) {
160        self.scroll_anchor = None;
161        self.scroll = ScrollPos::Follow;
162        self.unseen = 0;
163        self.cursor = None;
164    }
165
166    pub fn scroll_up(&mut self, lines: usize) {
167        self.scroll_anchor = None;
168        self.scroll = match self.scroll {
169            ScrollPos::Follow => ScrollPos::Up(lines),
170            ScrollPos::Up(n) => ScrollPos::Up(n.saturating_add(lines)),
171        };
172    }
173
174    pub fn scroll_down(&mut self, lines: usize) {
175        self.scroll_anchor = None;
176        self.scroll = match self.scroll {
177            ScrollPos::Follow => ScrollPos::Follow,
178            ScrollPos::Up(n) => {
179                let n = n.saturating_sub(lines);
180                if n == 0 {
181                    self.unseen = 0;
182                    ScrollPos::Follow
183                } else {
184                    ScrollPos::Up(n)
185                }
186            }
187        };
188    }
189}
190
191#[derive(Debug, Clone)]
192struct ServerSeen {
193    status: String,
194    live: HashMap<u32, String>,
195}
196
197#[derive(Debug, Clone)]
198struct ClientSeen {
199    status: String,
200    connected: bool,
201}
202
203/// Remembers the last snapshot well enough to say what changed.
204#[derive(Debug, Default)]
205pub struct Tracker {
206    seeded: bool,
207    servers: HashMap<ServerId, ServerSeen>,
208    clients: HashMap<ClientId, ClientSeen>,
209    last_request: u64,
210    intercepts: HashSet<u64>,
211}
212
213fn server_tag(row: &crate::tui::projection::ServerRow) -> String {
214    format!("{}#{}", row.protocol.to_lowercase(), row.id.as_u32())
215}
216
217fn client_tag(row: &crate::tui::projection::ClientRow) -> String {
218    format!("{}#{}", row.protocol.to_lowercase(), row.id.as_u32())
219}
220
221fn server_addr(row: &crate::tui::projection::ServerRow) -> String {
222    row.local_addr
223        .clone()
224        .unwrap_or_else(|| format!(":{}", row.port))
225}
226
227impl Tracker {
228    /// Everything that changed between the previous snapshot and this one.
229    ///
230    /// The first call only seeds: instances present at startup (a `--load`)
231    /// are already in the list, and their backlog of requests is history, not
232    /// activity.
233    pub fn diff(&mut self, snapshot: &RailSnapshot) -> Vec<Activity> {
234        let mut out = Vec::new();
235        let emit = self.seeded;
236
237        // ---- servers ----
238        let mut seen_servers: HashSet<ServerId> = HashSet::new();
239        for row in &snapshot.servers {
240            seen_servers.insert(row.id);
241            let key = UiKey::Server(row.id);
242            let tag = server_tag(row);
243            let status = row.status.to_string();
244            let live: HashMap<u32, String> = row
245                .conns
246                .iter()
247                .filter(|c| c.active)
248                .map(|c| (c.id, c.remote_addr.clone()))
249                .collect();
250
251            match self.servers.get(&row.id) {
252                None => {
253                    if emit {
254                        let (kind, text) = lifecycle_text(&row.status, &server_addr(row));
255                        out.push(Activity {
256                            kind,
257                            owner: Some(key),
258                            tag: tag.clone(),
259                            text,
260                            link: Some(Link::Instance(key)),
261                            at_unix_ms: None,
262                        });
263                        // Peers it already has are new to the feed.
264                        for addr in live.values() {
265                            out.push(Activity {
266                                kind: ActivityKind::Peer,
267                                owner: Some(key),
268                                tag: tag.clone(),
269                                text: format!("⇐ {addr} connected"),
270                                link: Some(Link::Instance(key)),
271                                at_unix_ms: None,
272                            });
273                        }
274                    }
275                }
276                Some(previous) => {
277                    if previous.status != status {
278                        let (kind, text) = lifecycle_text(&row.status, &server_addr(row));
279                        out.push(Activity {
280                            kind,
281                            owner: Some(key),
282                            tag: tag.clone(),
283                            text,
284                            link: Some(Link::Instance(key)),
285                            at_unix_ms: None,
286                        });
287                    }
288                    for (id, addr) in &live {
289                        if !previous.live.contains_key(id) {
290                            out.push(Activity {
291                                kind: ActivityKind::Peer,
292                                owner: Some(key),
293                                tag: tag.clone(),
294                                text: format!("⇐ {addr} connected"),
295                                link: Some(Link::Instance(key)),
296                                at_unix_ms: None,
297                            });
298                        }
299                    }
300                    for (id, addr) in &previous.live {
301                        if !live.contains_key(id) {
302                            let (rx, tx) = row
303                                .conns
304                                .iter()
305                                .find(|c| c.id == *id)
306                                .map(|c| (c.bytes_received, c.bytes_sent))
307                                .or_else(|| {
308                                    row.recent
309                                        .iter()
310                                        .find(|c| c.id == *id)
311                                        .map(|c| (c.bytes_received, c.bytes_sent))
312                                })
313                                .unwrap_or((0, 0));
314                            out.push(Activity {
315                                kind: ActivityKind::Peer,
316                                owner: Some(key),
317                                tag: tag.clone(),
318                                text: format!(
319                                    "{addr} closed · ↓{} ↑{}",
320                                    human_bytes(rx),
321                                    human_bytes(tx)
322                                ),
323                                link: Some(Link::Instance(key)),
324                                at_unix_ms: None,
325                            });
326                        }
327                    }
328                }
329            }
330            self.servers.insert(row.id, ServerSeen { status, live });
331        }
332        let gone: Vec<ServerId> = self
333            .servers
334            .keys()
335            .filter(|id| !seen_servers.contains(id))
336            .copied()
337            .collect();
338        for id in gone {
339            self.servers.remove(&id);
340            if emit {
341                out.push(Activity {
342                    kind: ActivityKind::Lifecycle,
343                    owner: None,
344                    tag: format!("#{}", id.as_u32()),
345                    text: "server stopped and removed".to_string(),
346                    link: None,
347                    at_unix_ms: None,
348                });
349            }
350        }
351
352        // ---- clients ----
353        let mut seen_clients: HashSet<ClientId> = HashSet::new();
354        for row in &snapshot.clients {
355            seen_clients.insert(row.id);
356            let key = UiKey::Client(row.id);
357            let tag = client_tag(row);
358            let status = row.status.to_string();
359            let connected = row.status == crate::state::client::ClientStatus::Connected;
360            let changed = match self.clients.get(&row.id) {
361                None => emit,
362                Some(previous) => previous.status != status || previous.connected != connected,
363            };
364            if changed {
365                let (kind, text) = client_text(&row.status, &row.remote_addr);
366                out.push(Activity {
367                    kind,
368                    owner: Some(key),
369                    tag,
370                    text,
371                    link: Some(Link::Instance(key)),
372                    at_unix_ms: None,
373                });
374            }
375            self.clients
376                .insert(row.id, ClientSeen { status, connected });
377        }
378        let gone: Vec<ClientId> = self
379            .clients
380            .keys()
381            .filter(|id| !seen_clients.contains(id))
382            .copied()
383            .collect();
384        for id in gone {
385            self.clients.remove(&id);
386            if emit {
387                out.push(Activity {
388                    kind: ActivityKind::Lifecycle,
389                    owner: None,
390                    tag: format!("#{}", id.as_u32()),
391                    text: "client removed".to_string(),
392                    link: None,
393                    at_unix_ms: None,
394                });
395            }
396        }
397
398        // ---- requests: every access-log entry newer than the last seen ----
399        let mut fresh: Vec<(u64, Activity)> = Vec::new();
400        for row in &snapshot.servers {
401            let key = UiKey::Server(row.id);
402            let tag = server_tag(row);
403            for entry in &row.requests {
404                if entry.id > self.last_request {
405                    let peer = entry
406                        .connection_id
407                        .and_then(|c| row.conns.iter().find(|x| x.id == c))
408                        .map(|c| c.remote_addr.clone())
409                        .or_else(|| {
410                            entry
411                                .connection_id
412                                .and_then(|c| row.recent.iter().find(|x| x.id == c))
413                                .map(|c| c.remote_addr.clone())
414                        });
415                    fresh.push((entry.id, request_activity(key, &tag, peer, entry)));
416                }
417            }
418        }
419        for row in &snapshot.clients {
420            let key = UiKey::Client(row.id);
421            let tag = client_tag(row);
422            for entry in &row.requests {
423                if entry.id > self.last_request {
424                    fresh.push((entry.id, request_activity(key, &tag, None, entry)));
425                }
426            }
427        }
428        fresh.sort_by_key(|(id, _)| *id);
429        if let Some((max, _)) = fresh.last() {
430            self.last_request = *max;
431        }
432        if emit {
433            out.extend(fresh.into_iter().map(|(_, a)| a));
434        }
435
436        // ---- intercepts ----
437        let mut current: HashSet<u64> = HashSet::new();
438        let mut waiting: Vec<(u64, Activity)> = Vec::new();
439        for row in &snapshot.servers {
440            let key = UiKey::Server(row.id);
441            for view in &row.intercepts {
442                current.insert(view.id);
443                if !self.intercepts.contains(&view.id) {
444                    waiting.push((
445                        view.id,
446                        waiting_activity(key, &server_tag(row), view, {
447                            view.connection_id
448                                .and_then(|c| row.conns.iter().find(|x| x.id == c))
449                                .map(|c| c.remote_addr.clone())
450                        }),
451                    ));
452                }
453            }
454        }
455        for row in &snapshot.clients {
456            let key = UiKey::Client(row.id);
457            for view in &row.intercepts {
458                current.insert(view.id);
459                if !self.intercepts.contains(&view.id) {
460                    waiting.push((view.id, waiting_activity(key, &client_tag(row), view, None)));
461                }
462            }
463        }
464        waiting.sort_by_key(|(id, _)| *id);
465        self.intercepts = current;
466        if emit {
467            out.extend(waiting.into_iter().map(|(_, a)| a));
468        }
469
470        self.seeded = true;
471        out
472    }
473}
474
475/// `:53121` for `127.0.0.1:53121` — the port is what tells peers apart.
476fn port_of(addr: &str) -> String {
477    addr.rsplit_once(':')
478        .map(|(_, p)| format!(":{p}"))
479        .unwrap_or_else(|| addr.to_string())
480}
481
482fn lifecycle_text(
483    status: &crate::state::server::ServerStatus,
484    addr: &str,
485) -> (ActivityKind, String) {
486    use crate::state::server::ServerStatus;
487    match status {
488        ServerStatus::Starting => (ActivityKind::Lifecycle, format!("starting on {addr}")),
489        ServerStatus::Running => (ActivityKind::Lifecycle, format!("listening on {addr}")),
490        ServerStatus::Stopped => (ActivityKind::Lifecycle, "stopped".to_string()),
491        ServerStatus::Error(e) => (ActivityKind::Failure, e.clone()),
492    }
493}
494
495fn client_text(
496    status: &crate::state::client::ClientStatus,
497    remote: &str,
498) -> (ActivityKind, String) {
499    use crate::state::client::ClientStatus;
500    match status {
501        ClientStatus::Connecting => (ActivityKind::Lifecycle, format!("connecting to {remote}")),
502        ClientStatus::Connected => (ActivityKind::Lifecycle, format!("connected to {remote}")),
503        ClientStatus::Disconnected => (
504            ActivityKind::Lifecycle,
505            format!("disconnected from {remote}"),
506        ),
507        ClientStatus::Error(e) => (ActivityKind::Failure, e.clone()),
508    }
509}
510
511fn request_activity(
512    key: UiKey,
513    tag: &str,
514    peer: Option<String>,
515    entry: &crate::state::app_state::AccessLogEntry,
516) -> Activity {
517    let answer = crate::tui::modal::request_detail::answer_summary(entry);
518    let text = match peer.as_deref().map(port_of) {
519        Some(peer) => format!("{peer} {} → {answer}", entry.event_type),
520        None => format!("{} → {answer}", entry.event_type),
521    };
522    Activity {
523        kind: ActivityKind::Request,
524        owner: Some(key),
525        tag: tag.to_string(),
526        text,
527        link: Some(Link::Request(key, entry.id)),
528        at_unix_ms: Some(entry.unix_ms),
529    }
530}
531
532fn waiting_activity(
533    key: UiKey,
534    tag: &str,
535    view: &crate::state::intercepts::InterceptView,
536    peer: Option<String>,
537) -> Activity {
538    let text = match peer.as_deref().map(port_of) {
539        Some(peer) => format!("{} from {peer} needs YOUR answer", view.event_type),
540        None => format!("{} needs YOUR answer", view.event_type),
541    };
542    Activity {
543        kind: ActivityKind::Waiting,
544        owner: Some(key),
545        tag: tag.to_string(),
546        text,
547        link: Some(Link::Intercept(key, view.id)),
548        at_unix_ms: Some(view.created_unix_ms),
549    }
550}