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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user