1use 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 Log(LogLevel),
26 Lifecycle,
28 Peer,
30 Request,
32 Waiting,
34 Failure,
36 Chat(crate::tui::chat::EntryKind),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Link {
45 Request(UiKey, u64),
46 Intercept(UiKey, u64),
47 Instance(UiKey),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Activity {
53 pub kind: ActivityKind,
54 pub owner: Option<UiKey>,
55 pub tag: String,
57 pub text: String,
58 pub link: Option<Link>,
59 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 pub unseen: usize,
75 pub cursor: Option<u64>,
77 pub(crate) scroll_anchor: Option<(u64, usize)>,
79 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 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 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#[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 pub fn diff(&mut self, snapshot: &RailSnapshot) -> Vec<Activity> {
234 let mut out = Vec::new();
235 let emit = self.seeded;
236
237 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 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 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 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 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
475fn 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}