Compare commits

..

3 Commits

Author SHA1 Message Date
brotoskyj 6008f7d8ca Merge pull request 'RustImplementation' (#49) from RustImplementation into master
Reviewed-on: brotoskyj/AirlockTools#49
2025-12-17 14:19:20 -05:00
Zarithas a7b659c951 policyprepworkflow: enhancements and fixes
- Added row copy functionality (Ctrl+C)
- Improved row selection visual contrast
- Fixed data state issues when navigating between stages

Server Log tab
- Added new Server Log tab to the main application
- Implemented live filtering with wildcard support
- Enabled auto-refresh capability

Multiagent selector
- Added file loading support for device lists
2025-12-17 12:38:31 -05:00
brotoskyj 5698184291 Merge pull request 'RustImplementation' (#28) from RustImplementation into master
Reviewed-on: brotoskyj/AirlockTools#28
2025-11-18 14:51:42 -05:00
3 changed files with 889 additions and 104 deletions
File diff suppressed because it is too large Load Diff
+128 -4
View File
@@ -14,6 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import difflib import difflib
from pathlib import Path
import re import re
from typing import List, Optional from typing import List, Optional
@@ -57,7 +58,7 @@ class MultiAgentSelector(Widget):
def compose(self): def compose(self):
yield Header(show_clock=True, icon="") yield Header(show_clock=True, icon="")
title_text = Static("🖥️ Agent Selector", id="selector_title") title_text = Static("🖥️ Agent Selector", id="selector_title")
title_text.styles.margin = (0, 0, 0, 1) title_text.styles.margin = (0, 0, 0, 1)
yield title_text yield title_text
@@ -77,7 +78,7 @@ class MultiAgentSelector(Widget):
text_area.styles.overflow_y = "auto" text_area.styles.overflow_y = "auto"
yield text_area yield text_area
with Horizontal(id="switch_search_container"): with Horizontal(id="switch_container"):
switch = Switch(value=False, id="match_switch") switch = Switch(value=False, id="match_switch")
switch.styles.width = "auto" switch.styles.width = "auto"
switch.styles.margin = (1, 0, 0, 0) switch.styles.margin = (1, 0, 0, 0)
@@ -89,8 +90,13 @@ class MultiAgentSelector(Widget):
switch_label.styles.margin = (2, 1, 0, 0) switch_label.styles.margin = (2, 1, 0, 0)
yield switch_label yield switch_label
with Horizontal(id="action_buttons_container"):
load_file = Button("📂 Load File", id="load_file_button")
load_file.styles.margin = (1, 1, 0, 1)
yield load_file
search = Button("🔍 Search", id="search_button") search = Button("🔍 Search", id="search_button")
search.styles.margin = (1, 0, 0, 0) search.styles.margin = (1, 0, 0, 1)
yield search yield search
with Horizontal() as select_buttons: with Horizontal() as select_buttons:
@@ -152,6 +158,9 @@ class MultiAgentSelector(Widget):
] ]
self.post_message(self.AgentsSelected(selected_agents)) self.post_message(self.AgentsSelected(selected_agents))
event.stop() event.stop()
elif btn_id == "load_file_button":
self._load_from_file()
event.stop()
elif btn_id == "search_button": elif btn_id == "search_button":
self.update_matches() self.update_matches()
event.stop() event.stop()
@@ -166,7 +175,7 @@ class MultiAgentSelector(Widget):
match_list.add_option((name, name)) match_list.add_option((name, name))
unmatched_label = self.query_one("#unmatched_label", Static) unmatched_label = self.query_one("#unmatched_label", Static)
if unmatched: if unmatched:
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}") unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
else: else:
unmatched_label.update("") unmatched_label.update("")
@@ -214,3 +223,118 @@ class MultiAgentSelector(Widget):
else: else:
unmatched.append(name) unmatched.append(name)
return sorted(matched), unmatched return sorted(matched), unmatched
def _load_from_file(self):
"""Safely load device names from a text file."""
try:
# Import here to avoid issues if tkinter isn't available
import tkinter as tk
from tkinter import filedialog
# Create file dialog
root = tk.Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="Select device list file",
filetypes=[
("Text files", "*.txt"),
("CSV files", "*.csv"),
("All files", "*.*"),
],
)
if not file_path:
# User cancelled
return
# Validate file path
path_obj = Path(file_path)
if not path_obj.exists():
self.app.notify("File does not exist", severity="error", timeout=3)
return
if not path_obj.is_file():
self.app.notify(
"Selected path is not a file", severity="error", timeout=3
)
return
# Check file size (limit to 1 MB for safety)
file_size = path_obj.stat().st_size
if file_size > 1_000_000: # 1 MB
self.app.notify(
f"File too large ({file_size:,} bytes). Maximum 1 MB.",
severity="error",
timeout=5,
)
return
# Read file with proper encoding to preserve emojis
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except UnicodeDecodeError:
# Try with different encoding if UTF-8 fails
try:
with open(file_path, "r", encoding="latin-1") as f:
content = f.read()
self.app.notify(
"File loaded with Latin-1 encoding (UTF-8 failed)",
severity="warning",
timeout=3,
)
except Exception as e:
self.app.notify(
f"Error reading file: {str(e)}", severity="error", timeout=5
)
return
# Validate and sanitize content
lines = content.split("\n")
valid_lines = []
invalid_count = 0
# Pattern for valid hostnames/device names
# Allows: letters, numbers, hyphens, underscores, periods, and Unicode chars
hostname_pattern = re.compile(r"^[\w\-\.\u0080-\uFFFF]+$", re.UNICODE)
for line in lines:
line = line.strip()
if not line:
continue # Skip empty lines
# Check if line looks like a valid hostname/device name
if hostname_pattern.match(line):
valid_lines.append(line)
else:
invalid_count += 1
# Log but don't add invalid entries
if not valid_lines:
self.app.notify(
"No valid device names found in file", severity="warning", timeout=3
)
return
# Update text area with validated content
text_area = self.query_one("#device_input", TextArea)
text_area.text = "\n".join(valid_lines)
# Show notification
msg = f"✅ Loaded {len(valid_lines)} devices from file"
if invalid_count > 0:
msg += f" ({invalid_count} invalid entries skipped)"
self.app.notify(msg, severity="information", timeout=5)
except ImportError:
self.app.notify(
"tkinter not available - cannot open file dialog",
severity="error",
timeout=3,
)
except Exception as e:
self.app.notify(
f"Error loading file: {str(e)}", severity="error", timeout=5
)
+106 -4
View File
@@ -19,7 +19,7 @@ import logging
from bson import ObjectId from bson import ObjectId
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Vertical from textual.containers import Container, Vertical
from textual.widgets import Button, DataTable, Static from textual.widgets import Button, DataTable, Input, Static
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
@@ -67,6 +67,19 @@ class ServerLogWidget(Vertical):
height: auto; height: auto;
layout: horizontal; layout: horizontal;
padding: 1; padding: 1;
align: left middle;
}
ServerLogWidget .filter_label {
width: auto;
height: 3;
content-align: left middle;
padding-right: 1;
}
ServerLogWidget #filter_input {
width: 40;
margin-right: 1;
} }
ServerLogWidget Button { ServerLogWidget Button {
@@ -77,11 +90,15 @@ class ServerLogWidget(Vertical):
def __init__(self, api: AirlockAPIWrapper): def __init__(self, api: AirlockAPIWrapper):
super().__init__() super().__init__()
self.api = api self.api = api
self.all_logs = [] # Store all logs for filtering
self.columns = [] # Store column names
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static("Loading server logs (last 72 hours)...", id="status_bar") yield Static("Loading server logs (last 72 hours)...", id="status_bar")
yield DataTable(id="server_log_table") yield DataTable(id="server_log_table")
with Container(id="button_container"): with Container(id="button_container"):
yield Static("Filter:", classes="filter_label")
yield Input(placeholder="Filter (use * and ? wildcards)", id="filter_input")
yield Button("Refresh", id="refresh_button", variant="primary") yield Button("Refresh", id="refresh_button", variant="primary")
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -105,23 +122,28 @@ class ServerLogWidget(Vertical):
if not logs: if not logs:
status.update("ℹï¸ No server logs found in the last 72 hours.") status.update("ℹï¸ No server logs found in the last 72 hours.")
table.clear(columns=True) table.clear(columns=True)
self.all_logs = []
self.columns = []
return return
# Store all logs for filtering
self.all_logs = logs
# Clear existing data # Clear existing data
table.clear(columns=True) table.clear(columns=True)
# Add columns based on the first log entry # Add columns based on the first log entry
if logs: if logs:
first_log = logs[0] first_log = logs[0]
columns = [col for col in first_log.keys() if col != "checkpoint"] self.columns = [col for col in first_log.keys() if col != "checkpoint"]
for col in columns: for col in self.columns:
table.add_column(col, key=col) table.add_column(col, key=col)
# Add rows in reverse order so newest entries are at the top # Add rows in reverse order so newest entries are at the top
for log_entry in reversed(logs): for log_entry in reversed(logs):
row_data = [] row_data = []
for col in columns: for col in self.columns:
value = log_entry.get(col, "") value = log_entry.get(col, "")
# Format datetime column to be more readable # Format datetime column to be more readable
if col == "datetime" and value: if col == "datetime" and value:
@@ -143,12 +165,86 @@ class ServerLogWidget(Vertical):
logger.info(f"Loaded {len(logs)} server log entries") logger.info(f"Loaded {len(logs)} server log entries")
else: else:
status.update("ℹï¸ No log entries found.") status.update("ℹï¸ No log entries found.")
self.all_logs = []
self.columns = []
except Exception as exc: except Exception as exc:
error_msg = f"❌ Error loading server logs: {exc}" error_msg = f"❌ Error loading server logs: {exc}"
status.update(error_msg) status.update(error_msg)
logger.error(f"Failed to load server logs: {exc}", exc_info=True) logger.error(f"Failed to load server logs: {exc}", exc_info=True)
table.clear(columns=True) table.clear(columns=True)
self.all_logs = []
self.columns = []
def filter_logs(self, filter_text: str) -> None:
"""Filter the logs based on the filter text with wildcard support."""
import fnmatch
table = self.query_one("#server_log_table", DataTable)
status = self.query_one("#status_bar", Static)
if not self.all_logs:
return
# Clear existing data
table.clear(columns=True)
# Re-add columns
for col in self.columns:
table.add_column(col, key=col)
# Filter logs
filtered_logs = []
if filter_text.strip():
filter_pattern = filter_text.strip().lower()
for log_entry in self.all_logs:
# Check if any field matches the filter pattern
match = False
for col in self.columns:
value = str(log_entry.get(col, "")).lower()
if fnmatch.fnmatch(value, filter_pattern):
match = True
break
if match:
filtered_logs.append(log_entry)
else:
# No filter, show all logs
filtered_logs = self.all_logs
# Add filtered rows in reverse order
for log_entry in reversed(filtered_logs):
row_data = []
for col in self.columns:
value = log_entry.get(col, "")
# Format datetime column to be more readable
if col == "datetime" and value:
try:
dt = datetime.datetime.fromisoformat(
str(value).replace("Z", "+00:00")
)
value = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
row_data.append(str(value))
table.add_row(*row_data)
if filter_text.strip():
status.update(
f"✅ Showing {len(filtered_logs)} of {len(self.all_logs)} log entries (filtered)"
)
else:
status.update(
f"✅ Loaded {len(self.all_logs)} log entries from the last 72 hours"
)
logger.info(
f"Filtered to {len(filtered_logs)} entries with pattern: {filter_text}"
)
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle filter input changes."""
if event.input.id == "filter_input":
self.filter_logs(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""
@@ -156,4 +252,10 @@ class ServerLogWidget(Vertical):
if button_id == "refresh_button": if button_id == "refresh_button":
self.load_logs() self.load_logs()
# Clear the filter input when refreshing
try:
filter_input = self.query_one("#filter_input", Input)
filter_input.value = ""
except Exception:
pass
event.stop() event.stop()