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:
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user