Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions adiuvare/tui/replit.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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%;
Comment thread
ionfwsrijan marked this conversation as resolved.
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;
Comment thread
ionfwsrijan marked this conversation as resolved.
border: solid #21262d;
background: #161b22;
padding: 1 2;
overflow-y: auto;
}

#events-context-panel {
width: 40%;
height: 1fr;
Comment thread
ionfwsrijan marked this conversation as resolved.
border: solid #21262d;
background: #161b22;
padding: 1 2;
overflow-y: auto;
}

#events-action-bar {
Expand All @@ -363,6 +377,7 @@ Scrollbar {
content-align: left middle;
}


/* ══════════════════════════════════════════════════════════════
PAGE 3 — CONFIG
══════════════════════════════════════════════════════════════ */
Expand Down
219 changes: 160 additions & 59 deletions adiuvare/tui/screens/events.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -21,7 +26,6 @@
dominant_color,
render_score_bar,
render_signal_bar,
styled_label,
styled_separator,
)

Expand All @@ -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),
Expand All @@ -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"):
Comment thread
ionfwsrijan marked this conversation as resolved.
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"}:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading