diff --git a/adiuvare/tui/replit.tcss b/adiuvare/tui/replit.tcss index 1dc3cc2..02e6c92 100644 --- a/adiuvare/tui/replit.tcss +++ b/adiuvare/tui/replit.tcss @@ -286,12 +286,21 @@ Scrollbar { layout: vertical; } +#events-header-notice { + height: auto; + max-height: 2; + padding: 0 2; + background: #0d1117; + border-bottom: solid #21262d; +} + #events-filter-bar { height: auto; background: #0d1117; padding: 0 2; layout: horizontal; align: left middle; + border-bottom: solid #21262d; } #events-filter-label { @@ -311,33 +320,38 @@ Scrollbar { content-align: right middle; } +#events-body { + height: 1fr; + layout: horizontal; +} + #events-table { - height: 40%; + width: 1fr; + height: 1fr; border: solid #21262d; background: #161b22; } -#events-detail-area { +#events-right-col { + width: 42%; + min-width: 44; height: 1fr; - min-height: 12; - layout: horizontal; - overflow-y: auto; + background: #0d1117; + border-left: solid #21262d; } #events-detail-panel { - width: 60%; + height: 1fr; border: solid #21262d; background: #161b22; padding: 1 2; - overflow-y: auto; } #events-context-panel { - width: 40%; + height: 1fr; border: solid #21262d; background: #161b22; padding: 1 2; - overflow-y: auto; } #events-action-bar { @@ -363,6 +377,7 @@ Scrollbar { content-align: left middle; } + /* ══════════════════════════════════════════════════════════════ PAGE 3 — CONFIG ══════════════════════════════════════════════════════════════ */ diff --git a/adiuvare/tui/screens/events.py b/adiuvare/tui/screens/events.py index 10e83b6..8d40f60 100644 --- a/adiuvare/tui/screens/events.py +++ b/adiuvare/tui/screens/events.py @@ -1,10 +1,15 @@ from collections import Counter from typing import TYPE_CHECKING, cast +from rich.console import Group +from rich.table import Table from rich.text import Text +from textual.css.scalar import Scalar +from textual.layouts.vertical import VerticalLayout from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, HorizontalScroll, Vertical +from textual import events +from textual.containers import Horizontal, HorizontalScroll, Vertical, VerticalScroll from textual.widgets import Button, DataTable, Input, Static from ..operator_actions import ( @@ -21,7 +26,6 @@ dominant_color, render_score_bar, render_signal_bar, - styled_label, styled_separator, ) @@ -33,6 +37,7 @@ class EventsScreen(WorkspaceView): shortcut_hints = "[1-7] tabs [f] filter [c] confirm [w] whitelist [m] monitor [e] export" primary_id = "events-table" search_id = "events-identity-filter" + responsive_breakpoint = 90 BINDINGS = [ Binding("c", "confirm_block", "Confirm block", show=False), @@ -49,31 +54,56 @@ def __init__(self, *args, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="events-outer"): + yield Static( + f"[{PALETTE['cyan']}]EVENTS[/] " + f"[{PALETTE['dim']}]Review queue for non-allow events (select a row to inspect + act)[/]", + id="events-header-notice", + ) with Horizontal(id="events-filter-bar"): yield Static(f"[{PALETTE['very_dim']}]FILTER[/]", id="events-filter-label") yield Input(placeholder="identity", id="events-identity-filter") yield Input(placeholder="flag / throttle / block", id="events-verdict-filter") yield Static("", id="events-filter-stats") - yield DataTable(id="events-table") - with Horizontal(id="events-detail-area"): - yield Static("", id="events-detail-panel") - yield Static("", id="events-context-panel") - with HorizontalScroll(id="events-action-bar"): - yield Button("Confirm Block", id="events-confirm", classes="confirm") - yield Button("Whitelist", id="events-whitelist", classes="success") - yield Button("Monitor", id="events-monitor", classes="warning") - yield Button("Unmonitor", id="events-unmonitor", classes="outline") - yield Button("Unblock+Monitor", id="events-unblock-monitor", classes="warning") - yield Button("Ban IP", id="events-ban-ip", classes="confirm") - yield Button("Unban IP", id="events-unban-ip", classes="outline") - yield Button("Export JSON", id="events-export", classes="danger") - yield Static("", id="events-action-status") + with Horizontal(id="events-body"): + yield DataTable(id="events-table") + with Vertical(id="events-right-col"): + with VerticalScroll(id="events-detail-panel"): + yield Static("", id="events-detail-text") + with VerticalScroll(id="events-context-panel"): + yield Static("", id="events-context-text") + with HorizontalScroll(id="events-action-bar"): + yield Button("Confirm Block", id="events-confirm", classes="confirm") + yield Button("Whitelist", id="events-whitelist", classes="success") + yield Button("Monitor", id="events-monitor", classes="warning") + yield Button("Unmonitor", id="events-unmonitor", classes="outline") + yield Button("Unblock+Monitor", id="events-unblock-monitor", classes="warning") + yield Button("Ban IP", id="events-ban-ip", classes="confirm") + yield Button("Unban IP", id="events-unban-ip", classes="outline") + yield Button("Export JSON", id="events-export", classes="danger") + yield Static("", id="events-action-status") def on_mount(self) -> None: table = self.query_one("#events-table", DataTable) table.cursor_type = "row" table.add_columns("VERDICT", "SCORE", "IDENTITY", "ENDPOINT", "IP", "DOMINANT", "AGE") self.refresh_view() + self._apply_responsive_layout() + + def on_resize(self, event: events.Resize) -> None: + self._apply_responsive_layout() + + def _apply_responsive_layout(self) -> None: + body = self.query_one("#events-body") + right_col = self.query_one("#events-right-col") + + if self.size.width <= self.responsive_breakpoint: + body.styles.set_rule("layout", VerticalLayout()) + right_col.styles.set_rule("width", Scalar.parse("1fr")) + right_col.styles.set_rule("min_width", Scalar.parse("0")) + else: + body.styles.clear_rule("layout") + right_col.styles.clear_rule("width") + right_col.styles.clear_rule("min_width") def on_input_changed(self, event: Input.Changed) -> None: if event.input.id in {"events-identity-filter", "events-verdict-filter"}: @@ -278,7 +308,7 @@ def _update_action_status(self) -> None: ) def _render_detail(self) -> None: - panel = self.query_one("#events-detail-panel", Static) + panel = self.query_one("#events-detail-text", Static) if not self._selected: panel.update(f"[{PALETTE['very_dim']}]Select an event to view details.[/]") return @@ -290,38 +320,78 @@ def _render_detail(self) -> None: breakdown = event.get("breakdown") or {} detail = event.get("detail") or {} - lines = [ - f"[{PALETTE['dim']} bold]EVENT DETAIL[/]", - "", - styled_label("Identity", str(event.get("identity", "?"))), - styled_label("Endpoint", f"[{PALETTE['dim']}]{event.get('endpoint', '?')}[/]"), - styled_label("IP", str(event.get("ip", "-") or "-")), - f"[{PALETTE['dim']}]Score [/] {render_score_bar(score)} [{PALETTE['cyan']}]{score:.4f}[/]", - styled_label("Verdict", f"[{verdict_color}]{decision_icon(verdict)} {verdict.upper()}[/]"), - ] + title = Text("EVENT DETAIL", style=f"{PALETTE['dim']} bold") + identity = str(event.get("identity", "?")) + endpoint = str(event.get("endpoint", "?")) + ip = str(event.get("ip", "-") or "-") + + kv = Table.grid(padding=(0, 1)) + kv.expand = True + kv.add_column(style=PALETTE["dim"], no_wrap=True) + kv.add_column(ratio=1) + kv.add_row("Identity", Text(identity, style=PALETTE["text"])) + kv.add_row( + "Endpoint", + Text(endpoint, style=PALETTE["dim"], overflow="ellipsis", no_wrap=True), + ) + kv.add_row("IP", Text(ip, style=PALETTE["dim"])) + + score_line = Text.from_markup( + ( + f"[{PALETTE['dim']}]Score[/] {render_score_bar(score, 8)} " + f"[{PALETTE['cyan']}]{score:.4f}[/] " + f"[{PALETTE['dim']}]Verdict[/] " + f"[{verdict_color}]{decision_icon(verdict)} {verdict.upper()}[/]" + ) + ) + + renderables: list[object] = [title, kv, score_line] if isinstance(breakdown, dict) and breakdown: - lines.extend(["", styled_separator(), f"[{PALETTE['very_dim']}]SIGNAL BREAKDOWN[/]", ""]) + breakdown_table = Table.grid(padding=(0, 1)) + breakdown_table.expand = True + breakdown_table.add_column(style=PALETTE["dim"], no_wrap=True) + breakdown_table.add_column(ratio=1) + breakdown_table.add_column(justify="right", no_wrap=True, style=PALETTE["cyan"]) + peak = max(breakdown.values()) if breakdown.values() else 1.0 for name, value in sorted(breakdown.items(), key=lambda item: item[1], reverse=True): value_f = float(value) bar = render_signal_bar(value_f, peak, 15) - lines.append(f" [{PALETTE['dim']}]{name:<12}[/] {bar} [{PALETTE['cyan']}]{value_f:.4f}[/]") + breakdown_table.add_row(str(name), Text.from_markup(bar), f"{value_f:.4f}") + + renderables.extend( + [ + Text(""), + Text.from_markup(styled_separator()), + Text.from_markup(f"[{PALETTE['very_dim']}]SIGNAL BREAKDOWN[/]"), + Text(""), + breakdown_table, + ] + ) ai = detail.get("ai") if isinstance(detail, dict) else None if isinstance(ai, dict) and ai: - lines.extend([ - "", - styled_separator(), - f"[{PALETTE['very_dim']}]AI DETAIL[/]", - styled_label("AI verdict", str(ai.get("verdict", "n/a")), PALETTE["purple"]), - styled_label("Confidence", f"{ai.get('confidence', 0):.2f}", PALETTE["cyan"]), - ]) + ai_table = Table.grid(padding=(0, 1)) + ai_table.expand = True + ai_table.add_column(style=PALETTE["dim"], no_wrap=True) + ai_table.add_column(ratio=1) + ai_table.add_row("AI verdict", Text(str(ai.get("verdict", "n/a")), style=PALETTE["purple"])) + ai_table.add_row("Confidence", Text(f"{ai.get('confidence', 0):.2f}", style=PALETTE["cyan"])) + + renderables.extend( + [ + Text(""), + Text.from_markup(styled_separator()), + Text.from_markup(f"[{PALETTE['very_dim']}]AI DETAIL[/]"), + ai_table, + ] + ) - panel.update("\n".join(lines)) + panel.update(Group(*renderables)) def _render_context(self) -> None: - panel = self.query_one("#events-context-panel", Static) + panel = self.query_one("#events-context-text", Static) if not self._selected: panel.update("") return @@ -342,29 +412,60 @@ def _render_context(self) -> None: is_whitelisted = identity in whitelisted states = self._action_states(event) - lines = [ - f"[{PALETTE['dim']} bold]IDENTITY CONTEXT[/]", - "", - styled_label("Identity", identity), - f"[{PALETTE['dim']}]Monitored [/] [{PALETTE['green'] if is_monitored else PALETTE['dim']}]{'yes' if is_monitored else 'no'}[/]", - f"[{PALETTE['dim']}]Blocked [/] [{PALETTE['red'] if is_blocked else PALETTE['dim']}]{'yes' if is_blocked else 'no'}[/]", - f"[{PALETTE['dim']}]Banned IP [/] [{PALETTE['red'] if is_banned else PALETTE['dim']}]{'yes' if is_banned else 'no'}[/]", - f"[{PALETTE['dim']}]Whitelisted [/] [{PALETTE['green'] if is_whitelisted else PALETTE['dim']}]{'yes' if is_whitelisted else 'no'}[/]", - "", - styled_separator(), - f"[{PALETTE['very_dim']}]AVAILABLE ACTIONS[/]", - f"[{PALETTE['very_dim']}]● ready ○ unavailable (hover buttons for detail)[/]", - "", - format_action_legend_line("Confirm block", states["events-confirm"], "C"), - format_action_legend_line("Whitelist", states["events-whitelist"], "W"), - format_action_legend_line("Monitor identity", states["events-monitor"], "M"), - format_action_legend_line("Unmonitor identity", states["events-unmonitor"]), - format_action_legend_line("Unblock + monitor", states["events-unblock-monitor"]), - format_action_legend_line("Ban IP", states["events-ban-ip"]), - format_action_legend_line("Unban IP", states["events-unban-ip"]), - format_action_legend_line("Export JSON", states["events-export"], "E"), + title = Text("IDENTITY CONTEXT", style=f"{PALETTE['dim']} bold") + + context_table = Table.grid(padding=(0, 1)) + context_table.expand = True + context_table.add_column(style=PALETTE["dim"], no_wrap=True) + context_table.add_column(ratio=1) + context_table.add_row("Identity", Text(identity, style=PALETTE["text"])) + + status_line_1 = Text.from_markup( + ( + f"[{PALETTE['dim']}]Monitored[/] " + f"[{PALETTE['green'] if is_monitored else PALETTE['dim']}]" + f"{'yes' if is_monitored else 'no'}[/] " + f"[{PALETTE['dim']}]Blocked[/] " + f"[{PALETTE['red'] if is_blocked else PALETTE['dim']}]" + f"{'yes' if is_blocked else 'no'}[/]" + ) + ) + status_line_2 = Text.from_markup( + ( + f"[{PALETTE['dim']}]Banned IP[/] " + f"[{PALETTE['red'] if is_banned else PALETTE['dim']}]" + f"{'yes' if is_banned else 'no'}[/] " + f"[{PALETTE['dim']}]Whitelisted[/] " + f"[{PALETTE['green'] if is_whitelisted else PALETTE['dim']}]" + f"{'yes' if is_whitelisted else 'no'}[/]" + ) + ) + + action_lines = [ + Text.from_markup(format_action_legend_line("Confirm block", states["events-confirm"], "C")), + Text.from_markup(format_action_legend_line("Whitelist", states["events-whitelist"], "W")), + Text.from_markup(format_action_legend_line("Monitor identity", states["events-monitor"], "M")), + Text.from_markup(format_action_legend_line("Unmonitor identity", states["events-unmonitor"])), + Text.from_markup(format_action_legend_line("Unblock + monitor", states["events-unblock-monitor"])), + Text.from_markup(format_action_legend_line("Ban IP", states["events-ban-ip"])), + Text.from_markup(format_action_legend_line("Unban IP", states["events-unban-ip"])), + Text.from_markup(format_action_legend_line("Export JSON", states["events-export"], "E")), ] - panel.update("\n".join(lines)) + + panel.update( + Group( + title, + context_table, + status_line_1, + status_line_2, + Text(""), + Text.from_markup(styled_separator()), + Text.from_markup(f"[{PALETTE['very_dim']}]AVAILABLE ACTIONS[/]"), + Text.from_markup(f"[{PALETTE['very_dim']}]● ready ○ unavailable (hover buttons for detail)[/]"), + Text(""), + *action_lines, + ) + ) def _has_filter(self) -> bool: return any( diff --git a/tests/test_tui.py b/tests/test_tui.py index f532b68..b4776b9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -11,6 +11,10 @@ HAS_TEXTUAL = importlib.util.find_spec("textual") is not None if HAS_TEXTUAL: + import io + from rich.console import Console + from textual.layouts.vertical import VerticalLayout + from textual.containers import VerticalScroll from textual.widgets import Button, ContentSwitcher, DataTable, Input, Select, Static from adiuvare.tui.app import AdiuvareApp @@ -262,6 +266,56 @@ async def test_events_filter_reduces_rows(app): assert table.row_count == 1 +@pytest.mark.asyncio +async def test_events_screen_layout_has_sidebar_sections(app): + async with app.run_test() as pilot: + await pilot.press("2") + assert app.query_one("#events-header-notice", Static) + assert app.query_one("#events-body") + assert app.query_one("#events-right-col") + assert app.query_one("#events-detail-panel", VerticalScroll) + assert app.query_one("#events-detail-text", Static) + assert app.query_one("#events-context-panel", VerticalScroll) + assert app.query_one("#events-context-text", Static) + + +@pytest.mark.asyncio +async def test_events_detail_elides_long_endpoint(app): + long_endpoint = "GET /this/is/a/very/long/path/that/should/not/blow/out/the/detail/panel/width?with=query¶ms=true" + app.audit.write( + AdiuvareEvent( + identity="user:long-endpoint", + endpoint=long_endpoint, + score=0.73, + verdict="flag", + breakdown={"payload": 0.73}, + detail={"ip": "203.0.113.8"}, + ) + ) + async with app.run_test() as pilot: + await pilot.press("2") + await pilot.pause() + detail = app.query_one("#events-detail-text", Static) + rendered = detail.render()._renderable + console = Console(width=48, record=True, color_system=None, force_terminal=False, file=io.StringIO()) + console.print(rendered) + exported = console.export_text() + assert "Endpoint" in exported + assert "…" in exported + + +@pytest.mark.asyncio +async def test_events_layout_stacks_on_narrow_terminal(app): + async with app.run_test(size=(60, 24)) as pilot: + await pilot.press("2") + await pilot.pause() + body = app.query_one("#events-body") + right_col = app.query_one("#events-right-col") + assert isinstance(body.styles.layout, VerticalLayout) + # Sidebar should stop enforcing a minimum width on narrow terminals. + assert int(right_col.styles.min_width.value) == 0 + + @pytest.mark.asyncio async def test_events_disabled_actions_show_reasons(app): app.audit.write(