1use std::collections::HashMap;
5
6use crate::cli::input_state::InputState;
7use crate::state::{ClientId, ServerId};
8use crate::tui::activity::{Activity, ActivityFeed, ActivityKind, Tracker};
9use crate::tui::cards::{CardState, Row};
10use crate::tui::chat::EntryKind;
11use crate::tui::hit::HitRegistry;
12use crate::tui::metrics::Throughput;
13use crate::tui::modal::Modal;
14use crate::tui::projection::{ClientRow, RailSnapshot, ServerRow};
15use crate::tui::theme::Styles;
16use crate::ui::App;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum UiKey {
21 Server(ServerId),
22 Client(ClientId),
23}
24
25impl UiKey {
26 pub fn section(&self) -> Section {
27 match self {
28 UiKey::Server(_) => Section::Servers,
29 UiKey::Client(_) => Section::Clients,
30 }
31 }
32
33 pub fn describe(&self) -> String {
35 match self {
36 UiKey::Server(id) => format!("server #{}", id.as_u32()),
37 UiKey::Client(id) => format!("client #{}", id.as_u32()),
38 }
39 }
40
41 pub fn raw_id(&self) -> u32 {
42 match self {
43 UiKey::Server(id) => id.as_u32(),
44 UiKey::Client(id) => id.as_u32(),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum Section {
51 Servers,
52 Clients,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Focus {
64 Cards,
65 Stream,
66 ChatInput,
67}
68
69#[derive(Debug, Default)]
71pub struct CardsUi {
72 pub row: usize,
74 pub col: usize,
77 pub scroll: usize,
79 pub width: usize,
82 pub state: CardState,
83 pub metrics: HashMap<UiKey, Throughput>,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct StatusModel {
90 pub model: String,
91 pub backend: String,
92 pub web_search: String,
93 pub handler_mode: String,
94 pub scripting: String,
95 pub notice: Option<String>,
96 pub input_tokens: u64,
97 pub output_tokens: u64,
98 pub llm_calls: u64,
99 pub active_conversations: usize,
100}
101
102#[derive(Debug, Clone, Copy)]
104pub enum InstanceRef<'a> {
105 Server(&'a ServerRow),
106 Client(&'a ClientRow),
107}
108
109impl<'a> InstanceRef<'a> {
110 pub fn key(&self) -> UiKey {
111 match self {
112 InstanceRef::Server(s) => UiKey::Server(s.id),
113 InstanceRef::Client(c) => UiKey::Client(c.id),
114 }
115 }
116
117 pub fn protocol(&self) -> &str {
118 match self {
119 InstanceRef::Server(s) => &s.protocol,
120 InstanceRef::Client(c) => &c.protocol,
121 }
122 }
123
124 pub fn routing(&self) -> Option<&crate::scripting::EventHandlerConfig> {
125 match self {
126 InstanceRef::Server(s) => s.routing.as_ref(),
127 InstanceRef::Client(c) => c.routing.as_ref(),
128 }
129 }
130
131 pub fn intercepts(&self) -> &[crate::state::intercepts::InterceptView] {
132 match self {
133 InstanceRef::Server(s) => &s.intercepts,
134 InstanceRef::Client(c) => &c.intercepts,
135 }
136 }
137
138 pub fn requests(&self) -> &[crate::state::app_state::AccessLogEntry] {
139 match self {
140 InstanceRef::Server(s) => &s.requests,
141 InstanceRef::Client(c) => &c.requests,
142 }
143 }
144
145 pub fn tag(&self) -> String {
147 format!("{}#{}", self.protocol().to_lowercase(), self.key().raw_id())
148 }
149}
150
151pub struct DashboardApp {
152 pub core: App,
154 pub activity: ActivityFeed,
156 pub tracker: Tracker,
157 pub input: InputState,
158 pub focus: Focus,
159 pub cards: CardsUi,
160 pub snapshot: RailSnapshot,
161 pub modals: Vec<Modal>,
162 pub hits: HitRegistry,
163 pub status: StatusModel,
164 pub styles: Styles,
165 pub dirty: bool,
166 pub animation_tick: usize,
167 pub mouse_capture: bool,
168 pub should_quit: bool,
169 pub status_tx: tokio::sync::mpsc::UnboundedSender<String>,
172 pub ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
175 pub llm_client: crate::llm::OllamaClient,
177}
178
179impl DashboardApp {
180 pub fn new(
181 core: App,
182 styles: Styles,
183 status_tx: tokio::sync::mpsc::UnboundedSender<String>,
184 ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
185 llm_client: crate::llm::OllamaClient,
186 ) -> Self {
187 Self {
188 status_tx,
189 ui_tx,
190 llm_client,
191 core,
192 activity: ActivityFeed::new(),
193 tracker: Tracker::default(),
194 input: InputState::new(),
195 focus: Focus::ChatInput,
196 cards: CardsUi {
197 width: 60,
198 ..Default::default()
199 },
200 snapshot: RailSnapshot::default(),
201 modals: Vec::new(),
202 hits: HitRegistry::default(),
203 status: StatusModel::default(),
204 styles,
205 dirty: true,
206 animation_tick: 0,
207 mouse_capture: true,
208 should_quit: false,
209 }
210 }
211
212 pub fn modal(&self) -> Option<&Modal> {
213 self.modals.last()
214 }
215
216 pub fn modal_mut(&mut self) -> Option<&mut Modal> {
217 self.modals.last_mut()
218 }
219
220 pub fn rows(&self) -> Vec<Row> {
222 crate::tui::cards::rows(
223 &self.snapshot,
224 &self.cards.state,
225 &self.cards.metrics,
226 self.cards.width,
227 )
228 }
229
230 pub fn tick_llm_activity(&mut self, activity: Vec<crate::state::llm_activity::LlmActivity>) {
233 if self.snapshot.llm_activity != activity {
234 let cursor = self.rows().get(self.cards.row).cloned();
236 self.snapshot.active_conversations = activity.len();
237 self.snapshot.llm_activity = activity;
238 if let Some(cursor) = cursor {
239 let rows = self.rows();
240 if let Some(index) = rows.iter().position(|row| {
241 row.key == cursor.key
242 && row.on_enter == cursor.on_enter
243 && row.buttons == cursor.buttons
244 && row.positions() > 0
245 }) {
246 self.cards.row = index;
247 }
248 self.clamp_cursor_to(&rows);
249 }
250 self.dirty = true;
251 }
252 if !self.snapshot.llm_activity.is_empty() {
253 self.animation_tick = self.animation_tick.wrapping_add(1);
254 self.dirty = true;
255 }
256 }
257
258 pub fn spinner(&self) -> &'static str {
259 crate::tui::llm_activity::spinner(self.animation_tick)
260 }
261
262 pub fn absorb_snapshot(&mut self, snapshot: RailSnapshot) {
266 let cursor = if self.snapshot.llm_activity != snapshot.llm_activity {
267 self.rows().get(self.cards.row).cloned()
268 } else {
269 None
270 };
271 let events = self.tracker.diff(&snapshot);
272 for event in events {
273 self.activity.push(event);
274 }
275 let newest = self.newest_arrival(&snapshot);
276 self.snapshot = snapshot;
277 self.prune();
278 if let Some(key) = newest {
281 self.focus_card(key);
282 }
283 let rows = self.rows();
284 if newest.is_none() {
285 if let Some(cursor) = cursor {
286 if let Some(index) = rows.iter().position(|row| {
287 row.key == cursor.key
288 && row.on_enter == cursor.on_enter
289 && row.buttons == cursor.buttons
290 && row.positions() > 0
291 }) {
292 self.cards.row = index;
293 }
294 }
295 }
296 self.clamp_cursor_to(&rows);
297 self.dirty = true;
298 }
299
300 fn newest_arrival(&self, next: &RailSnapshot) -> Option<UiKey> {
302 let server = next
303 .servers
304 .iter()
305 .filter(|s| !self.snapshot.servers.iter().any(|old| old.id == s.id))
306 .map(|s| s.id)
307 .max_by_key(|id| id.as_u32())
308 .map(UiKey::Server);
309 let client = next
310 .clients
311 .iter()
312 .filter(|c| !self.snapshot.clients.iter().any(|old| old.id == c.id))
313 .map(|c| c.id)
314 .max_by_key(|id| id.as_u32())
315 .map(UiKey::Client);
316 client.or(server)
317 }
318
319 pub fn focus_card(&mut self, key: UiKey) {
321 let rows = self.rows();
322 if let Some(index) = crate::tui::cards::header_index(&rows, key) {
323 self.cards.row = index;
324 self.cards.col = 0;
325 }
326 }
327
328 pub fn sample_metrics(&mut self) {
331 for server in &self.snapshot.servers {
332 let (mut rx, mut tx) = (0u64, 0u64);
333 for c in &server.conns {
334 rx += c.bytes_received;
335 tx += c.bytes_sent;
336 }
337 for c in &server.recent {
338 rx += c.bytes_received;
339 tx += c.bytes_sent;
340 }
341 self.cards
342 .metrics
343 .entry(UiKey::Server(server.id))
344 .or_default()
345 .sample(rx, tx);
346 }
347 for client in &self.snapshot.clients {
348 let (rx, tx) = client
349 .connection
350 .as_ref()
351 .map(|c| (c.bytes_received, c.bytes_sent))
352 .unwrap_or((0, 0));
353 self.cards
354 .metrics
355 .entry(UiKey::Client(client.id))
356 .or_default()
357 .sample(rx, tx);
358 }
359 }
360
361 fn prune(&mut self) {
362 let live: std::collections::HashSet<UiKey> = self
363 .snapshot
364 .servers
365 .iter()
366 .map(|s| UiKey::Server(s.id))
367 .chain(self.snapshot.clients.iter().map(|c| UiKey::Client(c.id)))
368 .collect();
369 self.cards.metrics.retain(|key, _| live.contains(key));
370 self.cards.state.retain_snapshot(&self.snapshot);
371 }
372
373 pub fn clamp_cursor_to(&mut self, rows: &[Row]) {
379 if rows.is_empty() {
380 self.cards.row = 0;
381 self.cards.col = 0;
382 return;
383 }
384 let row = self.cards.row.min(rows.len() - 1);
385 let stop = (row..rows.len())
386 .find(|i| rows[*i].positions() > 0)
387 .or_else(|| (0..row).rev().find(|i| rows[*i].positions() > 0))
388 .unwrap_or(0);
389 self.cards.row = stop;
390 let positions = rows[stop].positions();
391 if positions == 0 {
392 self.cards.col = 0;
393 } else if self.cards.col >= positions {
394 self.cards.col = positions - 1;
395 }
396 }
397
398 pub fn cursor_key(&self) -> Option<UiKey> {
400 self.rows().get(self.cards.row).and_then(|r| r.key)
401 }
402
403 pub fn server_row(&self, id: ServerId) -> Option<&ServerRow> {
404 self.snapshot.servers.iter().find(|s| s.id == id)
405 }
406
407 pub fn client_row(&self, id: ClientId) -> Option<&ClientRow> {
408 self.snapshot.clients.iter().find(|c| c.id == id)
409 }
410
411 pub fn instance(&self, key: UiKey) -> Option<InstanceRef<'_>> {
412 match key {
413 UiKey::Server(id) => self.server_row(id).map(InstanceRef::Server),
414 UiKey::Client(id) => self.client_row(id).map(InstanceRef::Client),
415 }
416 }
417
418 pub fn waiting_count(&self) -> usize {
420 self.snapshot
421 .servers
422 .iter()
423 .map(|s| s.intercepts.len())
424 .sum::<usize>()
425 + self
426 .snapshot
427 .clients
428 .iter()
429 .map(|c| c.intercepts.len())
430 .sum::<usize>()
431 }
432
433 pub fn push_chat(&mut self, kind: EntryKind, text: impl Into<String>) {
435 let kind = match kind {
436 EntryKind::Log(level) => ActivityKind::Log(level),
437 other => ActivityKind::Chat(other),
438 };
439 self.activity.push(Activity {
440 kind,
441 owner: None,
442 tag: String::new(),
443 text: text.into(),
444 link: None,
445 at_unix_ms: None,
446 });
447 self.dirty = true;
448 }
449
450 pub fn push_system(&mut self, text: impl Into<String>) {
451 self.push_chat(EntryKind::System, text);
452 }
453
454 pub fn push_error(&mut self, text: impl Into<String>) {
455 self.push_chat(EntryKind::Log(crate::ui::app::LogLevel::Error), text);
456 }
457}