netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Dashboard application state: where the cursor is, what the last poll saw,
//! and which modals are open.

use std::collections::HashMap;

use crate::cli::input_state::InputState;
use crate::state::{ClientId, ServerId};
use crate::tui::activity::{Activity, ActivityFeed, ActivityKind, Tracker};
use crate::tui::cards::{CardState, Row};
use crate::tui::chat::EntryKind;
use crate::tui::hit::HitRegistry;
use crate::tui::metrics::Throughput;
use crate::tui::modal::Modal;
use crate::tui::projection::{ClientRow, RailSnapshot, ServerRow};
use crate::tui::theme::Styles;
use crate::ui::App;

/// Stable identity of an instance across re-polls.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UiKey {
    Server(ServerId),
    Client(ClientId),
}

impl UiKey {
    pub fn section(&self) -> Section {
        match self {
            UiKey::Server(_) => Section::Servers,
            UiKey::Client(_) => Section::Clients,
        }
    }

    /// `server #1` / `client #4`, for stream lines and titles.
    pub fn describe(&self) -> String {
        match self {
            UiKey::Server(id) => format!("server #{}", id.as_u32()),
            UiKey::Client(id) => format!("client #{}", id.as_u32()),
        }
    }

    pub fn raw_id(&self) -> u32 {
        match self {
            UiKey::Server(id) => id.as_u32(),
            UiKey::Client(id) => id.as_u32(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Section {
    Servers,
    Clients,
}

/// Where keyboard input goes when no modal is open.
///
/// Two columns. `Cards` is the whole management column — every server and
/// client, their buttons and sections — walked with the arrows. `ChatInput`
/// is the box at the bottom of the stream; `Stream` is that column scrolled
/// away from its tail (PageUp, the wheel), where the arrows move through
/// the lines. Tab hops between the columns.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
    Cards,
    Stream,
    ChatInput,
}

/// The management column's cursor and fold state.
#[derive(Debug, Default)]
pub struct CardsUi {
    /// The row the cursor is on (index into `cards::rows`).
    pub row: usize,
    /// The position on that row: the label (0, where the label acts) then
    /// each button.
    pub col: usize,
    /// First visible row.
    pub scroll: usize,
    /// Inner width at the last paint, so the keymap builds the same rows
    /// the renderer did (button grids depend on it).
    pub width: usize,
    pub state: CardState,
    /// Throughput history per instance, sampled once a second.
    pub metrics: HashMap<UiKey, Throughput>,
}

/// Status-bar model.
#[derive(Debug, Clone, Default)]
pub struct StatusModel {
    pub model: String,
    pub backend: String,
    pub web_search: String,
    pub handler_mode: String,
    pub scripting: String,
    pub notice: Option<String>,
    pub input_tokens: u64,
    pub output_tokens: u64,
    pub llm_calls: u64,
    pub active_conversations: usize,
}

/// A borrowed view of either kind of instance row.
#[derive(Debug, Clone, Copy)]
pub enum InstanceRef<'a> {
    Server(&'a ServerRow),
    Client(&'a ClientRow),
}

impl<'a> InstanceRef<'a> {
    pub fn key(&self) -> UiKey {
        match self {
            InstanceRef::Server(s) => UiKey::Server(s.id),
            InstanceRef::Client(c) => UiKey::Client(c.id),
        }
    }

    pub fn protocol(&self) -> &str {
        match self {
            InstanceRef::Server(s) => &s.protocol,
            InstanceRef::Client(c) => &c.protocol,
        }
    }

    pub fn routing(&self) -> Option<&crate::scripting::EventHandlerConfig> {
        match self {
            InstanceRef::Server(s) => s.routing.as_ref(),
            InstanceRef::Client(c) => c.routing.as_ref(),
        }
    }

    pub fn intercepts(&self) -> &[crate::state::intercepts::InterceptView] {
        match self {
            InstanceRef::Server(s) => &s.intercepts,
            InstanceRef::Client(c) => &c.intercepts,
        }
    }

    pub fn requests(&self) -> &[crate::state::app_state::AccessLogEntry] {
        match self {
            InstanceRef::Server(s) => &s.requests,
            InstanceRef::Client(c) => &c.requests,
        }
    }

    /// `http#1`-style tag used by the stream.
    pub fn tag(&self) -> String {
        format!("{}#{}", self.protocol().to_lowercase(), self.key().raw_id())
    }
}

pub struct DashboardApp {
    /// Legacy display state reused for command history, log level and caps.
    pub core: App,
    /// The one timeline: machine events and the conversation.
    pub activity: ActivityFeed,
    pub tracker: Tracker,
    pub input: InputState,
    pub focus: Focus,
    pub cards: CardsUi,
    pub snapshot: RailSnapshot,
    pub modals: Vec<Modal>,
    pub hits: HitRegistry,
    pub status: StatusModel,
    pub styles: Styles,
    pub dirty: bool,
    pub animation_tick: usize,
    pub mouse_capture: bool,
    pub should_quit: bool,
    /// Clone of the status channel, so modal actions (create/update/send) can
    /// stream their progress into the same pane.
    pub status_tx: tokio::sync::mpsc::UnboundedSender<String>,
    /// Results of spawned actions (see `crate::tui::uimsg`). Network work must
    /// never be awaited on the event loop.
    pub ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
    /// The configured LLM client, needed when creating/updating clients.
    pub llm_client: crate::llm::OllamaClient,
}

impl DashboardApp {
    pub fn new(
        core: App,
        styles: Styles,
        status_tx: tokio::sync::mpsc::UnboundedSender<String>,
        ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
        llm_client: crate::llm::OllamaClient,
    ) -> Self {
        Self {
            status_tx,
            ui_tx,
            llm_client,
            core,
            activity: ActivityFeed::new(),
            tracker: Tracker::default(),
            input: InputState::new(),
            focus: Focus::ChatInput,
            cards: CardsUi {
                width: 60,
                ..Default::default()
            },
            snapshot: RailSnapshot::default(),
            modals: Vec::new(),
            hits: HitRegistry::default(),
            status: StatusModel::default(),
            styles,
            dirty: true,
            animation_tick: 0,
            mouse_capture: true,
            should_quit: false,
        }
    }

    pub fn modal(&self) -> Option<&Modal> {
        self.modals.last()
    }

    pub fn modal_mut(&mut self) -> Option<&mut Modal> {
        self.modals.last_mut()
    }

    /// The management column's rows as the renderer last laid them out.
    pub fn rows(&self) -> Vec<Row> {
        crate::tui::cards::rows(
            &self.snapshot,
            &self.cards.state,
            &self.cards.metrics,
            self.cards.width,
        )
    }

    /// Called on the 100ms UI tick. Only the small live-work registry is polled;
    /// the full state projection and traffic samples keep their slower cadence.
    pub fn tick_llm_activity(&mut self, activity: Vec<crate::state::llm_activity::LlmActivity>) {
        if self.snapshot.llm_activity != activity {
            // Transient status rows must not move the operator onto a different button.
            let cursor = self.rows().get(self.cards.row).cloned();
            self.snapshot.active_conversations = activity.len();
            self.snapshot.llm_activity = activity;
            if let Some(cursor) = cursor {
                let rows = self.rows();
                if let Some(index) = rows.iter().position(|row| {
                    row.key == cursor.key
                        && row.on_enter == cursor.on_enter
                        && row.buttons == cursor.buttons
                        && row.positions() > 0
                }) {
                    self.cards.row = index;
                }
                self.clamp_cursor_to(&rows);
            }
            self.dirty = true;
        }
        if !self.snapshot.llm_activity.is_empty() {
            self.animation_tick = self.animation_tick.wrapping_add(1);
            self.dirty = true;
        }
    }

    pub fn spinner(&self) -> &'static str {
        crate::tui::llm_activity::spinner(self.animation_tick)
    }

    /// Take a freshly built snapshot: derive activity from the change, keep
    /// the cursor on a row that exists, drop per-instance state for
    /// instances that are gone.
    pub fn absorb_snapshot(&mut self, snapshot: RailSnapshot) {
        let cursor = if self.snapshot.llm_activity != snapshot.llm_activity {
            self.rows().get(self.cards.row).cloned()
        } else {
            None
        };
        let events = self.tracker.diff(&snapshot);
        for event in events {
            self.activity.push(event);
        }
        let newest = self.newest_arrival(&snapshot);
        self.snapshot = snapshot;
        self.prune();
        // An instance that just appeared gets the cursor: the thing you just
        // made is what you want to look at, and its buttons are one ↓ away.
        if let Some(key) = newest {
            self.focus_card(key);
        }
        let rows = self.rows();
        if newest.is_none() {
            if let Some(cursor) = cursor {
                if let Some(index) = rows.iter().position(|row| {
                    row.key == cursor.key
                        && row.on_enter == cursor.on_enter
                        && row.buttons == cursor.buttons
                        && row.positions() > 0
                }) {
                    self.cards.row = index;
                }
            }
        }
        self.clamp_cursor_to(&rows);
        self.dirty = true;
    }

    /// The highest-id instance in `next` that the current snapshot lacks.
    fn newest_arrival(&self, next: &RailSnapshot) -> Option<UiKey> {
        let server = next
            .servers
            .iter()
            .filter(|s| !self.snapshot.servers.iter().any(|old| old.id == s.id))
            .map(|s| s.id)
            .max_by_key(|id| id.as_u32())
            .map(UiKey::Server);
        let client = next
            .clients
            .iter()
            .filter(|c| !self.snapshot.clients.iter().any(|old| old.id == c.id))
            .map(|c| c.id)
            .max_by_key(|id| id.as_u32())
            .map(UiKey::Client);
        client.or(server)
    }

    /// Put the cursor on `key`'s header row.
    pub fn focus_card(&mut self, key: UiKey) {
        let rows = self.rows();
        if let Some(index) = crate::tui::cards::header_index(&rows, key) {
            self.cards.row = index;
            self.cards.col = 0;
        }
    }

    /// Record one throughput sample per instance. Called on the 1s stats
    /// tick, so a sample is bytes per second.
    pub fn sample_metrics(&mut self) {
        for server in &self.snapshot.servers {
            let (mut rx, mut tx) = (0u64, 0u64);
            for c in &server.conns {
                rx += c.bytes_received;
                tx += c.bytes_sent;
            }
            for c in &server.recent {
                rx += c.bytes_received;
                tx += c.bytes_sent;
            }
            self.cards
                .metrics
                .entry(UiKey::Server(server.id))
                .or_default()
                .sample(rx, tx);
        }
        for client in &self.snapshot.clients {
            let (rx, tx) = client
                .connection
                .as_ref()
                .map(|c| (c.bytes_received, c.bytes_sent))
                .unwrap_or((0, 0));
            self.cards
                .metrics
                .entry(UiKey::Client(client.id))
                .or_default()
                .sample(rx, tx);
        }
    }

    fn prune(&mut self) {
        let live: std::collections::HashSet<UiKey> = self
            .snapshot
            .servers
            .iter()
            .map(|s| UiKey::Server(s.id))
            .chain(self.snapshot.clients.iter().map(|c| UiKey::Client(c.id)))
            .collect();
        self.cards.metrics.retain(|key, _| live.contains(key));
        self.cards.state.retain_snapshot(&self.snapshot);
    }

    /// Keep the cursor on a row that can take it.
    ///
    /// Rows come and go under the cursor — a peer closes, a card folds, a
    /// server stops — so the index is re-checked against the rows as they
    /// are now: forward to the next stop, else back to the previous one.
    pub fn clamp_cursor_to(&mut self, rows: &[Row]) {
        if rows.is_empty() {
            self.cards.row = 0;
            self.cards.col = 0;
            return;
        }
        let row = self.cards.row.min(rows.len() - 1);
        let stop = (row..rows.len())
            .find(|i| rows[*i].positions() > 0)
            .or_else(|| (0..row).rev().find(|i| rows[*i].positions() > 0))
            .unwrap_or(0);
        self.cards.row = stop;
        let positions = rows[stop].positions();
        if positions == 0 {
            self.cards.col = 0;
        } else if self.cards.col >= positions {
            self.cards.col = positions - 1;
        }
    }

    /// The instance whose row the cursor is on, if any.
    pub fn cursor_key(&self) -> Option<UiKey> {
        self.rows().get(self.cards.row).and_then(|r| r.key)
    }

    pub fn server_row(&self, id: ServerId) -> Option<&ServerRow> {
        self.snapshot.servers.iter().find(|s| s.id == id)
    }

    pub fn client_row(&self, id: ClientId) -> Option<&ClientRow> {
        self.snapshot.clients.iter().find(|c| c.id == id)
    }

    pub fn instance(&self, key: UiKey) -> Option<InstanceRef<'_>> {
        match key {
            UiKey::Server(id) => self.server_row(id).map(InstanceRef::Server),
            UiKey::Client(id) => self.client_row(id).map(InstanceRef::Client),
        }
    }

    /// Pending intercepts across every instance.
    pub fn waiting_count(&self) -> usize {
        self.snapshot
            .servers
            .iter()
            .map(|s| s.intercepts.len())
            .sum::<usize>()
            + self
                .snapshot
                .clients
                .iter()
                .map(|c| c.intercepts.len())
                .sum::<usize>()
    }

    /// A conversation entry into the stream.
    pub fn push_chat(&mut self, kind: EntryKind, text: impl Into<String>) {
        let kind = match kind {
            EntryKind::Log(level) => ActivityKind::Log(level),
            other => ActivityKind::Chat(other),
        };
        self.activity.push(Activity {
            kind,
            owner: None,
            tag: String::new(),
            text: text.into(),
            link: None,
            at_unix_ms: None,
        });
        self.dirty = true;
    }

    pub fn push_system(&mut self, text: impl Into<String>) {
        self.push_chat(EntryKind::System, text);
    }

    pub fn push_error(&mut self, text: impl Into<String>) {
        self.push_chat(EntryKind::Log(crate::ui::app::LogLevel::Error), text);
    }
}