feat: add version updater and statistics enhancements (fixes #29)

- Implemented version checking system with update notifications
- Integrated Git for fetching and downloading the latest version
- Added statistics updates
- Removed unused code across the project
- Condensed project structure
- Updated README
- Cleaned up UI
This commit is contained in:
2025-12-19 16:31:36 -05:00
parent 09d2c125cd
commit e1e0cb7ac7
26 changed files with 1906 additions and 3806 deletions
+464 -150
View File
@@ -30,10 +30,10 @@ from typing import Optional
from bson import ObjectId
import dotenv
import plotext as plt
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
Button,
@@ -51,7 +51,6 @@ import airlock_libs
from models.agent import Agent
from models.policy import Policy
from services.API import AirlockAPIWrapper
from services.security import getAPI
from TUI.Screens.executionhistoryscreen import ExecutionHistoryScreen
from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen
from TUI.Screens.otpactivityscreen import OTPActivitiesScreen
@@ -59,19 +58,19 @@ from TUI.Screens.otprevokescreen import OTPRevokeScreen
from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen
from TUI.Screens.policyprepworkflowscreen import PolicyPrepWorkflowScreen
from TUI.Screens.quietagentworkflowscreen import QuietAgentWorkflowScreen
from TUI.Themes.theme_amber_terminal import get_amber_terminal_theme
from TUI.Themes.theme_retro_terminal import get_retro_terminal_theme
from TUI.Themes.themeselector import ThemeSelector
from TUI.Widgets.agentmoveoperations import AgentMoveOperations
from TUI.Widgets.multiagentselector import MultiAgentSelector
from TUI.Widgets.policytreewidget import PolicyTreeWidget
from TUI.Widgets.resultsdisplay import ResultsDisplay
from TUI.Widgets.serverlogwidget import ServerLogWidget
from TUI.Widgets.settingswidget import SettingsWidget
from utils.configmanager import (
get_system_value,
get_user_value,
load_env,
save_user_config,
)
from utils.security import getAPI
from utils.setup import get_base_directory, setup
from utils.utils import irtang, open_directory
@@ -87,6 +86,81 @@ _APP_RESTART_REASON = None
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helper for plotext charts
# ---------------------------------------------------------------------------
def create_plotext_chart(
chart_type: str,
labels: list,
values: list,
height: int = 10,
width: int = 60,
title: str = "",
color: str = None,
) -> Static:
"""Create a plotext chart and return it as a Static widget with the rendered output."""
# Completely reset plotext state
plt.clear_data()
plt.clear_figure()
plt.clear_color()
plt.clear_terminal()
plt.clf()
# Set theme and canvas size
plt.theme("clear")
plt.plotsize(width, height)
if chart_type == "simple_bar":
# Simple horizontal bar - clean text-based look
if color:
plt.simple_bar(labels, values, width=width, color=color)
else:
plt.simple_bar(labels, values, width=width)
elif chart_type == "bar_h":
# Calculate integer ticks for bar charts
max_val = max(values) if values else 0
if max_val <= 5:
ticks = list(range(0, max_val + 1))
elif max_val <= 10:
ticks = list(range(0, max_val + 1, 2))
else:
step = max(1, int(max_val / 5))
ticks = list(range(0, max_val + step, step))
plt.bar(labels, values, orientation="h", width=0.5)
plt.xlabel("Count")
plt.xticks(ticks)
plt.grid(False, False)
elif chart_type == "bar_v":
max_val = max(values) if values else 0
if max_val <= 5:
ticks = list(range(0, max_val + 1))
elif max_val <= 10:
ticks = list(range(0, max_val + 1, 2))
else:
step = max(1, int(max_val / 5))
ticks = list(range(0, max_val + step, step))
plt.bar(labels, values, orientation="v", width=0.5)
plt.ylabel("Count")
plt.yticks(ticks)
plt.grid(False, False)
if title:
plt.title(title)
# Use build() to get the chart string
chart_output = plt.build()
# Clear state after building
plt.clf()
plt.clear_data()
# Create Static widget with the chart - disable selection
chart_widget = Static(chart_output, classes="chart_display")
chart_widget.can_focus = False
return chart_widget
# ---------------------------------------------------------------------------
# helper to persist TEXTUAL_THEME to *user* config and mirror to .env
# ---------------------------------------------------------------------------
@@ -110,31 +184,30 @@ def _persist_user_theme(theme_name: str) -> None:
# ---------------------------------------------------------------------------
class MainMenuScreen(Screen):
api: AirlockAPIWrapper
current_tab = reactive("")
BUTTON_DEFS = {
"agent_actions": [
{
"label": "🖥️ - Multi-Agent Operations",
"label": "🔧 - Multi-Agent Operations",
"id": "move_agent_workflow_button",
"description": "Select agents to: Move policies, Generate OTPs, Toggle audit/enforcement, View history, Export data",
"description": "Select agents to:\n Move policies\n Generate OTPs\n Toggle audit/enforcement\n View history\n Export data",
},
{
"label": "🎫 - Review and approve OTP Activities",
"label": "🎫 - Review and approve OTP Activities",
"id": "otp_activities_button",
},
{
"label": "🛑 - Revoke Active OTP Session",
"label": "🛑 - Revoke Active OTP Session",
"id": "otp_revoke_button",
},
],
"policy": [
{
"label": "⚖️ - Prepare Policy For Enforcement",
"label": "⚙️ - Prepare Policy For Enforcement",
"id": "policy_prep_button",
},
{
"label": "🔕 - Find and Move Quiet Hosts to Enforcement",
"label": "🔍 - Find and Move Quiet Hosts to Enforcement",
"id": "find_quiet_button",
},
],
@@ -154,35 +227,81 @@ class MainMenuScreen(Screen):
def _make_buttons_for(self, tab_id: str) -> Vertical:
defs = self.BUTTON_DEFS.get(tab_id, [])
# Special layout for agent_actions tab
if tab_id == "agent_actions":
left_col = Vertical()
left_col.styles.width = "1fr"
right_col = Vertical()
right_col.styles.width = "1fr"
for item in defs:
if isinstance(item, dict):
label = item["label"]
btn_id = item["id"]
description = item.get("description")
else:
label, btn_id = item
description = None
btn = Button(label, id=btn_id)
btn.styles.width = "100%"
btn.styles.margin = (0, 1, 1, 0)
btn_container = Vertical()
btn_container.styles.height = "auto"
btn_container.compose_add_child(btn)
if description:
desc_text = Static(description, classes="button_description")
desc_text.styles.width = "100%"
desc_text.styles.color = "ansi_bright_black"
desc_text.styles.text_align = "left"
desc_text.styles.margin = (0, 0, 1, 0)
btn_container.compose_add_child(desc_text)
# Multi-Agent on left, OTP buttons on right
if "otp" in btn_id:
right_col.compose_add_child(btn_container)
else:
left_col.compose_add_child(btn_container)
row = Horizontal()
row.styles.width = "100%"
row.styles.height = "auto"
row.compose_add_child(left_col)
row.compose_add_child(right_col)
return Vertical(row)
# Default single-column layout for other tabs
widgets = []
for item in defs:
# Support both old tuple format and new dict format
if isinstance(item, dict):
label = item["label"]
btn_id = item["id"]
description = item.get("description")
else:
# Old tuple format: (label, id)
label, btn_id = item
description = None
btn = Button(label, id=btn_id)
btn.styles.width = "100%"
btn.styles.margin = (0, 1, 1, 0)
widgets.append(btn)
# Add description text if provided
if description:
desc_text = Static(description, classes="button_description")
desc_text.styles.width = "100%"
desc_text.styles.color = "ansi_bright_black"
desc_text.styles.text_align = "center"
desc_text.styles.text_align = "left"
desc_text.styles.margin = (0, 0, 1, 0)
widgets.append(desc_text)
return Vertical(*widgets)
def compose(self) -> ComposeResult:
yield Header(show_clock=True, icon="âš™")
yield Header(show_clock=True, icon="")
tabs = [
Tab("Agents", id="agent_actions"),
@@ -203,6 +322,50 @@ class MainMenuScreen(Screen):
def on_mount(self) -> None:
self.switch_tab("agent_actions")
# Delay version check to ensure UI is fully ready for notifications
if hasattr(self.app, "_version_checker"):
self.set_timer(1.0, self._do_version_check)
def _do_version_check(self) -> None:
"""Perform version check and show notification if update available."""
logger.info("_do_version_check called")
if not hasattr(self.app, "_version_checker"):
logger.warning(
"No _version_checker on app - was create_update_notifier called?"
)
return
checker = self.app._version_checker
logger.info(f"Checker exists: {checker}")
logger.info("Calling check_now(force=True)...")
try:
result = checker.check_now(force=True)
logger.info(
f"Version check result: update_available={result.update_available}, latest={result.latest_version}, current={result.current_version}, error={result.error}"
)
if result.error:
logger.warning(f"Version check returned error: {result.error}")
return
if result.update_available:
# Always nag on startup - don't check dismissed version here
# User can dismiss from settings but we still want startup reminder
logger.info(f"Showing toast for update {result.latest_version}")
msg = f"🆕 Update available: {result.latest_version}\nGo to Settings to download"
self.app.notify(
msg, title="Loxide Update Available", severity="warning", timeout=15
)
logger.info("Toast notification called")
else:
logger.info(
f"No update available - current {result.current_version} is latest"
)
except Exception as e:
logger.error(f"Version check failed: {e}", exc_info=True)
def on_key(self, event) -> None:
"""Handle up/down arrow keys for button navigation."""
if event.key == "down":
@@ -252,7 +415,6 @@ class MainMenuScreen(Screen):
buttons[new_index].focus()
def switch_tab(self, tab_id: str) -> None:
self.current_tab = tab_id
content = self.query_one("#content", Vertical)
content.remove_children()
@@ -267,14 +429,13 @@ class MainMenuScreen(Screen):
elif tab_id == "statistics":
content.mount(self._create_statistics_widget())
elif tab_id == "settings":
content.mount(ThemeSelector())
content.mount(SettingsWidget())
else:
content.mount(Static(f"Unknown tab: {tab_id}"))
def _create_statistics_widget(self) -> Vertical:
"""Create the enhanced statistics display widget with time period selection."""
stats_container = Vertical(id="statistics_container")
# Build all widgets first, then mount them all at once
widgets_to_mount = []
@@ -306,41 +467,56 @@ class MainMenuScreen(Screen):
)
time_select.styles.width = 20
# Refresh button
refresh_btn = Button("🔄 Refresh", id="stats_refresh_btn", variant="primary")
refresh_btn.styles.margin = (0, 0, 0, 2)
# Fetch/Refresh button - starts as "Fetch"
if self.current_stats_days in self.statistics_cache:
button_label = "🔍„ Refresh"
else:
button_label = "📊 Fetch Statistics"
fetch_btn = Button(button_label, id="stats_refresh_btn", variant="primary")
fetch_btn.styles.margin = (0, 0, 0, 2)
# Compose controls
controls.compose_add_child(time_label)
controls.compose_add_child(time_select)
controls.compose_add_child(refresh_btn)
controls.compose_add_child(fetch_btn)
widgets_to_mount.append(controls)
# Timestamp and status area
status_container = Vertical(id="stats_status_area")
status_container.styles.margin = (0, 2, 1, 2)
status_container.styles.margin = (0, 2, 0, 2)
widgets_to_mount.append(status_container)
# Data display area - using horizontal layout
data_container = Horizontal(id="stats_data_area")
# Data display area - scrollable vertical layout
data_container = VerticalScroll(id="stats_data_area")
data_container.styles.margin = (0, 2, 0, 2)
data_container.styles.height = "auto"
widgets_to_mount.append(data_container)
# Compose all widgets into the container
for widget in widgets_to_mount:
stats_container.compose_add_child(widget)
# Schedule data fetch after the widget is mounted
if self.current_stats_days not in self.statistics_cache:
self.call_after_refresh(
lambda: self._fetch_execution_statistics(self.current_stats_days)
)
else:
# If cached, display it. Otherwise show prompt to fetch
if self.current_stats_days in self.statistics_cache:
self.call_after_refresh(self._update_statistics_display)
else:
self.call_after_refresh(self._show_fetch_prompt)
return stats_container
def _show_fetch_prompt(self) -> None:
"""Show a prompt to click Fetch to load statistics."""
try:
status_area = self.query_one("#stats_status_area", Vertical)
status_area.remove_children()
prompt = Static("Click 'Fetch Statistics' to load execution data.")
prompt.styles.text_style = "italic"
prompt.styles.text_align = "center"
status_area.mount(prompt)
except Exception as e:
logger.warning(f"Could not show fetch prompt: {e}")
def _skipback(self, days: int) -> ObjectId:
"""Generate a MongoDB ObjectId for a given number of days ago."""
date_days_ago = datetime.now(UTC) - timedelta(days=days)
@@ -372,10 +548,9 @@ class MainMenuScreen(Screen):
)
logger.debug(f"Calling with exec_types={exec_types}, days={days}")
# Use pull_policy_exec_histories - must pass all 4 args in order: (api, policy_name, type, days)
# Empty string for policy_name means all policies
# Use pull_policy_exec_histories - pass None for policy_name to get all policies
execs = airlock_libs.pull_policy_exec_histories(
self.app.api, None, str(exec_types), days # Empty string = all policies
self.app.api, None, str(exec_types), days # None = all policies
)
logger.debug(f"Received response, length: {len(execs) if execs else 0}")
@@ -449,24 +624,41 @@ class MainMenuScreen(Screen):
18: "Trusted Browser Metadata Execution",
}
# Categorize execution types
# "Untrusted" types: contain "Untrusted" in name
untrusted_types = {2, 3, 13, 14}
# "Block" types: contain "Block" in name (Blocked, Blocklist)
block_types = {1, 6, 7, 12, 15, 16}
# Count execution types
type_counter = Counter()
policy_counter = Counter()
hostname_counter = Counter()
# Separate counters for untrusted vs block
policy_counter_untrusted = Counter()
policy_counter_block = Counter()
hostname_counter_untrusted = Counter()
hostname_counter_block = Counter()
for exec_record in executions:
exec_type = exec_record.get("type", -1)
type_counter[exec_type] += 1
policy_name = exec_record.get("policyname", "Unknown")
policy_counter[policy_name] += 1
hostname = exec_record.get("hostname", "Unknown")
hostname_counter[hostname] += 1
# Get top 5 policies and hostnames
top_policies = policy_counter.most_common(5)
top_hostnames = hostname_counter.most_common(5)
# Categorize by type
if exec_type in untrusted_types:
policy_counter_untrusted[policy_name] += 1
hostname_counter_untrusted[hostname] += 1
elif exec_type in block_types:
policy_counter_block[policy_name] += 1
hostname_counter_block[hostname] += 1
# Get top 5 for each category
top_policies_untrusted = policy_counter_untrusted.most_common(5)
top_policies_block = policy_counter_block.most_common(5)
top_hostnames_untrusted = hostname_counter_untrusted.most_common(5)
top_hostnames_block = hostname_counter_block.most_common(5)
# Format execution type counts
type_counts = []
@@ -477,22 +669,36 @@ class MainMenuScreen(Screen):
return {
"total_executions": len(executions),
"type_counts": type_counts,
"top_policies": top_policies,
"top_hostnames": top_hostnames,
"top_policies_untrusted": top_policies_untrusted,
"top_policies_block": top_policies_block,
"top_hostnames_untrusted": top_hostnames_untrusted,
"top_hostnames_block": top_hostnames_block,
}
def _update_statistics_display(self) -> None:
"""Update the statistics display with cached data using horizontal layout."""
"""Update the statistics display with cached data using vertical scrollable layout."""
try:
data_area = self.query_one("#stats_data_area", Horizontal)
data_area = self.query_one("#stats_data_area", VerticalScroll)
status_area = self.query_one("#stats_status_area", Vertical)
except Exception as e:
logger.warning(f"Could not find statistics display areas: {e}")
return
# Clear any existing plotext state
plt.clear_data()
plt.clear_figure()
data_area.remove_children()
status_area.remove_children()
# Get theme color for charts
theme_name = (
self.app._textual_theme
if hasattr(self.app, "_textual_theme")
else "textual-dark"
)
chart_color = None # use plotext default
# Get cached data
cache_entry = self.statistics_cache.get(self.current_stats_days)
if not cache_entry:
@@ -511,109 +717,187 @@ class MainMenuScreen(Screen):
timestamp_label.styles.margin = (0, 0, 1, 0)
status_area.mount(timestamp_label)
# Create three columns for horizontal layout
# Column 1: Summary and Execution Types
col1 = Vertical()
col1.styles.width = "1fr"
col1.styles.padding = (0, 1, 0, 0)
# Create single scrollable column for better chart display
main_col = Vertical()
main_col.styles.width = "100%"
main_col.styles.height = "auto"
# Column 2: Top Policies
col2 = Vertical()
col2.styles.width = "1fr"
col2.styles.padding = (0, 1)
# Column 3: Top Machines and System Overview
col3 = Vertical()
col3.styles.width = "1fr"
col3.styles.padding = (0, 0, 0, 1)
# === COLUMN 1: Summary and Execution Types ===
total = Static(f"📊 Total Non-Trusted Executions: {stats['total_executions']}")
total.styles.text_style = "bold"
total.styles.margin = (0, 0, 1, 0)
col1.compose_add_child(total)
# Execution type breakdown
if stats["type_counts"]:
type_header = Static("📋 Execution Types")
type_header.styles.text_style = "bold"
type_header.styles.margin = (1, 0, 0, 0)
col1.compose_add_child(type_header)
for type_name, count in stats["type_counts"]:
# Abbreviate long type names
short_name = type_name.replace("Execution", "Exec").replace(
"[Audit]", "[Aud]"
)
type_line = Static(f" {short_name}: {count}")
type_line.styles.margin = (0, 0, 0, 1)
col1.compose_add_child(type_line)
# === COLUMN 2: Top Policies ===
if stats["top_policies"]:
policy_header = Static("⚖️ Top 5 Policies")
policy_header.styles.text_style = "bold"
policy_header.styles.margin = (0, 0, 0, 0)
col2.compose_add_child(policy_header)
for i, (policy_name, count) in enumerate(stats["top_policies"], 1):
policy_line = Static(f"{i}. {policy_name}: {count}")
policy_line.styles.margin = (0, 0, 0, 1)
col2.compose_add_child(policy_line)
# === COLUMN 3: Top Machines and System Overview ===
if stats["top_hostnames"]:
host_header = Static("🖥️ Top 5 Machines")
host_header.styles.text_style = "bold"
host_header.styles.margin = (0, 0, 0, 0)
col3.compose_add_child(host_header)
for i, (hostname, count) in enumerate(stats["top_hostnames"], 1):
host_line = Static(f"{i}. {hostname}: {count}")
host_line.styles.margin = (0, 0, 0, 1)
col3.compose_add_child(host_line)
# System Overview in column 3
# === SYSTEM OVERVIEW (First) ===
total_agents = len(self.app.devices) if self.app.devices else 0
total_policies = len(self.app.policies) if self.app.policies else 0
if self.app.devices:
enforced_agents = sum(
1 for agent in self.app.devices if getattr(agent, "enforcement", False)
)
audit_agents = sum(
1
for agent in self.app.devices
if not getattr(agent, "enforcement", False)
)
# status: 0=Offline, 1=Online, 2=Hidden, 3=Safemode
online_agents = sum(
1 for agent in self.app.devices if getattr(agent, "online", False)
1 for agent in self.app.devices if getattr(agent, "status", 0) == 1
)
offline_agents = sum(
1 for agent in self.app.devices if getattr(agent, "status", 0) == 0
)
hidden_agents = sum(
1 for agent in self.app.devices if getattr(agent, "status", 0) == 2
)
safemode_agents = sum(
1 for agent in self.app.devices if getattr(agent, "status", 0) == 3
)
offline_agents = total_agents - online_agents
else:
enforced_agents = audit_agents = online_agents = offline_agents = 0
online_agents = offline_agents = hidden_agents = safemode_agents = 0
system_header = Static("️ System Overview")
system_header.styles.text_style = "bold"
system_header.styles.margin = (2, 0, 0, 0)
col3.compose_add_child(system_header)
system_header.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(system_header)
system_data = [
f"Agents: {total_agents} ({online_agents} online)",
f"Policies: {total_policies}",
f"Enforced: {enforced_agents}",
f"Audit: {audit_agents}",
# Total counts
totals_line = Static(
f"Total Agents: {total_agents} Total Policies: {total_policies}"
)
totals_line.styles.margin = (0, 0, 1, 1)
main_col.compose_add_child(totals_line)
# Agent status with plotext bar chart and percentages
status_data = [
("Online", online_agents),
("Offline", offline_agents),
("Hidden", hidden_agents),
("Safemode", safemode_agents),
]
for line in system_data:
line_widget = Static(f" {line}")
line_widget.styles.margin = (0, 0, 0, 1)
col3.compose_add_child(line_widget)
labels = []
counts = []
for status_name, count in status_data:
percentage = int((count / total_agents) * 100) if total_agents > 0 else 0
labels.append(f"{status_name} ({percentage}%)")
counts.append(count)
# Mount all columns
data_area.mount(col1)
data_area.mount(col2)
data_area.mount(col3)
chart = create_plotext_chart(
"simple_bar", labels, counts, height=15, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# === EXECUTION SUMMARY ===
total_execs = stats["total_executions"]
total = Static(f"📊 Total Untrusted Executions: {total_execs}")
total.styles.text_style = "bold"
total.styles.margin = (1, 0, 1, 0)
main_col.compose_add_child(total)
# Execution type breakdown with percentages
if stats["type_counts"]:
type_header = Static("📋 Execution Types")
type_header.styles.text_style = "bold"
type_header.styles.margin = (1, 0, 0, 0)
main_col.compose_add_child(type_header)
labels = []
counts = []
for type_name, count in stats["type_counts"]:
short_name = type_name.replace("Execution", "Exec").replace(
"[Audit]", "[Aud]"
)
if len(short_name) > 18:
short_name = short_name[:15] + "..."
percentage = int((count / total_execs) * 100) if total_execs > 0 else 0
labels.append(f"{short_name} ({percentage}%)")
counts.append(count)
chart = create_plotext_chart(
"simple_bar", labels, counts, height=15, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# === TOP POLICIES - Untrusted ===
if stats["top_policies_untrusted"]:
policy_header = Static("⚖️ Most Active Policies (Untrusted)")
policy_header.styles.text_style = "bold"
policy_header.styles.margin = (1, 0, 0, 0)
main_col.compose_add_child(policy_header)
policy_total = sum(count for _, count in stats["top_policies_untrusted"])
labels = []
values = []
for policy_name, count in stats["top_policies_untrusted"]:
short_name = policy_name[:18] if len(policy_name) > 18 else policy_name
percentage = (
int((count / policy_total) * 100) if policy_total > 0 else 0
)
labels.append(f"{short_name} ({percentage}%)")
values.append(count)
chart = create_plotext_chart(
"simple_bar", labels, values, height=12, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# === TOP POLICIES - Blocked ===
if stats["top_policies_block"]:
policy_header = Static("⚖️ Most Active Policies (Blocked)")
policy_header.styles.text_style = "bold"
policy_header.styles.margin = (1, 0, 0, 0)
main_col.compose_add_child(policy_header)
policy_total = sum(count for _, count in stats["top_policies_block"])
labels = []
values = []
for policy_name, count in stats["top_policies_block"]:
short_name = policy_name[:18] if len(policy_name) > 18 else policy_name
percentage = (
int((count / policy_total) * 100) if policy_total > 0 else 0
)
labels.append(f"{short_name} ({percentage}%)")
values.append(count)
chart = create_plotext_chart(
"simple_bar", labels, values, height=12, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# === TOP MACHINES - Untrusted ===
if stats["top_hostnames_untrusted"]:
host_header = Static("🖥️ Most Active Machines (Untrusted)")
host_header.styles.text_style = "bold"
host_header.styles.margin = (1, 0, 0, 0)
main_col.compose_add_child(host_header)
host_total = sum(count for _, count in stats["top_hostnames_untrusted"])
labels = []
values = []
for hostname, count in stats["top_hostnames_untrusted"]:
short_name = hostname[:18] if len(hostname) > 18 else hostname
percentage = int((count / host_total) * 100) if host_total > 0 else 0
labels.append(f"{short_name} ({percentage}%)")
values.append(count)
chart = create_plotext_chart(
"simple_bar", labels, values, height=12, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# === TOP MACHINES - Blocked ===
if stats["top_hostnames_block"]:
host_header = Static("🖥️ Most Active Machines (Blocked)")
host_header.styles.text_style = "bold"
host_header.styles.margin = (1, 0, 0, 0)
main_col.compose_add_child(host_header)
host_total = sum(count for _, count in stats["top_hostnames_block"])
labels = []
values = []
for hostname, count in stats["top_hostnames_block"]:
short_name = hostname[:18] if len(hostname) > 18 else hostname
percentage = int((count / host_total) * 100) if host_total > 0 else 0
labels.append(f"{short_name} ({percentage}%)")
values.append(count)
chart = create_plotext_chart(
"simple_bar", labels, values, height=12, width=60, color=chart_color
)
chart.styles.margin = (0, 0, 1, 0)
main_col.compose_add_child(chart)
# Mount the main scrollable column
data_area.mount(main_col)
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
self.switch_tab(event.tab.id)
@@ -625,13 +909,23 @@ class MainMenuScreen(Screen):
if new_days != self.current_stats_days:
self.current_stats_days = new_days
# Fetch if not cached
if new_days not in self.statistics_cache:
self._fetch_execution_statistics(new_days)
else:
self._update_statistics_display()
# Update button label based on cache status
try:
button = self.query_one("#stats_refresh_btn", Button)
if new_days in self.statistics_cache:
button.label = "🔍„ Refresh"
# Display cached data
self._update_statistics_display()
else:
button.label = "📊 Fetch Statistics"
# Show fetch prompt
self._show_fetch_prompt()
except Exception as e:
logger.warning(f"Could not update button or display: {e}")
def on_multi_agent_selector_agents_selected() -> None:
def on_multi_agent_selector_agents_selected(
self, message: MultiAgentSelector.AgentsSelected
) -> None:
"""Handle selected agents from AgentSelector."""
global _APP_RESTART_REASON
selected_agents = message.selected_agents
@@ -640,10 +934,10 @@ class MainMenuScreen(Screen):
_APP_RESTART_REASON = ("multi_agent_action", selected_agents)
self.app.exit()
def on_theme_selector_theme_selected(
self, message: ThemeSelector.ThemeSelected
def on_settings_widget_theme_selected(
self, message: SettingsWidget.ThemeSelected
) -> None:
"""Handle theme selection from ThemeSelector."""
"""Handle theme selection from SettingsWidget."""
global _APP_RESTART_REASON
_persist_user_theme(message.theme_name)
_APP_RESTART_REASON = ("restart",)
@@ -713,7 +1007,7 @@ class MainMenuScreen(Screen):
logger.info("Toggling enforcement for device: %s", message.device.hostname)
try:
from services.agenthandler import moveAgentToRelatedPolicy
from TUI.Widgets.agentmoveoperations import moveAgentToRelatedPolicy
from utils.configmanager import get_system_json
policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
@@ -768,6 +1062,12 @@ class MainMenuScreen(Screen):
# Handle statistics refresh button
if button_id == "stats_refresh_btn":
self._fetch_execution_statistics(self.current_stats_days)
# Update button label to "Refresh" after first fetch
try:
button = event.button
button.label = "🔍„ Refresh"
except Exception as e:
logger.warning(f"Could not update button label: {e}")
event.stop()
return
@@ -826,6 +1126,22 @@ class Loxide(App[Message]):
content-align: center middle;
text-align: center;
}
.chart_display {
overflow: hidden;
}
.chart_display:hover {
background: transparent;
}
#statistics_container {
height: 1fr;
}
#stats_status_area {
height: auto;
}
#stats_data_area {
height: 1fr;
scrollbar-gutter: stable;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
@@ -867,8 +1183,6 @@ class Loxide(App[Message]):
self.devices = None
def on_mount(self, api: AirlockAPIWrapper) -> None:
self.register_theme(get_retro_terminal_theme())
self.register_theme(get_amber_terminal_theme())
self.theme = self._textual_theme
self.push_screen(MainMenuScreen())