RustImplementation #49

Merged
mysticmomba merged 33 commits from RustImplementation into master 2025-12-17 14:19:20 -05:00
3 changed files with 889 additions and 104 deletions
Showing only changes of commit a7b659c951 - Show all commits
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/>.
import difflib
from pathlib import Path
import re
from typing import List, Optional
@@ -57,7 +58,7 @@ class MultiAgentSelector(Widget):
def compose(self):
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)
yield title_text
@@ -77,7 +78,7 @@ class MultiAgentSelector(Widget):
text_area.styles.overflow_y = "auto"
yield text_area
with Horizontal(id="switch_search_container"):
with Horizontal(id="switch_container"):
switch = Switch(value=False, id="match_switch")
switch.styles.width = "auto"
switch.styles.margin = (1, 0, 0, 0)
@@ -89,8 +90,13 @@ class MultiAgentSelector(Widget):
switch_label.styles.margin = (2, 1, 0, 0)
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.styles.margin = (1, 0, 0, 0)
search.styles.margin = (1, 0, 0, 1)
yield search
with Horizontal() as select_buttons:
@@ -152,6 +158,9 @@ class MultiAgentSelector(Widget):
]
self.post_message(self.AgentsSelected(selected_agents))
event.stop()
elif btn_id == "load_file_button":
self._load_from_file()
event.stop()
elif btn_id == "search_button":
self.update_matches()
event.stop()
@@ -166,7 +175,7 @@ class MultiAgentSelector(Widget):
match_list.add_option((name, name))
unmatched_label = self.query_one("#unmatched_label", Static)
if unmatched:
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
else:
unmatched_label.update("")
@@ -214,3 +223,118 @@ class MultiAgentSelector(Widget):
else:
unmatched.append(name)
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 textual.app import ComposeResult
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
@@ -67,6 +67,19 @@ class ServerLogWidget(Vertical):
height: auto;
layout: horizontal;
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 {
@@ -77,11 +90,15 @@ class ServerLogWidget(Vertical):
def __init__(self, api: AirlockAPIWrapper):
super().__init__()
self.api = api
self.all_logs = [] # Store all logs for filtering
self.columns = [] # Store column names
def compose(self) -> ComposeResult:
yield Static("Loading server logs (last 72 hours)...", id="status_bar")
yield DataTable(id="server_log_table")
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")
def on_mount(self) -> None:
@@ -105,23 +122,28 @@ class ServerLogWidget(Vertical):
if not logs:
status.update("ℹï¸ No server logs found in the last 72 hours.")
table.clear(columns=True)
self.all_logs = []
self.columns = []
return
# Store all logs for filtering
self.all_logs = logs
# Clear existing data
table.clear(columns=True)
# Add columns based on the first log entry
if logs:
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)
# Add rows in reverse order so newest entries are at the top
for log_entry in reversed(logs):
row_data = []
for col in columns:
for col in self.columns:
value = log_entry.get(col, "")
# Format datetime column to be more readable
if col == "datetime" and value:
@@ -143,12 +165,86 @@ class ServerLogWidget(Vertical):
logger.info(f"Loaded {len(logs)} server log entries")
else:
status.update("ℹï¸ No log entries found.")
self.all_logs = []
self.columns = []
except Exception as exc:
error_msg = f"❌ Error loading server logs: {exc}"
status.update(error_msg)
logger.error(f"Failed to load server logs: {exc}", exc_info=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:
"""Handle button presses."""
@@ -156,4 +252,10 @@ class ServerLogWidget(Vertical):
if button_id == "refresh_button":
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()