4runr-os 2.10.39 → 2.10.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/apps/gateway/dist/apps/gateway/src/index.js +14 -4
  2. package/apps/gateway/dist/apps/gateway/src/index.js.map +1 -1
  3. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts +18 -0
  4. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts.map +1 -0
  5. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js +117 -0
  6. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js.map +1 -0
  7. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts +2 -0
  8. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts.map +1 -0
  9. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js +54 -0
  10. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js.map +1 -0
  11. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts +15 -0
  12. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts.map +1 -0
  13. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js +164 -0
  14. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js.map +1 -0
  15. package/apps/gateway/package-lock.json +204 -353
  16. package/apps/gateway/src/index.ts +27 -8
  17. package/apps/gateway/src/metrics/monitoring-detail.ts +162 -0
  18. package/apps/gateway/src/middleware/log-capture.ts +70 -0
  19. package/apps/gateway/src/routes/monitoring.ts +298 -0
  20. package/dist/gateway-client.d.ts +2 -0
  21. package/dist/gateway-client.d.ts.map +1 -1
  22. package/dist/gateway-client.js +22 -0
  23. package/dist/gateway-client.js.map +1 -1
  24. package/dist/tui-handlers.js +498 -0
  25. package/dist/tui-handlers.js.map +1 -1
  26. package/mk3-tui/src/app.rs +569 -8
  27. package/mk3-tui/src/main.rs +248 -0
  28. package/mk3-tui/src/monitoring/mod.rs +428 -0
  29. package/mk3-tui/src/ui/portal_monitoring.rs +1018 -146
  30. package/package.json +2 -2
@@ -1,10 +1,23 @@
1
- //! Portal Monitoring — live Gateway snapshot from Prometheus `GET /metrics` (via CLI WebSocket).
1
+ //! Portal Monitoring — Phase 1: section-based layout from the CLI observability snapshot
2
+ //! (same data as before: `gateway.observability` → healthLines + Prometheus stats).
3
+ //!
4
+ //! ## Snapshot layout contract (fragile)
5
+ //! `parse_snapshot` splits on **substring markers** that must stay aligned across:
6
+ //! - `packages/os-cli/src/tui-handlers.ts` (`observabilityHealthLines`, `summarizeGatewayPrometheusMetrics` output)
7
+ //! - `main.rs` (where `content_lines` are assembled for `PortalMonitoringState`)
8
+ //!
9
+ //! If any layer renames or drops these strings, `unwrap_or(lines.len())` paths can put most of the
10
+ //! text into `overview_prefix` and leave `health` / `dependencies` empty. When changing those call
11
+ //! sites, run `cargo test -p mk3-tui portal_snapshot` and update the fixture in this module.
12
+ //!
13
+ //! Marker constants in this file (`SNAPSHOT_MARKER_*`) are the single source of truth for those strings.
2
14
 
3
15
  use ratatui::layout::{Alignment, Constraint, Direction, Layout};
4
16
  use ratatui::prelude::*;
5
17
  use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
6
18
 
7
19
  use crate::app::AppState;
20
+ use crate::monitoring::{MetricsDrillPanel, MonitoringSection, SectionStatus};
8
21
 
9
22
  const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
23
  const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
@@ -15,6 +28,17 @@ const TEXT_WARN: Color = Color::Rgb(255, 180, 80);
15
28
  const TEXT_HEADER: Color = Color::Rgb(0, 200, 255);
16
29
  const BG_PANEL: Color = Color::Rgb(18, 18, 25);
17
30
  const SEPARATOR_COLOR: Color = Color::Rgb(60, 60, 80);
31
+ const SEL_BG: Color = Color::Rgb(35, 40, 55);
32
+
33
+ // Substrings searched with `contains()` — keep in sync with tui-handlers + main.rs assembly.
34
+ /// First line of `observabilityHealthLines` in `tui-handlers.ts` must still include this.
35
+ pub(crate) const SNAPSHOT_MARKER_LIVE: &str = "Live link check";
36
+ /// `Dependency checks (from /ready):` block header from CLI.
37
+ pub(crate) const SNAPSHOT_MARKER_DEPS: &str = "Dependency checks (from /ready):";
38
+ /// Title line before Prometheus stats block (see `main.rs` portal observability handler).
39
+ pub(crate) const SNAPSHOT_MARKER_PROM: &str = "Prometheus /metrics";
40
+ /// Lines after stats; route table header from `main.rs`.
41
+ pub(crate) const SNAPSHOT_MARKER_TOP_ROUTES: &str = "Top HTTP routes";
18
42
 
19
43
  fn gateway_display_url(state: &AppState) -> String {
20
44
  state
@@ -25,12 +49,765 @@ fn gateway_display_url(state: &AppState) -> String {
25
49
  .to_string()
26
50
  }
27
51
 
52
+ /// Partition `content_lines` from `gateway.observability` into sections.
53
+ #[derive(Debug, Clone, Default)]
54
+ struct ParsedSnapshot {
55
+ overview_prefix: Vec<String>,
56
+ health: Vec<String>,
57
+ dependencies: Vec<String>,
58
+ metrics: Vec<String>,
59
+ queue: Vec<String>,
60
+ routes: Vec<String>,
61
+ }
62
+
63
+ fn trim_strings(lines: &[String]) -> Vec<String> {
64
+ lines
65
+ .iter()
66
+ .map(|s| s.trim_end().to_string())
67
+ .collect()
68
+ }
69
+
70
+ fn parse_snapshot(lines: &[String]) -> ParsedSnapshot {
71
+ let mut out = ParsedSnapshot::default();
72
+ if lines.is_empty() {
73
+ return out;
74
+ }
75
+
76
+ let live_idx = lines
77
+ .iter()
78
+ .position(|l| l.contains(SNAPSHOT_MARKER_LIVE));
79
+ let deps_idx = lines
80
+ .iter()
81
+ .position(|l| l.contains(SNAPSHOT_MARKER_DEPS));
82
+ let prom_idx = lines
83
+ .iter()
84
+ .position(|l| l.contains(SNAPSHOT_MARKER_PROM));
85
+ let top_idx = lines
86
+ .iter()
87
+ .position(|l| l.contains(SNAPSHOT_MARKER_TOP_ROUTES));
88
+
89
+ let live = live_idx.unwrap_or(lines.len());
90
+ out.overview_prefix = trim_strings(&lines[..live]);
91
+
92
+ let after_live = if live < lines.len() { live } else { lines.len() };
93
+ let deps_start = deps_idx.unwrap_or(lines.len());
94
+ let prom_start = prom_idx.unwrap_or(lines.len());
95
+
96
+ if deps_start < lines.len() && deps_start > after_live {
97
+ out.health = trim_strings(&lines[after_live..deps_start]);
98
+ } else if prom_start > after_live && deps_idx.is_none() {
99
+ // No dependency block — treat remainder before Prometheus as health only
100
+ let end_h = prom_start.min(lines.len());
101
+ out.health = trim_strings(&lines[after_live..end_h]);
102
+ }
103
+
104
+ if deps_start < lines.len() && prom_start > deps_start {
105
+ out.dependencies = trim_strings(&lines[deps_start..prom_start]);
106
+ }
107
+
108
+ let routes_start = top_idx.unwrap_or(lines.len());
109
+ if prom_start < lines.len() && routes_start >= prom_start {
110
+ // Skip Prometheus title line(s) and blank → stats
111
+ let mut i = prom_start;
112
+ while i < lines.len() && i < routes_start {
113
+ let l = lines[i].trim();
114
+ if l.is_empty() {
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if l.contains(SNAPSHOT_MARKER_PROM) {
119
+ i += 1;
120
+ continue;
121
+ }
122
+ break;
123
+ }
124
+ let stats_end = routes_start.min(lines.len());
125
+ if i < stats_end {
126
+ let stats_block = trim_strings(&lines[i..stats_end]);
127
+ for row in stats_block {
128
+ if row.contains("Queue jobs") {
129
+ out.queue.push(row);
130
+ } else if !row.is_empty() {
131
+ out.metrics.push(row);
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ if top_idx.is_some() && routes_start < lines.len() {
138
+ // Include header line "Top HTTP routes..." and column header + rows
139
+ out.routes = trim_strings(&lines[routes_start..]);
140
+ }
141
+
142
+ out
143
+ }
144
+
145
+ fn status_from_text(block: &[String]) -> SectionStatus {
146
+ let joined = block.join("\n");
147
+ let not_ready_bad =
148
+ joined.contains("not ready") && !joined.contains("✓ /ready: all critical dependencies OK");
149
+ if joined.contains("✗")
150
+ || joined.contains(": down")
151
+ || joined.contains("Verify failed")
152
+ || not_ready_bad
153
+ {
154
+ return SectionStatus::Unhealthy;
155
+ }
156
+ if joined.contains('⚠') || joined.contains("degraded") || joined.contains("not OK") {
157
+ return SectionStatus::Degraded;
158
+ }
159
+ if joined.contains('✓') || joined.contains(": up") {
160
+ return SectionStatus::Healthy;
161
+ }
162
+ SectionStatus::Unknown
163
+ }
164
+
165
+ fn overall_status(parsed: &ParsedSnapshot) -> SectionStatus {
166
+ let mut s = status_from_text(&parsed.health);
167
+ let ds = status_from_text(&parsed.dependencies);
168
+ if matches!(ds, SectionStatus::Unhealthy) {
169
+ s = SectionStatus::Unhealthy;
170
+ } else if matches!(s, SectionStatus::Healthy) && matches!(ds, SectionStatus::Degraded) {
171
+ s = SectionStatus::Degraded;
172
+ } else if matches!(s, SectionStatus::Healthy) && matches!(ds, SectionStatus::Unhealthy) {
173
+ s = SectionStatus::Unhealthy;
174
+ }
175
+ s
176
+ }
177
+
178
+ fn summarize_overview(parsed: &ParsedSnapshot, url: &str) -> String {
179
+ let st = overall_status(parsed);
180
+ let tag = match st {
181
+ SectionStatus::Healthy => "healthy",
182
+ SectionStatus::Degraded => "degraded",
183
+ SectionStatus::Unhealthy => "unhealthy",
184
+ _ => "unknown",
185
+ };
186
+ format!("{} · {}", url, tag)
187
+ }
188
+
189
+ fn summarize_health(health: &[String]) -> String {
190
+ for row in health {
191
+ if row.contains("Uptime:") {
192
+ return row.trim().to_string();
193
+ }
194
+ if row.contains("/health OK") {
195
+ return row.trim().chars().take(64).collect();
196
+ }
197
+ }
198
+ if health.is_empty() {
199
+ "—".into()
200
+ } else {
201
+ health[0].trim().chars().take(56).collect()
202
+ }
203
+ }
204
+
205
+ fn summarize_dependencies(deps: &[String]) -> String {
206
+ let mut down = 0;
207
+ let mut warn = 0;
208
+ for row in deps {
209
+ if row.contains(": down") || row.contains('✗') {
210
+ down += 1;
211
+ }
212
+ if row.contains('⚠') || row.contains("degraded") {
213
+ warn += 1;
214
+ }
215
+ }
216
+ if down > 0 {
217
+ format!("{} dependency down", down)
218
+ } else if warn > 0 {
219
+ format!("{} warning(s)", warn)
220
+ } else if deps.is_empty() {
221
+ "—".into()
222
+ } else {
223
+ "all checks OK".into()
224
+ }
225
+ }
226
+
227
+ fn summarize_metrics(metrics: &[String]) -> String {
228
+ let mut req = None;
229
+ let mut err = None;
230
+ for row in metrics {
231
+ let t = row.trim();
232
+ if t.starts_with("HTTP requests total") {
233
+ if let Some(n) = t.split_whitespace().last() {
234
+ req = Some(n.to_string());
235
+ }
236
+ }
237
+ if t.starts_with("HTTP request errors") {
238
+ if let Some(rest) = t.strip_prefix("HTTP request errors") {
239
+ let parts: Vec<&str> = rest.split_whitespace().collect();
240
+ if let Some(first) = parts.first() {
241
+ err = Some(first.to_string());
242
+ }
243
+ }
244
+ }
245
+ }
246
+ match (req, err) {
247
+ (Some(r), Some(e)) => format!("→ {} req, {} err", r, e),
248
+ (Some(r), None) => format!("→ {} req", r),
249
+ _ => "—".into(),
250
+ }
251
+ }
252
+
253
+ fn summarize_queue(queue: &[String]) -> String {
254
+ if queue.is_empty() {
255
+ "—".into()
256
+ } else {
257
+ queue
258
+ .iter()
259
+ .map(|s| s.trim().chars().take(48).collect::<String>())
260
+ .collect::<Vec<_>>()
261
+ .join(" · ")
262
+ }
263
+ }
264
+
265
+ /// First unsigned integer token after stripping a line prefix (handles `packages/os-cli` stats formatting).
266
+ fn first_u64_after_line_prefix(line: &str, prefix: &str) -> Option<u64> {
267
+ let rest = line.trim().strip_prefix(prefix)?.trim();
268
+ let tok = rest.split_whitespace().next()?;
269
+ tok.replace(",", "").parse().ok()
270
+ }
271
+
272
+ /// Metrics row tint: non-zero **HTTP request errors** total ⇒ degraded (Prometheus text from CLI).
273
+ fn metrics_section_status(metrics: &[String]) -> SectionStatus {
274
+ for row in metrics {
275
+ let t = row.trim();
276
+ if t.starts_with("HTTP request errors") {
277
+ return match first_u64_after_line_prefix(t, "HTTP request errors") {
278
+ Some(n) if n > 0 => SectionStatus::Degraded,
279
+ _ => SectionStatus::Healthy,
280
+ };
281
+ }
282
+ }
283
+ // No matching row (unusual) — avoid implying “all green”.
284
+ SectionStatus::Unknown
285
+ }
286
+
287
+ /// Queue stats tint: non-zero **Queue jobs failed** gauge ⇒ degraded (`gateway-observability.ts` labels).
288
+ fn queue_section_status(queue: &[String]) -> SectionStatus {
289
+ for row in queue {
290
+ let t = row.trim();
291
+ if t.starts_with("Queue jobs failed") {
292
+ return match first_u64_after_line_prefix(t, "Queue jobs failed") {
293
+ Some(n) if n > 0 => SectionStatus::Degraded,
294
+ _ => SectionStatus::Healthy,
295
+ };
296
+ }
297
+ }
298
+ SectionStatus::Healthy
299
+ }
300
+
301
+ /// Log lines from CLI use `timestamp [level] message`; derive status from worst `[level]` seen.
302
+ fn logs_section_status(lines: &[String]) -> SectionStatus {
303
+ if lines.is_empty() {
304
+ return SectionStatus::Unknown;
305
+ }
306
+ let mut worst: u8 = 0; // 0 ok, 1 warn, 2 error
307
+ for row in lines {
308
+ let lower = row.to_ascii_lowercase();
309
+ if lower.contains("[fatal]")
310
+ || lower.contains("[error]")
311
+ || lower.contains("[critical]")
312
+ {
313
+ worst = worst.max(2);
314
+ } else if lower.contains("[warn]") || lower.contains("[warning]") {
315
+ worst = worst.max(1);
316
+ }
317
+ }
318
+ match worst {
319
+ 2 => SectionStatus::Unhealthy,
320
+ 1 => SectionStatus::Degraded,
321
+ _ => SectionStatus::Healthy,
322
+ }
323
+ }
324
+
325
+ /// Phase 3: tiny ASCII sparkline from monotonic `http_requests_total` samples (full snapshot polling).
326
+ fn ascii_sparkline(samples: &[u64]) -> String {
327
+ if samples.len() < 2 {
328
+ return String::new();
329
+ }
330
+ const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
331
+ let mn = *samples.iter().min().unwrap();
332
+ let mx = *samples.iter().max().unwrap();
333
+ let span = (mx - mn).max(1);
334
+ samples
335
+ .iter()
336
+ .map(|&v| {
337
+ let i = (((v - mn) as f64 / span as f64) * 7.0).round() as usize;
338
+ BLOCKS[i.min(7)]
339
+ })
340
+ .collect()
341
+ }
342
+
343
+ fn tui_runtime_lines(state: &AppState) -> Vec<String> {
344
+ let count = state.render_durations.len() as u64;
345
+ let avg_render = if count > 0 {
346
+ state.render_durations.iter().copied().sum::<u64>() as f64 / count as f64
347
+ } else {
348
+ 0.0
349
+ };
350
+ vec![
351
+ String::new(),
352
+ "MK3 TUI runtime".into(),
353
+ format!(
354
+ " uptime={}s tick={} renders={} scheduled={} logs={}",
355
+ state.uptime_secs,
356
+ state.tick,
357
+ state.render_count,
358
+ state.render_scheduled_count,
359
+ state.log_write_count
360
+ ),
361
+ format!(
362
+ " last_render={}ms avg_render={:.1}ms viewport_lines={}",
363
+ state.last_render_time.elapsed().as_millis(),
364
+ avg_render,
365
+ state.portal_monitoring.viewport_lines
366
+ ),
367
+ format!(
368
+ " screen_scroll={} auto_refresh={}",
369
+ state.portal_monitoring.scroll_offset,
370
+ if state.portal_monitoring.auto_refresh_enabled {
371
+ "on"
372
+ } else {
373
+ "off"
374
+ }
375
+ ),
376
+ ]
377
+ }
378
+
379
+ fn metric_history_sparkline<F>(state: &AppState, f: F) -> String
380
+ where
381
+ F: Fn(&crate::app::MetricHistoryEntry) -> u64,
382
+ {
383
+ let samples: Vec<u64> = state
384
+ .portal_monitoring
385
+ .metric_history
386
+ .iter()
387
+ .map(f)
388
+ .collect();
389
+ ascii_sparkline(&samples)
390
+ }
391
+
392
+ fn metric_history_lines(state: &AppState) -> Vec<String> {
393
+ let hist = &state.portal_monitoring.metric_history;
394
+ if hist.is_empty() {
395
+ return vec!["History: no samples yet (wait for a full snapshot refresh).".into()];
396
+ }
397
+ let first = hist.front().map(|x| x.timestamp.as_str()).unwrap_or("?");
398
+ let last = hist.back().map(|x| x.timestamp.as_str()).unwrap_or("?");
399
+ vec![
400
+ format!(
401
+ "History: {} / 720 samples in memory (first {} last {}; wall-clock span depends on refresh cadence)",
402
+ hist.len(),
403
+ first,
404
+ last
405
+ ),
406
+ format!(
407
+ " HTTP requests {}",
408
+ metric_history_sparkline(state, |x| x.http_requests)
409
+ ),
410
+ format!(
411
+ " HTTP errors {}",
412
+ metric_history_sparkline(state, |x| x.http_errors)
413
+ ),
414
+ format!(
415
+ " Queue waiting {}",
416
+ metric_history_sparkline(state, |x| x.queue_waiting)
417
+ ),
418
+ format!(
419
+ " Queue failed {}",
420
+ metric_history_sparkline(state, |x| x.queue_failed)
421
+ ),
422
+ format!(
423
+ " Runs active {}",
424
+ metric_history_sparkline(state, |x| x.runs_active)
425
+ ),
426
+ format!(
427
+ " SSE active {}",
428
+ metric_history_sparkline(state, |x| x.sse_active)
429
+ ),
430
+ ]
431
+ }
432
+
433
+ fn section_row_style(status: SectionStatus) -> Style {
434
+ match status {
435
+ SectionStatus::Healthy => Style::default().fg(NEON_GREEN),
436
+ SectionStatus::Degraded => Style::default().fg(TEXT_WARN),
437
+ SectionStatus::Unhealthy => Style::default().fg(Color::Red),
438
+ SectionStatus::Loading => Style::default().fg(CYBER_CYAN),
439
+ SectionStatus::Unknown => Style::default().fg(TEXT_DIM),
440
+ }
441
+ }
442
+
443
+ const FIXED_TOP_LINES: usize = 3;
444
+
445
+ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
446
+ let vertical = Layout::default()
447
+ .direction(Direction::Vertical)
448
+ .constraints([
449
+ Constraint::Percentage((100 - percent_y) / 2),
450
+ Constraint::Percentage(percent_y),
451
+ Constraint::Percentage((100 - percent_y) / 2),
452
+ ])
453
+ .split(area);
454
+ Layout::default()
455
+ .direction(Direction::Horizontal)
456
+ .constraints([
457
+ Constraint::Percentage((100 - percent_x) / 2),
458
+ Constraint::Percentage(percent_x),
459
+ Constraint::Percentage((100 - percent_x) / 2),
460
+ ])
461
+ .split(vertical[1])[1]
462
+ }
463
+
464
+ fn summary_max_chars(area_width: u16) -> usize {
465
+ (area_width as usize).saturating_sub(26).clamp(24, 120)
466
+ }
467
+
468
+ /// Max `scroll_offset` for section list (excludes fixed gateway header inside body panel).
469
+ pub fn portal_monitoring_scroll_max(state: &AppState, summary_width: usize, body_inner_h: usize) -> usize {
470
+ let total = build_body_lines(state, summary_width).len();
471
+ let content_h = body_inner_h
472
+ .saturating_sub(FIXED_TOP_LINES)
473
+ .max(1);
474
+ total.saturating_sub(content_h.min(total.max(1)))
475
+ }
476
+
477
+ /// Build scrollable section lines. **Phase 1:** collapsed summaries and ●/⚠/✖ colors are derived only from
478
+ /// `parse_snapshot` of `content_lines`; `MonitoringState.sections` controls **expand** + **selection** only
479
+ /// (not `SectionState.status` / `summary`).
480
+ fn build_body_lines(state: &AppState, summary_width: usize) -> Vec<Line<'static>> {
481
+ let mut out: Vec<Line<'static>> = Vec::new();
482
+
483
+ if state.portal_monitoring.loading && state.portal_monitoring.content_lines.is_empty() {
484
+ out.push(Line::from(Span::styled(
485
+ "⏳ Fetching Gateway observability snapshot…",
486
+ Style::default().fg(TEXT_WARN),
487
+ )));
488
+ return out;
489
+ }
490
+ if let Some(ref err) = state.portal_monitoring.error {
491
+ out.push(Line::from(vec![
492
+ Span::styled("✗ ", Style::default().fg(Color::Red).bold()),
493
+ Span::styled(err.clone(), Style::default().fg(TEXT_PRIMARY)),
494
+ ]));
495
+ let hint = if state.portal_monitoring.content_lines.is_empty() {
496
+ "Press R to retry"
497
+ } else {
498
+ "Showing last known snapshot below. Press R to retry."
499
+ };
500
+ out.push(Line::from(Span::styled(hint, Style::default().fg(TEXT_DIM))));
501
+ if state.portal_monitoring.content_lines.is_empty() {
502
+ return out;
503
+ }
504
+ out.push(Line::from(""));
505
+ }
506
+ if state.portal_monitoring.content_lines.is_empty() {
507
+ out.push(Line::from(Span::styled(
508
+ "No snapshot yet. Select Overview and press R for a full snapshot.",
509
+ Style::default().fg(TEXT_DIM),
510
+ )));
511
+ return out;
512
+ }
513
+
514
+ let parsed = parse_snapshot(&state.portal_monitoring.content_lines);
515
+ let url = gateway_display_url(state);
516
+ let sections_order = MonitoringSection::all();
517
+ let ms = &state.advanced_monitoring.monitoring_state;
518
+ let sel = ms.selected_index;
519
+ let overrides = &state.portal_monitoring.section_overrides;
520
+
521
+ if let Some(alert) = state.portal_monitoring.dependency_alerts.back() {
522
+ if alert.current != "healthy" {
523
+ out.push(Line::from(vec![
524
+ Span::styled("⚠ ", Style::default().fg(TEXT_WARN).bold()),
525
+ Span::styled(
526
+ format!(
527
+ "Dependency status changed: {} → {} ({})",
528
+ alert.previous, alert.current, alert.timestamp
529
+ ),
530
+ Style::default().fg(TEXT_WARN),
531
+ ),
532
+ ]));
533
+ out.push(Line::from(""));
534
+ }
535
+ }
536
+
537
+ for (idx, section) in sections_order.iter().enumerate() {
538
+ let sec_state = ms.sections.get(section);
539
+ let expanded = sec_state.map(|s| s.expanded).unwrap_or(false);
540
+ let selected = idx == sel;
541
+ let ov = overrides.get(section);
542
+
543
+ let (status, summary) = match section {
544
+ MonitoringSection::Overview => {
545
+ let st = overall_status(&parsed);
546
+ (st, summarize_overview(&parsed, &url))
547
+ }
548
+ MonitoringSection::Health => {
549
+ if let Some(lines) = ov {
550
+ (status_from_text(lines), summarize_health(lines))
551
+ } else {
552
+ let st = status_from_text(&parsed.health);
553
+ (st, summarize_health(&parsed.health))
554
+ }
555
+ }
556
+ MonitoringSection::Dependencies => {
557
+ if let Some(lines) = ov {
558
+ (status_from_text(lines), summarize_dependencies(lines))
559
+ } else {
560
+ let st = status_from_text(&parsed.dependencies);
561
+ (st, summarize_dependencies(&parsed.dependencies))
562
+ }
563
+ }
564
+ MonitoringSection::Metrics => {
565
+ if let Some(lines) = ov {
566
+ (metrics_section_status(lines), summarize_metrics(lines))
567
+ } else {
568
+ let st = metrics_section_status(&parsed.metrics);
569
+ (st, summarize_metrics(&parsed.metrics))
570
+ }
571
+ }
572
+ MonitoringSection::Queue => {
573
+ if let Some(lines) = ov {
574
+ (queue_section_status(lines), summarize_queue(lines))
575
+ } else {
576
+ let st = queue_section_status(&parsed.queue);
577
+ (st, summarize_queue(&parsed.queue))
578
+ }
579
+ }
580
+ MonitoringSection::Logs => {
581
+ if let Some(lines) = ov {
582
+ let summ = if lines.is_empty() {
583
+ "no entries".into()
584
+ } else {
585
+ format!("{} entries", lines.len())
586
+ };
587
+ (logs_section_status(lines), summ)
588
+ } else if state.portal_monitoring.logs_fetch_loading {
589
+ (SectionStatus::Loading, "loading…".into())
590
+ } else {
591
+ (
592
+ SectionStatus::Unknown,
593
+ "Press L to fetch logs".to_string(),
594
+ )
595
+ }
596
+ }
597
+ MonitoringSection::System => {
598
+ if let Some(lines) = ov {
599
+ let summ = lines
600
+ .first()
601
+ .map(|s| s.trim().chars().take(56).collect::<String>())
602
+ .unwrap_or_else(|| "—".into());
603
+ (SectionStatus::Healthy, summ)
604
+ } else {
605
+ (
606
+ SectionStatus::Unknown,
607
+ "Press R for CLI/host stats".to_string(),
608
+ )
609
+ }
610
+ }
611
+ };
612
+
613
+ let sym = status.symbol();
614
+ let arrow = if expanded { "▼" } else { "▶" };
615
+ let prefix = if selected { "› " } else { " " };
616
+
617
+ let header_style = section_row_style(status).patch(
618
+ Style::default()
619
+ .bg(if selected { SEL_BG } else { BG_PANEL })
620
+ .bold(),
621
+ );
622
+
623
+ let spans = vec![
624
+ Span::styled(prefix, Style::default().fg(if selected { CYBER_CYAN } else { TEXT_DIM })),
625
+ Span::styled(format!("{} ", arrow), Style::default().fg(TEXT_HEADER)),
626
+ Span::styled(
627
+ format!("{:<14}", section.display_name()),
628
+ Style::default().fg(TEXT_PRIMARY),
629
+ ),
630
+ Span::styled(format!("{} ", sym), header_style),
631
+ Span::styled(
632
+ summary.chars().take(summary_width).collect::<String>(),
633
+ Style::default().fg(TEXT_PRIMARY),
634
+ ),
635
+ ];
636
+ out.push(Line::from(spans));
637
+
638
+ if expanded {
639
+ let loading_detail = state.portal_monitoring.section_refresh_loading == Some(*section)
640
+ || (matches!(section, MonitoringSection::Logs)
641
+ && state.portal_monitoring.logs_fetch_loading)
642
+ || (matches!(section, MonitoringSection::Metrics)
643
+ && state.portal_monitoring.metrics_drill_loading);
644
+ let using_override = ov.is_some();
645
+
646
+ let mut detail: Vec<String> = if loading_detail {
647
+ vec!["⏳ Loading…".into()]
648
+ } else if matches!(section, MonitoringSection::Metrics)
649
+ && state.portal_monitoring.metrics_drill.is_some()
650
+ {
651
+ state.portal_monitoring.metrics_drill_lines.clone()
652
+ } else if let Some(lines) = ov {
653
+ if matches!(section, MonitoringSection::Logs) && lines.is_empty() {
654
+ vec![
655
+ "No lines returned — the Gateway monitoring log buffer is empty.".into(),
656
+ "Entries require the Gateway to record events (e.g. addMonitoringLog). Traffic alone may not appear here.".into(),
657
+ ]
658
+ } else if matches!(section, MonitoringSection::System) {
659
+ let mut combined = lines.clone();
660
+ combined.extend(tui_runtime_lines(state));
661
+ combined
662
+ } else {
663
+ lines.clone()
664
+ }
665
+ } else {
666
+ match section {
667
+ MonitoringSection::Overview => parsed.overview_prefix.clone(),
668
+ MonitoringSection::Health => parsed.health.clone(),
669
+ MonitoringSection::Dependencies => parsed.dependencies.clone(),
670
+ MonitoringSection::Metrics => {
671
+ let mut m = parsed.metrics.clone();
672
+ if state.portal_monitoring.trend_view {
673
+ let mut v = metric_history_lines(state);
674
+ v.push(String::new());
675
+ v.extend(m);
676
+ m = v;
677
+ } else if state.portal_monitoring.metrics_drill.is_none()
678
+ && !state.portal_monitoring.http_total_sparkline.is_empty()
679
+ {
680
+ let samples: Vec<u64> = state
681
+ .portal_monitoring
682
+ .http_total_sparkline
683
+ .iter()
684
+ .copied()
685
+ .collect();
686
+ let spark = ascii_sparkline(&samples);
687
+ if !spark.is_empty() {
688
+ let mut v = vec![format!(
689
+ "HTTP total (from recent full snapshots) {}",
690
+ spark
691
+ )];
692
+ v.push(String::new());
693
+ v.extend(m);
694
+ m = v;
695
+ }
696
+ }
697
+ m
698
+ }
699
+ MonitoringSection::Queue => parsed.queue.clone(),
700
+ MonitoringSection::Logs => vec![
701
+ "Press L to fetch Gateway logs (monitoring.logs → GET /api/monitoring/logs).".into(),
702
+ "If results stay empty, nothing may be writing to the Gateway log ring buffer yet.".into(),
703
+ ],
704
+ MonitoringSection::System => vec![
705
+ "Press R to fetch CLI process + host OS stats.".into(),
706
+ "Press S to run diagnostics (Gateway verify + TUI WebSocket details).".into(),
707
+ String::new(),
708
+ "MK3 TUI runtime".into(),
709
+ format!(
710
+ " uptime={}s renders={} scheduled={}",
711
+ state.uptime_secs,
712
+ state.render_count,
713
+ state.render_scheduled_count
714
+ ),
715
+ ],
716
+ }
717
+ };
718
+ if matches!(section, MonitoringSection::Dependencies)
719
+ && !state.portal_monitoring.dependency_alerts.is_empty()
720
+ {
721
+ detail.push(String::new());
722
+ detail.push("Recent dependency status changes (text-derived from health lines)".into());
723
+ for alert in state.portal_monitoring.dependency_alerts.iter().rev().take(10) {
724
+ detail.push(format!(
725
+ " {} {} → {}",
726
+ alert.timestamp, alert.previous, alert.current
727
+ ));
728
+ }
729
+ }
730
+ if matches!(section, MonitoringSection::Overview) {
731
+ if let Some(path) = &state.portal_monitoring.last_export_path {
732
+ detail.push(String::new());
733
+ detail.push(format!("Last export: {}", path));
734
+ }
735
+ }
736
+ if matches!(section, MonitoringSection::Metrics)
737
+ && !using_override
738
+ && state.portal_monitoring.metrics_drill.is_none()
739
+ && !parsed.routes.is_empty()
740
+ {
741
+ let mut m = detail;
742
+ m.push(String::new());
743
+ m.extend(parsed.routes.clone());
744
+ for row in m {
745
+ out.push(Line::from(vec![Span::styled(
746
+ format!(" {}", row),
747
+ Style::default().fg(TEXT_DIM),
748
+ )]));
749
+ }
750
+ } else {
751
+ for row in detail {
752
+ out.push(Line::from(vec![Span::styled(
753
+ format!(" {}", row),
754
+ Style::default().fg(TEXT_DIM),
755
+ )]));
756
+ }
757
+ }
758
+ if matches!(section, MonitoringSection::Overview) {
759
+ out.push(Line::from(vec![Span::styled(
760
+ " Tip: Overview+R = full snapshot · other rows+R = section · E exports JSON to current working directory · H trends · L logs · A auto.",
761
+ Style::default().fg(TEXT_DIM),
762
+ )]));
763
+ }
764
+ if matches!(section, MonitoringSection::System) && expanded {
765
+ out.push(Line::from(vec![Span::styled(
766
+ " Phase 4: R = stats, S = diagnostics. Disk is intentionally marked unavailable until a native probe exists.",
767
+ Style::default().fg(TEXT_DIM),
768
+ )]));
769
+ }
770
+ if matches!(section, MonitoringSection::Metrics) && expanded {
771
+ let sub = state
772
+ .portal_monitoring
773
+ .metrics_drill
774
+ .map(|p: MetricsDrillPanel| p.label())
775
+ .unwrap_or("(snapshot)");
776
+ out.push(Line::from(vec![Span::styled(
777
+ format!(
778
+ " Phase 3/5: {} │ → next panel │ ← leave drill │ H trends {}",
779
+ sub,
780
+ if state.portal_monitoring.trend_view { "on" } else { "off" }
781
+ ),
782
+ Style::default().fg(TEXT_DIM),
783
+ )]));
784
+ }
785
+ }
786
+ }
787
+
788
+ out
789
+ }
790
+
28
791
  /// Full-screen Portal Monitoring (standalone base screen).
29
792
  pub fn render(f: &mut Frame, state: &mut AppState) {
30
793
  let area = f.size();
31
794
  if area.width == 0 || area.height == 0 {
32
795
  return;
33
796
  }
797
+ let summary_width = summary_max_chars(area.width);
798
+ state.portal_monitoring.summary_clip_width = summary_width;
799
+
800
+ // Auto-refresh flag + one mirror of `AppState.gateway_url` for code that reads `MonitoringState` only.
801
+ // (URL shown in the panel always comes from `gateway_display_url` → `AppState`.)
802
+ state
803
+ .advanced_monitoring
804
+ .monitoring_state
805
+ .gateway_url
806
+ .clone_from(&state.gateway_url);
807
+ state
808
+ .advanced_monitoring
809
+ .monitoring_state
810
+ .live_mode = state.portal_monitoring.auto_refresh_enabled;
34
811
 
35
812
  let chunks = Layout::default()
36
813
  .direction(Direction::Vertical)
@@ -47,7 +824,6 @@ pub fn render(f: &mut Frame, state: &mut AppState) {
47
824
  f.render_widget(Clear, area);
48
825
  f.render_widget(Block::default().style(Style::default().bg(BG_PANEL)), area);
49
826
 
50
- // Header
51
827
  let title = Block::default()
52
828
  .title(" Portal Monitoring ")
53
829
  .title_style(Style::default().fg(BRAND_PURPLE).bold())
@@ -57,141 +833,61 @@ pub fn render(f: &mut Frame, state: &mut AppState) {
57
833
  f.render_widget(title, chunks[0]);
58
834
 
59
835
  let url = gateway_display_url(state);
60
-
61
- // Build formatted content lines with styling
62
- let mut body_lines: Vec<Line> = vec![];
63
-
64
- // Gateway URL header
65
- body_lines.push(Line::from(vec![
66
- Span::styled("Linked Gateway: ", Style::default().fg(TEXT_DIM)),
836
+ let header_line = Line::from(vec![
837
+ Span::styled("Gateway: ", Style::default().fg(TEXT_DIM)),
67
838
  Span::styled(url, Style::default().fg(CYBER_CYAN).bold()),
68
- ]));
69
- body_lines.push(Line::from(""));
839
+ ]);
70
840
 
71
- if state.portal_monitoring.loading && state.portal_monitoring.content_lines.is_empty() {
72
- body_lines.push(Line::from(Span::styled(
73
- " Fetching Prometheus /metrics from Gateway…",
74
- Style::default().fg(TEXT_WARN),
75
- )));
76
- } else if let Some(ref err) = state.portal_monitoring.error {
77
- body_lines.push(Line::from(vec![
78
- Span::styled("✗ Error: ", Style::default().fg(Color::Red).bold()),
79
- Span::styled(err, Style::default().fg(TEXT_PRIMARY)),
80
- ]));
81
- body_lines.push(Line::from(""));
82
- body_lines.push(Line::from(Span::styled(
83
- "Press R to retry",
84
- Style::default().fg(TEXT_DIM),
85
- )));
86
- } else if state.portal_monitoring.content_lines.is_empty() {
87
- body_lines.push(Line::from(Span::styled(
88
- "No snapshot yet. Press R to fetch /metrics.",
89
- Style::default().fg(TEXT_DIM),
90
- )));
841
+ let auto_iv = state.portal_monitoring.auto_refresh_interval.as_secs();
842
+ let live_txt = if state.portal_monitoring.auto_refresh_enabled {
843
+ format!("Live {}s", auto_iv)
91
844
  } else {
92
- // Parse and format content lines with enhanced readability
93
- let mut in_section = false;
94
- let mut section_name = "";
95
-
96
- for raw_line in &state.portal_monitoring.content_lines {
97
- let line_text = raw_line.trim();
98
-
99
- // Detect section headers
100
- if line_text.starts_with("Live link check")
101
- || line_text.starts_with("Dependency checks")
102
- || line_text.starts_with("Prometheus /metrics")
103
- || line_text.starts_with("Top HTTP routes") {
104
- in_section = true;
105
- section_name = line_text;
106
- body_lines.push(Line::from(Span::styled(
107
- format!("━━ {} ━━", line_text),
108
- Style::default().fg(TEXT_HEADER).bold(),
109
- )));
110
- continue;
111
- }
112
-
113
- // Empty lines
114
- if line_text.is_empty() {
115
- body_lines.push(Line::from(""));
116
- in_section = false;
117
- continue;
118
- }
119
-
120
- // Special formatting for different line types
121
- if line_text.starts_with("LIVE:") {
122
- body_lines.push(Line::from(Span::styled(
123
- line_text,
124
- Style::default().fg(NEON_GREEN).bold(),
125
- )));
126
- } else if line_text.starts_with("✓") || line_text.starts_with("/health OK") {
127
- body_lines.push(Line::from(Span::styled(
128
- line_text,
129
- Style::default().fg(NEON_GREEN),
130
- )));
131
- } else if line_text.starts_with("⚠") || line_text.contains("degraded") || line_text.contains("not ready") {
132
- body_lines.push(Line::from(Span::styled(
133
- line_text,
134
- Style::default().fg(TEXT_WARN),
135
- )));
136
- } else if line_text.starts_with(" •") {
137
- // Dependency check lines - color based on status
138
- let color = if line_text.contains(": up") {
139
- NEON_GREEN
140
- } else if line_text.contains(": down") {
141
- Color::Red
142
- } else {
143
- TEXT_WARN
144
- };
145
- body_lines.push(Line::from(Span::styled(
146
- line_text,
147
- Style::default().fg(color),
148
- )));
149
- } else if line_text.starts_with(" ") {
150
- // Indented content (route metrics, etc)
151
- body_lines.push(Line::from(Span::styled(
152
- line_text,
153
- Style::default().fg(TEXT_PRIMARY),
154
- )));
155
- } else if line_text.contains("total") || line_text.contains("latency") || line_text.contains("created") {
156
- // Metrics lines - highlight numbers
157
- let parts: Vec<&str> = line_text.split_whitespace().collect();
158
- if parts.len() >= 2 {
159
- let (label, value) = line_text.split_at(line_text.rfind(char::is_whitespace).unwrap_or(line_text.len()));
160
- body_lines.push(Line::from(vec![
161
- Span::styled(format!("{} ", label.trim()), Style::default().fg(TEXT_DIM)),
162
- Span::styled(value.trim(), Style::default().fg(CYBER_CYAN).bold()),
163
- ]));
164
- } else {
165
- body_lines.push(Line::from(Span::styled(
166
- line_text,
167
- Style::default().fg(TEXT_PRIMARY),
168
- )));
169
- }
170
- } else {
171
- // Default text
172
- body_lines.push(Line::from(Span::styled(
173
- line_text,
174
- Style::default().fg(TEXT_PRIMARY),
175
- )));
176
- }
177
- }
178
- }
845
+ "Live ✗".into()
846
+ };
847
+ let meta = Line::from(vec![
848
+ Span::styled(
849
+ " ",
850
+ Style::default(),
851
+ ),
852
+ Span::styled(live_txt, Style::default().fg(NEON_GREEN)),
853
+ ]);
854
+
855
+ let keys_hint = Line::from(vec![Span::styled(
856
+ "R sect/full · H trends · E export · Metrics ▼ + →/← drill · Dep ▼ + T detail · Sys ▼ + S diagnostics · L logs · A auto",
857
+ Style::default().fg(TEXT_DIM),
858
+ )]);
179
859
 
180
- // Calculate scrolling
860
+ let body_lines = build_body_lines(state, summary_width);
181
861
  let total = body_lines.len();
182
- let max_scroll = total.saturating_sub(body_inner_h.min(total.max(1)));
862
+ let content_h = body_inner_h
863
+ .saturating_sub(FIXED_TOP_LINES)
864
+ .max(1);
865
+ let max_scroll = total.saturating_sub(content_h.min(total.max(1)));
183
866
  let start = state.portal_monitoring.scroll_offset.min(max_scroll);
184
- let end = (start + body_inner_h).min(total);
185
- let window: Vec<Line> = body_lines[start..end].to_vec();
867
+ let end = (start + content_h).min(total);
868
+ let window: Vec<Line> = if start < total {
869
+ body_lines[start..end].to_vec()
870
+ } else {
871
+ vec![]
872
+ };
186
873
 
187
- let body = Paragraph::new(window)
874
+ let header_lines = vec![header_line, meta, keys_hint];
875
+ let combined: Vec<Line> = header_lines
876
+ .into_iter()
877
+ .chain(window.into_iter())
878
+ .collect();
879
+
880
+ let body = Paragraph::new(combined)
188
881
  .wrap(Wrap { trim: true })
189
882
  .style(Style::default().bg(BG_PANEL))
190
883
  .block(
191
884
  Block::default()
192
885
  .title(vec![
193
886
  Span::styled(" ", Style::default()),
194
- Span::styled("Traffic & Observability", Style::default().fg(NEON_GREEN).bold()),
887
+ Span::styled(
888
+ "Sections (same Gateway data as before)",
889
+ Style::default().fg(NEON_GREEN).bold(),
890
+ ),
195
891
  Span::styled(" ", Style::default()),
196
892
  ])
197
893
  .borders(Borders::ALL)
@@ -200,53 +896,100 @@ pub fn render(f: &mut Frame, state: &mut AppState) {
200
896
  );
201
897
  f.render_widget(body, chunks[1]);
202
898
 
203
- // Footer with better visual hierarchy
899
+ // Footer (Phase 1 keys) — align with body when the last observability pull failed
204
900
  let status = if state.portal_monitoring.loading {
205
901
  vec![
206
902
  Span::styled("⏳ ", Style::default().fg(TEXT_WARN)),
207
903
  Span::styled("loading", Style::default().fg(TEXT_WARN)),
208
904
  ]
905
+ } else if state.portal_monitoring.section_refresh_loading.is_some()
906
+ || state.portal_monitoring.logs_fetch_loading
907
+ || state.portal_monitoring.metrics_drill_loading
908
+ {
909
+ vec![
910
+ Span::styled("◉ ", Style::default().fg(CYBER_CYAN)),
911
+ Span::styled("section…", Style::default().fg(CYBER_CYAN)),
912
+ ]
913
+ } else if state.portal_monitoring.error.is_some() {
914
+ vec![
915
+ Span::styled("⚠ ", Style::default().fg(TEXT_WARN)),
916
+ Span::styled("error", Style::default().fg(TEXT_WARN)),
917
+ ]
209
918
  } else {
210
919
  vec![
211
920
  Span::styled("● ", Style::default().fg(NEON_GREEN)),
212
- Span::styled("live", Style::default().fg(NEON_GREEN)),
921
+ Span::styled("ok", Style::default().fg(NEON_GREEN)),
213
922
  ]
214
923
  };
215
-
216
- // Auto-refresh status
924
+
217
925
  let auto_status = if state.portal_monitoring.auto_refresh_enabled {
218
926
  Span::styled("auto ✓", Style::default().fg(NEON_GREEN))
219
927
  } else {
220
928
  Span::styled("auto ✗", Style::default().fg(TEXT_DIM))
221
929
  };
222
-
223
- // Last refresh timer
930
+
224
931
  let refresh_info = if let Some(last) = state.portal_monitoring.last_refresh {
225
932
  let elapsed = last.elapsed().as_secs();
226
933
  format!(" ~{}s ago", elapsed)
227
934
  } else {
228
935
  " never".to_string()
229
936
  };
230
-
231
- let mut footer_spans = vec![
232
- Span::styled("ESC ", Style::default().fg(BRAND_PURPLE).bold()),
233
- Span::styled("Main ", Style::default().fg(TEXT_DIM)),
234
- Span::styled(" ", Style::default().fg(SEPARATOR_COLOR)),
235
- Span::styled("R ", Style::default().fg(CYBER_CYAN).bold()),
236
- Span::styled("refresh ", Style::default().fg(TEXT_DIM)),
237
- Span::styled("", Style::default().fg(SEPARATOR_COLOR)),
238
- Span::styled("A ", Style::default().fg(CYBER_CYAN).bold()),
239
- Span::styled("toggle auto ", Style::default().fg(TEXT_DIM)),
240
- Span::styled(" ", Style::default().fg(SEPARATOR_COLOR)),
241
- Span::styled("↑↓ ", Style::default().fg(CYBER_CYAN).bold()),
242
- Span::styled("scroll ", Style::default().fg(TEXT_DIM)),
243
- Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
244
- ];
937
+
938
+ let mut footer_spans = if area.width < 100 {
939
+ vec![
940
+ Span::styled("? ", Style::default().fg(CYBER_CYAN).bold()),
941
+ Span::styled("help ", Style::default().fg(TEXT_DIM)),
942
+ Span::styled("ESC ", Style::default().fg(BRAND_PURPLE).bold()),
943
+ Span::styled("main ", Style::default().fg(TEXT_DIM)),
944
+ Span::styled("↑↓ ", Style::default().fg(CYBER_CYAN).bold()),
945
+ Span::styled("sel ", Style::default().fg(TEXT_DIM)),
946
+ Span::styled("Enter ", Style::default().fg(CYBER_CYAN).bold()),
947
+ Span::styled("open ", Style::default().fg(TEXT_DIM)),
948
+ Span::styled("R ", Style::default().fg(CYBER_CYAN).bold()),
949
+ Span::styled("refresh ", Style::default().fg(TEXT_DIM)),
950
+ ]
951
+ } else {
952
+ vec![
953
+ Span::styled("? ", Style::default().fg(CYBER_CYAN).bold()),
954
+ Span::styled("help ", Style::default().fg(TEXT_DIM)),
955
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
956
+ Span::styled("ESC ", Style::default().fg(BRAND_PURPLE).bold()),
957
+ Span::styled("Main ", Style::default().fg(TEXT_DIM)),
958
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
959
+ Span::styled("↑↓ ", Style::default().fg(CYBER_CYAN).bold()),
960
+ Span::styled("section ", Style::default().fg(TEXT_DIM)),
961
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
962
+ Span::styled("Enter ", Style::default().fg(CYBER_CYAN).bold()),
963
+ Span::styled("expand ", Style::default().fg(TEXT_DIM)),
964
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
965
+ Span::styled("R ", Style::default().fg(CYBER_CYAN).bold()),
966
+ Span::styled("full/sect ", Style::default().fg(TEXT_DIM)),
967
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
968
+ Span::styled("L ", Style::default().fg(CYBER_CYAN).bold()),
969
+ Span::styled("logs ", Style::default().fg(TEXT_DIM)),
970
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
971
+ Span::styled("H ", Style::default().fg(CYBER_CYAN).bold()),
972
+ Span::styled("trend ", Style::default().fg(TEXT_DIM)),
973
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
974
+ Span::styled("E ", Style::default().fg(CYBER_CYAN).bold()),
975
+ Span::styled("export ", Style::default().fg(TEXT_DIM)),
976
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
977
+ Span::styled("S ", Style::default().fg(CYBER_CYAN).bold()),
978
+ Span::styled("diag ", Style::default().fg(TEXT_DIM)),
979
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
980
+ Span::styled("A ", Style::default().fg(CYBER_CYAN).bold()),
981
+ Span::styled("auto ", Style::default().fg(TEXT_DIM)),
982
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
983
+ Span::styled("Pg ", Style::default().fg(CYBER_CYAN).bold()),
984
+ Span::styled("scroll ", Style::default().fg(TEXT_DIM)),
985
+ Span::styled("│ ", Style::default().fg(SEPARATOR_COLOR)),
986
+ ]
987
+ };
245
988
  footer_spans.extend(status);
246
989
  footer_spans.push(Span::styled(refresh_info.as_str(), Style::default().fg(TEXT_DIM)));
247
990
  footer_spans.push(Span::styled(" ", Style::default()));
248
991
  footer_spans.push(auto_status);
249
-
992
+
250
993
  let footer = Paragraph::new(Line::from(footer_spans))
251
994
  .alignment(Alignment::Center)
252
995
  .block(
@@ -256,4 +999,133 @@ pub fn render(f: &mut Frame, state: &mut AppState) {
256
999
  .style(Style::default().bg(BG_PANEL)),
257
1000
  );
258
1001
  f.render_widget(footer, chunks[2]);
1002
+
1003
+ if state.portal_monitoring.help_visible {
1004
+ let help_area = centered_rect(72, 62, area);
1005
+ let help_lines = vec![
1006
+ Line::from(vec![Span::styled(
1007
+ "Portal Monitoring Help",
1008
+ Style::default().fg(CYBER_CYAN).bold(),
1009
+ )]),
1010
+ Line::from(""),
1011
+ Line::from("Navigation"),
1012
+ Line::from(" ↑/↓ select section Enter/Space expand/collapse PgUp/PgDn scroll"),
1013
+ Line::from(" ESC close help / return to Main"),
1014
+ Line::from(""),
1015
+ Line::from("Actions"),
1016
+ Line::from(" R refresh current row (Overview = full snapshot)"),
1017
+ Line::from(" L fetch Gateway logs H toggle trends E export JSON to current directory"),
1018
+ Line::from(" Metrics expanded: → cycle HTTP/Runs/Queue/SSE drill, ← leave drill"),
1019
+ Line::from(" Dependencies expanded: T detail System expanded: S diagnostics"),
1020
+ Line::from(""),
1021
+ Line::from("Recovery"),
1022
+ Line::from(" If a refresh fails, the last known snapshot remains visible when available."),
1023
+ Line::from(" Dependency alert banners are text-derived from Gateway health lines."),
1024
+ Line::from(""),
1025
+ Line::from("Press ? or ESC to close."),
1026
+ ];
1027
+ let help = Paragraph::new(help_lines)
1028
+ .wrap(Wrap { trim: true })
1029
+ .style(Style::default().bg(BG_PANEL).fg(TEXT_PRIMARY))
1030
+ .block(
1031
+ Block::default()
1032
+ .title(" Help ")
1033
+ .title_style(Style::default().fg(BRAND_PURPLE).bold())
1034
+ .borders(Borders::ALL)
1035
+ .border_style(Style::default().fg(CYBER_CYAN))
1036
+ .style(Style::default().bg(BG_PANEL)),
1037
+ );
1038
+ f.render_widget(Clear, help_area);
1039
+ f.render_widget(help, help_area);
1040
+ }
1041
+ }
1042
+
1043
+ #[cfg(test)]
1044
+ mod tests {
1045
+ use super::*;
1046
+
1047
+ /// Ordering mirrors `main.rs` (observability handler) + strings from `tui-handlers` healthLines.
1048
+ fn fixture_representative() -> Vec<String> {
1049
+ vec![
1050
+ "Portal snapshot (health + metrics on each refresh / auto poll).".into(),
1051
+ "Fetched: 2026-01-01T00:00:00.000Z".into(),
1052
+ String::new(),
1053
+ "LIVE: +0 HTTP since last screen refresh (~0/min) --".into(),
1054
+ String::new(),
1055
+ "Live link check (/health + /ready)".into(),
1056
+ "✓ /health OK — Gateway alive (5 ms)".into(),
1057
+ String::new(),
1058
+ "Dependency checks (from /ready):".into(),
1059
+ " • ✓ PostgreSQL (4Runr DB): up".into(),
1060
+ String::new(),
1061
+ "Prometheus /metrics (HTTP total includes this screen’s /health, /ready, and /metrics polls; add API/agent traffic for runs/queue/SSE)."
1062
+ .into(),
1063
+ String::new(),
1064
+ "HTTP requests total 10".into(),
1065
+ "HTTP request errors 0 (0.00%)".into(),
1066
+ "Queue jobs waiting 0".into(),
1067
+ "Queue jobs failed 0".into(),
1068
+ String::new(),
1069
+ "Top HTTP routes (from http_requests_total labels)".into(),
1070
+ " GET /health 3".into(),
1071
+ ]
1072
+ }
1073
+
1074
+ #[test]
1075
+ fn portal_snapshot_parse_partitions_sections() {
1076
+ let p = parse_snapshot(&fixture_representative());
1077
+ assert!(
1078
+ !p.overview_prefix.is_empty(),
1079
+ "overview should hold preamble + LIVE block"
1080
+ );
1081
+ assert!(
1082
+ p.health.iter().any(|l| l.contains(SNAPSHOT_MARKER_LIVE)),
1083
+ "health should contain live marker"
1084
+ );
1085
+ assert!(
1086
+ p.dependencies
1087
+ .iter()
1088
+ .any(|l| l.contains(SNAPSHOT_MARKER_DEPS)),
1089
+ "dependencies block present"
1090
+ );
1091
+ assert!(
1092
+ p.metrics.iter().any(|l| l.contains("HTTP requests total")),
1093
+ "metrics stats extracted"
1094
+ );
1095
+ assert!(
1096
+ p.queue.iter().any(|l| l.contains("Queue jobs")),
1097
+ "queue stats extracted"
1098
+ );
1099
+ assert!(
1100
+ p.routes.iter().any(|l| l.contains(SNAPSHOT_MARKER_TOP_ROUTES)),
1101
+ "routes tail present"
1102
+ );
1103
+ }
1104
+
1105
+ #[test]
1106
+ fn metrics_section_status_errors_nonzero_is_degraded() {
1107
+ let m = vec!["HTTP request errors 3 (2.00%)".into()];
1108
+ assert_eq!(metrics_section_status(&m), SectionStatus::Degraded);
1109
+ let ok = vec!["HTTP request errors 0 (0.00%)".into()];
1110
+ assert_eq!(metrics_section_status(&ok), SectionStatus::Healthy);
1111
+ }
1112
+
1113
+ #[test]
1114
+ fn queue_section_status_failed_nonzero_is_degraded() {
1115
+ let q = vec!["Queue jobs failed 2".into()];
1116
+ assert_eq!(queue_section_status(&q), SectionStatus::Degraded);
1117
+ let ok = vec!["Queue jobs failed 0".into()];
1118
+ assert_eq!(queue_section_status(&ok), SectionStatus::Healthy);
1119
+ }
1120
+
1121
+ #[test]
1122
+ fn logs_section_status_picks_worst_level() {
1123
+ assert_eq!(logs_section_status(&[]), SectionStatus::Unknown);
1124
+ let ok = vec!["2026-01-01T00:00:00.000Z [info] up".into()];
1125
+ assert_eq!(logs_section_status(&ok), SectionStatus::Healthy);
1126
+ let warn = vec!["t [warn] slow".into(), "t [info] fine".into()];
1127
+ assert_eq!(logs_section_status(&warn), SectionStatus::Degraded);
1128
+ let err = vec!["t [warn] x".into(), "t [error] boom".into()];
1129
+ assert_eq!(logs_section_status(&err), SectionStatus::Unhealthy);
1130
+ }
259
1131
  }