diff --git a/.gitea/workflows/loxide_lib.yml b/.gitea/workflows/loxide_lib.yml
index 0a21747..a8b5039 100644
--- a/.gitea/workflows/loxide_lib.yml
+++ b/.gitea/workflows/loxide_lib.yml
@@ -14,7 +14,7 @@ jobs:
- name: Install Prerequisites
run: |
apt update
- apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 -y
+ apt install curl git python3 pip pkg-config openssl libssl-dev patchelf binutils-mingw-w64-x86-64 mingw-w64 protobuf-compiler -y
curl https://sh.rustup.rs -sSf | sh -s -- -y
pip install maturin twine --break-system-packages
diff --git a/AirlockTools_Server.py b/AirlockTools_Server.py
deleted file mode 100644
index 183635c..0000000
--- a/AirlockTools_Server.py
+++ /dev/null
@@ -1,93 +0,0 @@
-# Copyright (C) 2025 James Brotosky, Brandon Wickline
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published
-# by the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-
-# TODO Add CSV injection prevention
-# TODO Continue OTP and Local approval rewrites
-# TODO Explore pywin32
-# TODO Fix Requirements.txt
-# TODO Create Generic system_config.json for gitea
-
-
-import logging
-import os
-
-import dotenv
-import urllib3
-
-import flows.localApproval as la
-from Server.scheduler_async import (
- recurring_job,
- register_function,
- reload_jobs,
- start_scheduler,
-)
-from services.API import AirlockAPIWrapper
-from services.policyhandler import updateAuditPoliciesFromEnforcementPolices
-from services.security import getAPI
-from utils.setup import setup
-
-urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-
-
-def main():
-
- # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored
-
- working_dir = setup()
-
- logger = logging.getLogger(__name__)
-
- dotenv.load_dotenv(dotenv_path=working_dir / ".env")
-
- try:
- url = os.getenv("URL")
- username = os.getenv("USERNAME")
-
- if not url:
- raise ValueError("Missing URL in environment variables.")
- if not username:
- raise ValueError("Missing USERNAME in environment variables.")
-
- logger.debug(f"Retrieved URL: {url}")
- logger.debug(f"Retrieved Username: {username}")
-
- except ValueError as e:
- logger.error(f"Configuration error: {e}", exc_info=True)
- raise
-
- api = AirlockAPIWrapper(
- base_url=str(os.getenv("URL")),
- api_key=getAPI(username, "AirlockTools"),
- )
-
- logger.info("Running non-interactively to start monitoring Airlock Changes")
-
- register_function("monitorLA", la.scheduleAddingLAHashes)
- register_function("updateAuditPolicies", updateAuditPoliciesFromEnforcementPolices)
-
- if not os.path.exists("scheduling\\jobs.json"):
- recurring_job("monitorLA", "monitorLA", interval=50, unit="seconds", args=[api])
- recurring_job(
- "updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api]
- )
- else:
- reload_jobs()
-
- start_scheduler()
-
-
-if __name__ == "__main__":
- main()
diff --git a/IRT_icon_32-512.ico b/IRT_icon_32-512.ico
deleted file mode 100644
index 103b72e..0000000
Binary files a/IRT_icon_32-512.ico and /dev/null differ
diff --git a/AirlockTools_Client.py b/Loxide.py
similarity index 81%
rename from AirlockTools_Client.py
rename to Loxide.py
index 47c9562..fae9cc8 100644
--- a/AirlockTools_Client.py
+++ b/Loxide.py
@@ -23,39 +23,28 @@
import logging
import os
-import tempfile
-import dotenv
import urllib3
from services.API import AirlockAPIWrapper
from services.security import getAPI
+from TUI.TUI import run_Loxide
+from utils.configmanager import get_system_value
from utils.setup import get_base_directory, setup
-from utils.TUI import run_Loxide
from utils.utils import irtang
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def main():
-
- if "NUITKA_ONEFILE_PARENT" in os.environ:
- splash_filename = os.path.join(
- tempfile.gettempdir(),
- f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp",
- )
- if os.path.exists(splash_filename):
- os.unlink(splash_filename)
-
irtang()
# Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored
setup()
base_dir = get_base_directory()
logger = logging.getLogger(__name__)
- dotenv.load_dotenv(dotenv_path=base_dir / ".env")
try:
- url = os.getenv("URL")
+ url = get_system_value("URL")
username = os.getenv("USERNAME")
if not url:
@@ -75,7 +64,7 @@ def main():
raise ValueError("API key for Loxide is missing.")
api = AirlockAPIWrapper(
- base_url=str(os.getenv("URL")),
+ base_url=str(url),
api_key=api_key,
)
run_Loxide(api)
diff --git a/Loxide_Icon.ico b/Loxide_Icon.ico
new file mode 100644
index 0000000..dce491f
Binary files /dev/null and b/Loxide_Icon.ico differ
diff --git a/Server/scheduler_async.py b/Server/scheduler_async.py
deleted file mode 100644
index 87e8c80..0000000
--- a/Server/scheduler_async.py
+++ /dev/null
@@ -1,215 +0,0 @@
-# Copyright (C) 2025 James Brotosky, Brandon Wickline
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published
-# by the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-import asyncio
-import json
-import logging
-import os
-from typing import Any, Callable, Dict, List
-
-logger = logging.getLogger(__name__)
-
-# Registry of functions that can be scheduled
-FUNCTION_MAP: Dict[str, Callable] = {}
-
-# Dictionary to manually track scheduled jobs by ID
-scheduled_jobs: Dict[str, asyncio.TimerHandle] = {}
-
-# Path to the JSON file for job persistence TODO - pin this to the correct place
-JOBS_FILE = os.path.join(os.getcwd(), "jobs.json")
-
-
-def register_function(name: str, func: Callable):
- """
- Register a function so it can be called by name later.
- Example:
- register_function("say_hello", say_hello)
- """
- FUNCTION_MAP[name] = func
-
-
-def load_jobs() -> List[Dict[str, Any]]:
- """
- Load jobs from the JSON file, or return [] if none exist.
- """
- if not os.path.exists(JOBS_FILE):
- return []
- with open(JOBS_FILE, "r") as f:
- return json.load(f)
-
-
-def save_jobs(jobs: List[Dict[str, Any]]):
- """
- Save jobs to the JSON file (overwrite).
- """
- with open(JOBS_FILE, "w") as f:
- json.dump(jobs, f, indent=4)
-
-
-def cancel_job(job_id: str):
- """
- Cancel a scheduled job by ID and remove it from the registry and persistence.
- """
- handle = scheduled_jobs.pop(job_id, None)
- if handle:
- handle.cancel()
- logger.info(f"Cancelled job '{job_id}'")
-
- jobs = [j for j in load_jobs() if j.get("id") != job_id]
- save_jobs(jobs)
-
-
-def run_once_job(
- job_id: str,
- func_name: str,
- delay_seconds: float,
- args=None,
- kwargs=None,
- persist=True,
-):
- """
- Schedule a job to run once after a delay (in seconds).
- """
- args = args or []
- kwargs = kwargs or {}
-
- def job_wrapper():
- func = FUNCTION_MAP.get(func_name)
- if func is None:
- logger.error(f"Function '{func_name}' is not registered.")
- return
- func(*args, **kwargs)
- cancel_job(job_id)
-
- loop = asyncio.get_event_loop()
- handle = loop.call_later(delay_seconds, job_wrapper)
- scheduled_jobs[job_id] = handle
-
- if persist:
- jobs = [j for j in load_jobs() if j.get("id") != job_id]
- jobs.append(
- {
- "id": job_id,
- "type": "once",
- "delay": delay_seconds,
- "function": func_name,
- "args": args,
- "kwargs": kwargs,
- }
- )
- save_jobs(jobs)
- logger.info(
- f"Scheduled one-time job '{job_id}' to run in {delay_seconds} seconds."
- )
-
-
-def recurring_job(
- job_id: str, func_name: str, interval: float, args=None, kwargs=None, persist=True
-):
- """
- Schedule a recurring job.
- """
- args = args or []
- kwargs = kwargs or {}
-
- def job_wrapper():
- func = FUNCTION_MAP.get(func_name)
- if func is None:
- logger.error(f"Function '{func_name}' is not registered.")
- return
- func(*args, **kwargs)
- # Reschedule the job
- handle = asyncio.get_event_loop().call_later(interval, job_wrapper)
- scheduled_jobs[job_id] = handle
-
- cancel_job(job_id)
- handle = asyncio.get_event_loop().call_later(interval, job_wrapper)
- scheduled_jobs[job_id] = handle
-
- if persist:
- jobs = [j for j in load_jobs() if j.get("id") != job_id]
- jobs.append(
- {
- "id": job_id,
- "type": "recurring",
- "interval": interval,
- "function": func_name,
- "args": args,
- "kwargs": kwargs,
- }
- )
- save_jobs(jobs)
- logger.info(f"Scheduled recurring job '{job_id}' every {interval} seconds.")
-
-
-def reload_jobs():
- """
- Reload jobs from JSON and reschedule them.
- """
- jobs = load_jobs()
- for job in jobs:
- if job["type"] == "once":
- run_once_job(
- job["id"],
- job["function"],
- job["delay"],
- job.get("args"),
- job.get("kwargs"),
- persist=False,
- )
- elif job["type"] == "recurring":
- recurring_job(
- job["id"],
- job["function"],
- job["interval"],
- job.get("args"),
- job.get("kwargs"),
- persist=False,
- )
-
-
-async def start_scheduler():
- """
- Start the asynchronous scheduler loop.
-
- This function is a placeholder to keep the event loop alive.
- Jobs are scheduled using asyncio.call_later and do not require polling.
- """
- try:
- await asyncio.Event().wait()
- except asyncio.CancelledError:
- logger.critical("Scheduler stopped.")
-
- """
- Start the asynchronous scheduler loop.
-
- This function is a placeholder for compatibility. Since we use asyncio.call_later,
- jobs are scheduled directly on the event loop and no polling is required.
-
- Usage:
- # In an async app (e.g., Textual)
- asyncio.create_task(start_scheduler())
-
- # Or in a standalone script
- async def main():
- await start_scheduler()
-
- asyncio.run(main())
- """
- try:
- while True:
- await asyncio.sleep(3600) # Sleep indefinitely; jobs run via call_later
- except asyncio.CancelledError:
- logger.critical("Scheduler stopped.")
diff --git a/widgets/OTP_generate.py b/TUI/OTP_generate.py
similarity index 94%
rename from widgets/OTP_generate.py
rename to TUI/OTP_generate.py
index b35b39d..4ca7977 100644
--- a/widgets/OTP_generate.py
+++ b/TUI/OTP_generate.py
@@ -1,12 +1,21 @@
import logging
-from typing import List
+from typing import List, Optional
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
-from textual.widgets import Button, Input, RadioButton, RadioSet, Static, TextArea
+from textual.widgets import (
+ Button,
+ Footer,
+ Header,
+ Input,
+ RadioButton,
+ RadioSet,
+ Static,
+ TextArea,
+)
from models.agent import Agent
@@ -22,7 +31,11 @@ class OTPGenerator(Widget):
class OTPInfo(Message):
def __init__(
- self, devices: List[Agent], requestor: str, reasoning: str, duration: int
+ self,
+ devices: Optional[List[Agent]],
+ requestor: str,
+ reasoning: str,
+ duration: int,
):
super().__init__()
self.devices = devices
@@ -70,6 +83,7 @@ class OTPGenerator(Widget):
pass
def compose(self):
+ yield Header(show_clock=True, icon="⚙")
title_text = Static(
f"🎫 Generate One Time Passes for {len(self.devices)} device(s)",
id="otpgen_title",
@@ -165,6 +179,7 @@ class OTPGenerator(Widget):
copy_button.styles.margin = (1, 0, 0, 0)
copy_button.styles.display = "none"
yield copy_button
+ yield Footer()
def on_mount(self) -> None:
"""Set initial button state."""
@@ -183,7 +198,10 @@ class OTPGenerator(Widget):
btn_id = event.button.id
if btn_id == "back_button":
- self.app.pop_screen()
+
+ while len(self.app.screen_stack) > 2:
+ self.app.pop_screen()
+
event.stop()
elif btn_id == "copy_clipboard_button":
@@ -229,12 +247,11 @@ class OTPGenerator(Widget):
self.otp_generated = True
# Access API from the app - this is the key change!
- api = self.app.api
+ api = self.app.api # type: ignore
output_lines = [
- "=" * 60,
- "OTP GENERATION RESULTS",
- "=" * 60,
+ "Requested OTP Codes:",
+ "=" * 25,
]
otp_dict = {}
@@ -250,9 +267,9 @@ class OTPGenerator(Widget):
)
for hostname, otp_code in otp_dict.items():
- output_lines.append(f"{hostname:30} | {otp_code}")
+ output_lines.append(f"{hostname} | {otp_code}")
- output_lines.append("=" * 60)
+ output_lines.append("=" * 25)
result_text = "\n".join(output_lines)
self._show_result(result_text)
diff --git a/utils/tui.py b/TUI/TUI.py
similarity index 63%
rename from utils/tui.py
rename to TUI/TUI.py
index 6eb070d..2c5d4fc 100644
--- a/utils/tui.py
+++ b/TUI/TUI.py
@@ -1,11 +1,12 @@
import logging
import os
import sys
+from typing import Optional
import dotenv
-from dotenv import set_key
from textual.app import App, ComposeResult
from textual.containers import Vertical
+from textual.message import Message
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
@@ -18,22 +19,27 @@ from textual.widgets import (
Tabs,
)
-from flows.otp import otp_activities_by_agent, otp_revoke
+from flows.otp import otp_revoke
from flows.prepPolicy import menu_policy_enforce
-from flows.quietAgent import findQuietAgents
from models.agent import Agent
from models.policy import Policy
-from screens.otpworkflowscreen import OTPWorkflowScreen
-from services.agenthandler import findAgents, moveAgents, toggleEnforcement
from services.API import AirlockAPIWrapper
from services.policyhandler import confirmUpdateAfromE
-from utils.configmanager import load_env
-from utils.setup import get_base_directory, load_user_config
+from TUI.agentmoveoperations import AgentMoveOperations
+from TUI.moveagentworkflowscreen import MoveAgentWorkflowScreen
+from TUI.multiagentselector import MultiAgentSelector
+from TUI.OTP_generate import OTPGenerator
+from TUI.otpactivityscreen import OTPActivitiesScreen
+from TUI.otpworkflowscreen import OTPWorkflowScreen
+from TUI.policytreewidget import PolicyTreeWidget
+from TUI.quietagentworkflowscreen import QuietAgentWorkflowScreen
+from TUI.resultsdisplay import ResultsDisplay
+from TUI.theme_amber_terminal import get_amber_terminal_theme
+from TUI.theme_retro_terminal import get_retro_terminal_theme
+from TUI.themeselector import ThemeSelector
+from utils.configmanager import get_user_value, load_env, save_user_config
+from utils.setup import get_base_directory
from utils.utils import open_directory
-from widgets.multiagentselector import MultiAgentSelector
-from widgets.OTP_generate import OTPGenerator
-from widgets.policytreewidget import PolicyTreeWidget
-from widgets.themeselector import ThemeSelector
dotenv.load_dotenv()
@@ -51,80 +57,45 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
def _persist_user_theme(theme_name: str) -> None:
"""
- Store the chosen Textual theme in the user's config:
- /config/user_config.json
- and also mirror to /.env so load_env(...) sees it.
+ Store the chosen Textual theme in the user's config using the config manager.
+ No need to touch .env - config manager handles everything.
"""
base_dir = get_base_directory()
config_dir = base_dir / "config"
- user_config_path = config_dir / "user_config.json"
- env_path = base_dir / ".env"
- # ensure dirs / files exist similarly to setup()
- config_dir.mkdir(parents=True, exist_ok=True)
- if not user_config_path.exists():
- # minimal default like your load_user_config does
- user_config_path.write_text(
- '{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8"
- )
-
- # load existing user config
- user_conf = load_user_config(config_dir)
- user_conf["TEXTUAL_THEME"] = theme_name
-
- # write it back
- user_config_path.write_text(
- # pretty print so it stays human-readable
- __import__("json").dumps(user_conf, indent=4),
- encoding="utf-8",
- )
- logger.debug("Updated user_config.json with TEXTUAL_THEME=%s", theme_name)
-
- # mirror to .env (like setup.write_config_to_env does)
- env_path.parent.mkdir(parents=True, exist_ok=True)
- if not env_path.exists():
- env_path.touch()
try:
- set_key(str(env_path), "TEXTUAL_THEME", theme_name)
- except Exception as exc: # keep going even if .env write fails
- logger.warning("Failed to mirror TEXTUAL_THEME to .env: %s", exc)
-
- # reload so load_env(...) sees the new value right now
- dotenv.load_dotenv(dotenv_path=env_path, override=True)
- logger.debug("Reloaded .env from %s", env_path)
+ save_user_config(config_dir, {"TEXTUAL_THEME": theme_name})
+ logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name)
+ except Exception as exc:
+ logger.error("Failed to save TEXTUAL_THEME: %s", exc)
# ---------------------------------------------------------------------------
# 1) SCREEN
# ---------------------------------------------------------------------------
class MainMenuScreen(Screen):
+ api: AirlockAPIWrapper
current_tab = reactive("")
BUTTON_DEFS = {
- "find": [
- ("🔍 - Device Search", "find_device_button"),
- ("🔇 - Find Quiet Hosts", "find_quiet_button"),
- ],
- "move": [
- ("✅ - Move to local approval", "move_local_button"),
- ("🔄 - Move to Audit/Enforcement", "move_audit_button"),
- ("🔀 - Move - Other", "move_other_button"),
- ],
- "otp": [
- ("🎫 - Generate OTPs", "otp_generate_button"),
- ("📊 - OTP Activities By Agent", "otp_activities_button"),
- ("❌ - Revoke OTPs", "otp_revoke_button"),
+ "agent_actions": [
+ (
+ "🖥️ - Find, Move, or Generate OTP for Agents",
+ "move_agent_workflow_button",
+ ),
+ ("📊 - Review and appove OTP Activities", "otp_activities_button"),
+ ("📇 - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"),
],
"policy": [
("🔒 - Prepare Policy For Enforcement", "policy_prep_button"),
("🔄 - Update Audit Policies", "policy_audit_update_button"),
+ ("❌ - Revoke OTPs", "otp_revoke_button"),
],
}
- def __init__(self, api: AirlockAPIWrapper) -> None:
+ def __init__(self) -> None:
super().__init__()
- self.api = api
- self.extras = load_env("EXTRAS")
+ self.extras = get_user_value("EXTRAS", str, "NOTTODAY")
wd = load_env("WORKING_DIR") or os.getcwd()
if not os.path.isdir(wd):
wd = os.getcwd()
@@ -143,23 +114,22 @@ class MainMenuScreen(Screen):
yield Header(show_clock=True, icon="⚙")
tabs = [
- Tab("Policy Tree", id="p_tree"),
- Tab("Device Search", id="find"),
- Tab("Move Agent", id="move"),
- Tab("OTP", id="otp"),
+ Tab("Tree View", id="p_tree"),
+ Tab("Agents", id="agent_actions"),
Tab("Directory", id="dir"),
Tab("Settings", id="settings"),
]
if self.extras == "POLICYPREP":
- tabs.insert(3, Tab("Policy Prep", id="policy"))
+ tabs.insert(2, Tab("Policy Prep", id="policy"))
yield Tabs(*tabs, id="tabs")
yield Vertical(id="content")
yield Footer()
def on_mount(self) -> None:
- self.switch_tab("find")
+ api = self.app.api
+ self.switch_tab("agent_actions")
# focus helpers
def _get_content_buttons(self) -> list[Button]:
@@ -220,7 +190,7 @@ class MainMenuScreen(Screen):
def on_multi_agent_selector_agents_selected(
self, message: MultiAgentSelector.AgentsSelected
) -> None:
- """Handle selected agents from MultiAgentSelector."""
+ """Handle selected agents from AgentSelector."""
global _PENDING_JOB
selected_agents = message.selected_agents
logger.info("Selected agents: %s", selected_agents)
@@ -241,7 +211,6 @@ class MainMenuScreen(Screen):
"""Handle OTP generation request from the workflow."""
global _PENDING_JOB
- # Log what we received
logger.info(
"OTP Generation requested: %d devices, requestor=%s, reason=%s, duration=%d",
len(message.devices),
@@ -250,7 +219,6 @@ class MainMenuScreen(Screen):
message.duration,
)
- # Set up the job to run the OTP generation
_PENDING_JOB = (
"otp_workflow",
message.devices,
@@ -261,6 +229,47 @@ class MainMenuScreen(Screen):
self.app.exit()
+ def on_agent_move_operations_operation_complete(
+ self, message: AgentMoveOperations.OperationComplete
+ ) -> None:
+ """Handle completion of agent move operation - show results."""
+ logger.info(
+ "Agent move operation completed: %s, %d successful, %d unsuccessful",
+ message.operation,
+ len(message.successful),
+ len(message.unsuccessful),
+ )
+
+ # Format results for display
+ successful_text = "\n".join(
+ [f"{agent.hostname}" for agent, _ in message.successful]
+ )
+ unsuccessful_text = "\n".join(
+ [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful]
+ )
+
+ # Remove the operations widget
+ try:
+ ops_widget = self.query_one(AgentMoveOperations)
+ ops_widget.remove()
+ except Exception:
+ pass
+
+ # Show results
+ self.query_one("#content", Vertical).mount(
+ ResultsDisplay(message.operation, successful_text, unsuccessful_text)
+ )
+
+ def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None:
+ """Handle back button from results display."""
+ try:
+ results_widget = self.query_one(ResultsDisplay)
+ results_widget.remove()
+ except Exception:
+ pass
+ # Return to main menu
+ self.app.pop_screen()
+
def on_directory_tree_file_selected(
self, event: DirectoryTree.FileSelected
) -> None:
@@ -278,47 +287,58 @@ class MainMenuScreen(Screen):
logger.debug("Button pressed: %s", button_id)
match button_id:
- case "find_device_button":
- _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {})
- case "find_quiet_button":
- _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {})
- case "move_local_button":
- _PENDING_JOB = (
- "legacy",
- print,
- ("Move to local approval (placeholder)",),
- {},
- )
- case "move_audit_button":
- _PENDING_JOB = ("legacy", toggleEnforcement, (self.app.api,), {})
- case "move_other_button":
- _PENDING_JOB = ("legacy", moveAgents, (self.app.api,), {})
+ case "move_agent_workflow_button":
+ self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices))
+ event.stop()
+
case "otp_generate_button":
- # NEW: Push OTP workflow screen instead of legacy function
self.app.push_screen(OTPWorkflowScreen(self.app.devices))
event.stop()
- return # Don't exit the app
+
+ case "find_quiet_button":
+ self.app.push_screen(
+ QuietAgentWorkflowScreen(self.app.api, self.app.policies)
+ )
+ event.stop()
+ return
+
case "otp_activities_button":
- _PENDING_JOB = ("legacy", otp_activities_by_agent, (self.app.api,), {})
+ self.app.push_screen(OTPActivitiesScreen())
+ event.stop()
+ return
+
case "otp_revoke_button":
_PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {})
+
case "policy_prep_button":
_PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {})
+
case "policy_audit_update_button":
_PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {})
+
case _:
self.app.bell()
logger.warning("Unknown button pressed: %s", button_id)
return
+ # Only exit the UI loop when we explicitly queued a legacy job.
+ # The original flow used `self.app.exit()` after setting _PENDING_JOB so
+ # the outer loop could run legacy code. Keep that behavior only for legacy jobs.
logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB)
- self.app.exit()
+ if _PENDING_JOB and _PENDING_JOB[0] == "legacy":
+ # let the main loop pick up the legacy job
+ self.app.exit()
# ---------------------------------------------------------------------------
# 2) APP
# ---------------------------------------------------------------------------
-class Loxide(App):
+class Loxide(App[Message]):
+ api: AirlockAPIWrapper
+ working_dir: str
+ policies: Optional[list[Policy]]
+ devices: Optional[list[Agent]]
+
CSS = """
#logo {
width: 100%;
@@ -326,48 +346,67 @@ class Loxide(App):
text-align: center;
}
"""
-
BINDINGS = [
("q", "quit", "Quit"),
- ("d", "open_dir", "Open Directory"),
+ ("f", "open_fe", "Launch Explorer"),
+ ("r", "refresh", "Refresh"),
]
def __init__(self, api: AirlockAPIWrapper):
- self._textual_theme = load_env("TEXTUAL_THEME") or "nord"
+ self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord")
super().__init__()
self.api = api
wd = load_env("WORKING_DIR") or os.getcwd()
if not os.path.isdir(wd):
wd = os.getcwd()
self.working_dir = wd
+ # Initial data load
+ self.refresh_data()
- # Add error handling for API calls
+ def refresh_data(self) -> None:
+ """Public method to refresh policies and devices from the API."""
try:
self.policies = [
- Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()
+ Policy(**row.to_dict())
+ for _, row in self.api.policy_find_all().iterrows()
]
self.devices = [
- Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows()
+ Agent(**row.to_dict())
+ for _, row in self.api.agent_find_all().iterrows()
]
+ if self.policies and self.devices:
+ for agent in self.devices:
+ agent.enrich_with_policies(self.policies)
+ logger.debug(
+ f"Enriched {len(self.devices)} agents with policy information"
+ )
except Exception as exc:
logger.error("Failed to load policies/devices: %s", exc)
self.policies = None
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(api))
+ self.push_screen(MainMenuScreen())
+
+ def action_refresh(self) -> None:
+ self.refresh_data()
def action_quit(self) -> None:
global _PENDING_JOB
_PENDING_JOB = None
self.exit()
- def action_open_dir(self) -> None:
- screen = self.screen_stack[-1]
- if isinstance(screen, MainMenuScreen):
- if screen.current_tab != "dir":
- screen.switch_tab("dir")
+ def action_open_fe(self) -> None:
+ """Open the working directory in the OS file manager (footer binding)."""
+ path_to_open = self.working_dir or os.getcwd()
+ try:
+ open_directory(path_to_open)
+ except Exception as exc:
+ logger.error("Failed to open directory %s: %s", path_to_open, exc)
+ self.bell() # optional feedback
# ---------------------------------------------------------------------------
@@ -380,7 +419,6 @@ def _restore_terminal_for_legacy() -> None:
sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l")
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
-
if os.name == "nt":
try:
import ctypes
@@ -397,7 +435,6 @@ def _restore_terminal_for_legacy() -> None:
def _run_legacy_job(func, args, kwargs) -> None:
logger.debug("Running legacy job: %s", getattr(func, "__name__", func))
_restore_terminal_for_legacy()
-
try:
func(*args, **kwargs)
finally:
@@ -412,24 +449,31 @@ def _run_legacy_job(func, args, kwargs) -> None:
# ---------------------------------------------------------------------------
def run_Loxide(api: AirlockAPIWrapper) -> None:
global _PENDING_JOB
+ base_dir = get_base_directory()
+ env_path = base_dir / ".env"
+ dotenv.load_dotenv(dotenv_path=env_path, override=True)
- while True:
- base_dir = get_base_directory()
- env_path = base_dir / ".env"
- dotenv.load_dotenv(dotenv_path=env_path, override=True)
+ max_attempts = 5
+ attempts = 0
+ while attempts < max_attempts:
+ attempts += 1
+ logger.debug("Starting job loop iteration (attempt %d)", attempts)
_PENDING_JOB = None
app = Loxide(api)
try:
app.run()
except SystemExit as exc:
- logger.debug("Caught SystemExit from Textual: %s", exc)
+ if exc.code != 0:
+ logger.debug("Caught SystemExit from Textual: %s", exc)
+ raise
job = _PENDING_JOB
logger.debug("After app.run(), _PENDING_JOB = %r", job)
if not job:
+ logger.debug("No job pending, exiting loop")
break
if job[0] == "legacy":
@@ -438,49 +482,24 @@ def run_Loxide(api: AirlockAPIWrapper) -> None:
continue
if job[0] == "restart":
- # just loop again; fresh .env was already loaded at the top
+ logger.debug("Restarting job loop")
continue
if job[0] == "multi_agent_action":
- # Handle multi-agent selection
logger.info("Multi-agent action with selected agents: %s", job[1])
continue
- # NEW: Handle OTP workflow
if job[0] == "otp_workflow":
_, devices, requestor, reasoning, duration = job
- # Call your OTP generation with the parameters
def otp_generate_with_params():
-
- print(f"\n{'='*60}")
- print("OTP GENERATION")
- print(f"{'='*60}")
- print(f"Requestor: {requestor}")
- print(f"Reasoning: {reasoning}")
- print(f"Duration: {duration} minutes")
- print(f"\nGenerating OTPs for {len(devices)} devices:")
- print(f"{'='*60}\n")
-
- # Call your actual OTP generation function
- # You'll need to adapt otp_generate to accept these parameters
- # For now, this is a placeholder showing the structure
- for device in devices:
- print(f"Device: {device}")
- print(f" Requestor: {requestor}")
- print(f" Reason: {reasoning}")
- print(f" Duration: {duration} minutes")
- # TODO: Actually call your API to generate OTP
- # result = api.generate_otp(device, requestor, reasoning, duration)
- print()
-
- print(f"{'='*60}")
- print("OTP Generation Complete!")
- print(f"{'='*60}")
+ # Your OTP logic here
+ pass
_run_legacy_job(otp_generate_with_params, (), {})
continue
+ logger.error("Unknown job type: %r", job)
break
diff --git a/TUI/agentmoveoperations.py b/TUI/agentmoveoperations.py
new file mode 100644
index 0000000..26032a0
--- /dev/null
+++ b/TUI/agentmoveoperations.py
@@ -0,0 +1,733 @@
+from dataclasses import asdict
+from datetime import datetime
+import logging
+import os
+from typing import List
+
+import pandas as pd
+from textual.containers import Horizontal, Vertical
+from textual.css.query import NoMatches
+from textual.message import Message
+from textual.reactive import reactive
+from textual.widget import Widget
+from textual.widgets import Button, DataTable, Header, Static, TextArea
+
+from models.agent import Agent
+from TUI.OTP_generate import OTPGenerator
+from TUI.otpworkflowscreen import OTPWorkflowScreen
+from TUI.policyselectorscreen import PolicySelectorScreen
+
+logger = logging.getLogger(__name__)
+
+
+class AgentMoveOperations(Widget):
+ """
+ A Textual widget for managing bulk agent operations and policy migrations.
+
+ This widget provides a comprehensive UI for performing operations on multiple
+ selected agents. It displays the list of target agents and provides buttons to
+ trigger various bulk operations like toggling policy modes or enabling local approval.
+
+ The widget manages its own state through reactive properties and provides real-time
+ feedback on operation progress and results. Operations are executed sequentially
+ per agent with error handling that tracks both successful and failed operations.
+
+ Attributes:
+ operation_in_progress (reactive[bool]): Tracks whether an operation is currently
+ executing. Used to disable buttons during execution.
+ selected_operation (reactive[str]): Tracks which operation type is currently
+ selected or in progress (e.g., "local_approval", "toggle_enforcement").
+
+ Example:
+ ```python
+ agents = [agent1, agent2, agent3]
+ widget = AgentMoveOperations(agents)
+ ```
+ """
+
+ # Reactive property to track if an operation is in progress
+ operation_in_progress = reactive(False)
+ # Tracks the currently selected operation type
+ selected_operation = reactive("")
+
+ class OperationComplete(Message):
+ """
+ Message posted when a bulk operation completes.
+
+ This message is broadcast to parent widgets/screens to notify them of
+ operation completion along with detailed results. It contains the list
+ of agents that were processed and the outcome for each.
+
+ Attributes:
+ operation (str): Name of the operation that completed (e.g., "Local Approval Mode").
+ agents (List[Agent]): List of all agents that were targeted by the operation.
+ successful (List[tuple]): List of (Agent, result_data) tuples for successfully
+ processed agents. Result data varies by operation type.
+ unsuccessful (List[tuple]): List of (Agent, error_message) tuples for agents
+ where the operation failed. Error message is a string explaining the failure.
+ """
+
+ def __init__(
+ self,
+ operation: str,
+ agents: List[Agent],
+ successful: List[tuple],
+ unsuccessful: List[tuple],
+ ):
+ super().__init__()
+ self.operation = operation
+ self.agents = agents
+ self.successful = successful # List of (agent, result) tuples
+ self.unsuccessful = unsuccessful # List of (agent, error) tuples
+
+ def __init__(self, agents: List[Agent]):
+ """
+ Initialize the AgentMoveOperations widget.
+
+ Args:
+ agents (List[Agent]): List of Agent objects to perform operations on.
+ These agents will be displayed in the widget's agent table.
+ """
+ super().__init__()
+ self.agents = agents
+
+ def watch_operation_in_progress(self, old_value: bool, new_value: bool) -> None:
+ """
+ React to changes in the operation_in_progress reactive property.
+
+ This is called automatically by Textual when operation_in_progress changes.
+ It updates the button states to reflect whether an operation is running.
+
+ Args:
+ old_value (bool): Previous value of operation_in_progress.
+ new_value (bool): New value of operation_in_progress.
+ """
+ self._update_button_states()
+
+ def _update_button_states(self) -> None:
+ """
+ Update the enabled/disabled state of operation buttons based on current status.
+
+ This method implements the following logic:
+ - If an operation is in progress: disable all buttons
+ - If an operation is selected: disable only that operation's button
+ - If no operation is selected: enable all buttons
+
+ The state transitions prevent users from starting multiple operations
+ simultaneously and provide visual feedback on which operation is active.
+
+ Handles NoMatches exceptions gracefully in case buttons are not yet rendered.
+ """
+ try:
+ export_csv_btn = self.query_one("#export_csv_btn", Button)
+ local_approval_btn = self.query_one("#local_approval_btn", Button)
+ toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button)
+ other_policy_btn = self.query_one("#other_policy_btn", Button)
+ otp_gen_btn = self.query_one("#otp_gen_btn", Button)
+
+ # If operation in progress, disable all
+ if self.operation_in_progress:
+ otp_gen_btn = True
+ export_csv_btn.disabled = True
+ local_approval_btn.disabled = True
+ toggle_enforcement_btn.disabled = True
+ other_policy_btn.disabled = True
+ else:
+ # If an operation was selected, disable
+ if self.selected_operation:
+ otp_gen_btn.disabled = self.selected_operation == "otp_gen"
+ export_csv_btn.disabled = self.selected_operation == "export_csv"
+ local_approval_btn.disabled = (
+ self.selected_operation == "local_approval"
+ )
+ toggle_enforcement_btn.disabled = (
+ self.selected_operation == "toggle_enforcement"
+ )
+ other_policy_btn.disabled = (
+ self.selected_operation == "other_policy"
+ )
+ else:
+ # Enable all buttons
+ otp_gen_btn = False
+ export_csv_btn = False
+ local_approval_btn.disabled = False
+ toggle_enforcement_btn.disabled = False
+ other_policy_btn.disabled = False
+
+ except NoMatches:
+ pass
+
+ def _display_results(
+ self, operation_name: str, successful: list, unsuccessful: list
+ ) -> None:
+ """
+ Display operation results in the results text area.
+
+ Formats the results into a human-readable summary including:
+ - Operation name and separator
+ - List of successful operations with agent hostnames
+ - List of failed operations with agent hostnames and error messages
+ - Summary statistics (total successful/failed count)
+
+ The results are displayed in the results_text TextArea widget and the
+ results container is made visible after being initially hidden.
+
+ Args:
+ operation_name (str): Human-readable name of the operation (e.g., "Local Approval Mode").
+ successful (list): List of (Agent, result_data) tuples for successful operations.
+ unsuccessful (list): List of (Agent, error_message) tuples for failed operations.
+ """
+ try:
+ # Build results text
+ results_lines = [
+ f"Operation: {operation_name}",
+ f"{'=' * 50}",
+ "",
+ f"✅ Successful ({len(successful)}):",
+ ]
+
+ if successful:
+ for agent, result in successful:
+ results_lines.append(f" ✅ {agent.hostname}")
+ else:
+ results_lines.append(" (none)")
+
+ results_lines.append("")
+ results_lines.append(f"⌠Failed ({len(unsuccessful)}):")
+
+ if unsuccessful:
+ for agent, error in unsuccessful:
+ results_lines.append(f" ⌠{agent.hostname}: {error}")
+ else:
+ results_lines.append(" (none)")
+
+ results_lines.append("")
+ results_lines.append(f"{'=' * 50}")
+ results_lines.append(
+ f"Total: {len(successful)} successful, {len(unsuccessful)} failed"
+ )
+
+ results_text_widget = self.query_one("#results_text", TextArea)
+ results_text_widget.text = "\n".join(results_lines)
+
+ # Show results container
+ results_container = self.query_one("#results_container", Vertical)
+ results_container.styles.display = "block"
+
+ except Exception as e:
+ logger.error(f"Error displaying results: {e}")
+
+ def compose(self):
+ """
+ Build the UI layout for the AgentMoveOperations widget.
+
+ This method is called by Textual to create the widget's UI structure.
+ It builds a two-column layout with:
+ - Left side: Agent table showing selected agents and their current policies
+ - Right side: Operation buttons and results display area
+ - Bottom: Navigation buttons (Back)
+
+ The layout is responsive with:
+ - Agent table: 2/3 width
+ - Operations panel: 1/3 width
+ - Results area: Initially hidden, shown after operation completion
+ """
+ yield Header(show_clock=True, icon="âš™")
+ title_text = Static(
+ f"ðŸ–¥ï¸ Agent Operations - {len(self.agents)} device(s) selected",
+ id="move_ops_title",
+ )
+ title_text.styles.margin = (0, 0, 1, 0)
+ yield title_text
+
+ with Horizontal() as main_layout:
+ main_layout.styles.height = "auto"
+
+ # Left side - Agent list
+ with Vertical() as left_side:
+ left_side.styles.width = "3fr"
+ left_side.styles.height = "auto"
+
+ agents_label = Static("Selected Agents:")
+ agents_label.styles.margin = (0, 0, 0, 0)
+ yield agents_label
+
+ # Create a DataTable to show agents with their current policies
+ agent_table = DataTable(id="agent_table")
+ agent_table.styles.height = "1fr"
+ agent_table.styles.margin = (1, 0, 1, 0)
+ yield agent_table
+
+ # Right side - Operation buttons
+ with Vertical() as right_side:
+ right_side.styles.width = "2fr"
+ right_side.styles.margin = (0, 1, 0, 1)
+ right_side.styles.height = "auto"
+
+ operations_label = Static("Operations:")
+ operations_label.styles.margin = (0, 0, 1, 0)
+ yield operations_label
+
+ # Operation buttons
+ export_csv_btn = Button("📈 Export CSV", id="export_csv_btn")
+ export_csv_btn.styles.width = "100%"
+ export_csv_btn.styles.margin = (0, 0, 1, 0)
+ yield export_csv_btn
+
+ local_approval_btn = Button(
+ "âœ”ï¸ Local Approval Mode", id="local_approval_btn"
+ )
+ local_approval_btn.styles.width = "100%"
+ local_approval_btn.styles.margin = (0, 0, 1, 0)
+ yield local_approval_btn
+
+ otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn")
+ otp_gen_btn.styles.width = "100%"
+ otp_gen_btn.styles.margin = (0, 0, 1, 0)
+ yield otp_gen_btn
+
+ toggle_enforcement_btn = Button(
+ "🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn"
+ )
+ toggle_enforcement_btn.styles.width = "100%"
+ toggle_enforcement_btn.styles.margin = (0, 0, 1, 0)
+ yield toggle_enforcement_btn
+
+ other_policy_btn = Button(
+ "🔀 Move to Other Policy", id="other_policy_btn"
+ )
+ other_policy_btn.styles.width = "100%"
+ other_policy_btn.styles.margin = (0, 0, 1, 0)
+ yield other_policy_btn
+
+ # Status label
+ status_label = Static("", id="status_label")
+ status_label.styles.margin = (2, 0, 0, 0)
+ yield status_label
+
+ back_button = Button("↠Back", id="back_button")
+ back_button.styles.width = "50%"
+ back_button.styles.margin = (0, 1, 1, 0)
+ yield back_button
+
+ def on_mount(self) -> None:
+ """
+ Initialize widget after it has been mounted on the screen.
+
+ This Textual lifecycle method is called after the widget is added to the DOM.
+ It performs initialization tasks:
+ - Populates the agent table with columns for Hostname, Policy, and Status
+ - Adds rows to the table for each agent in self.agents
+ - Initializes button states based on current widget state
+
+ The agent table displays agent.hostname, agent.groupname (or "Unknown"),
+ and agent.status_text (or "Unknown") for each agent.
+ """
+ table = self.query_one("#agent_table", DataTable)
+ table.add_columns("Hostname", "Current Policy", "Status")
+
+ for agent in self.agents:
+ table.add_row(
+ agent.hostname,
+ agent.groupname or "Unknown",
+ agent.status_text or "Unknown",
+ )
+
+ self._update_button_states()
+
+ def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None:
+ """Handle OTP generation request - call the actual OTP generation function."""
+
+ def on_button_pressed(self, event: Button.Pressed):
+ """
+ Handle button press events from the widget.
+
+ This Textual event handler routes button presses to appropriate actions:
+ - back_button: Pop this screen (return to parent)
+ - copy_results_btn: Copy results text to clipboard (requires pyperclip)
+ - local_approval_btn: Start local approval operation
+ - toggle_enforcement_btn: Start toggle audit/enforcement operation
+ - other_policy_btn: Start move to other policy operation
+
+ After handling, event.stop() is called to prevent event propagation.
+
+ Args:
+ event (Button.Pressed): The button press event containing the button reference.
+ """
+
+ btn_id = event.button.id
+
+ if btn_id == "back_button":
+ while len(self.app.screen_stack) > 2:
+ self.app.pop_screen()
+ event.stop()
+
+ elif btn_id == "copy_results_btn":
+ try:
+ results_text = self.query_one("#results_text", TextArea)
+ import pyperclip
+
+ pyperclip.copy(results_text.text)
+ self.app.notify(
+ "📋✅ Results copied to clipboard!",
+ severity="information",
+ timeout=2,
+ )
+ except ImportError:
+ self.app.notify(
+ "⌠pyperclip not installed. Run: pip install pyperclip",
+ severity="warning",
+ )
+ except Exception as e:
+ self.app.notify(f"âÂÅ’ Failed to copy: {str(e)}", severity="error")
+ event.stop()
+ elif btn_id == "export_csv_btn":
+ self._start_export_csv_operation()
+ event.stop()
+
+ elif btn_id == "local_approval_btn":
+ self._start_local_approval_operation()
+ event.stop()
+
+ elif btn_id == "toggle_enforcement_btn":
+ self._start_toggle_enforcement_operation()
+ event.stop()
+
+ elif btn_id == "other_policy_btn":
+ self._start_other_policy_operation()
+ event.stop()
+ elif btn_id == "otp_gen_btn":
+ self._start_OTP_gen_operation()
+ event.stop()
+
+ def _start_local_approval_operation(self) -> None:
+ """
+ Execute the local approval mode operation on all selected agents.
+
+ This operation performs the following steps for each agent:
+ 1. Generate a unique batch ID (current Unix timestamp)
+ 2. Create a local approval OTP with default duration of 360 minutes (6 hours)
+ 3. Move the agent to its related audit policy mode
+
+ The operation:
+ - Sets operation state flags (selected_operation, operation_in_progress)
+ - Updates the status label with progress indicator
+ - Iterates through all agents, tracking successful and unsuccessful operations
+ - Displays formatted results via _display_results()
+ - Posts an OperationComplete message for parent widget handling
+
+ Agents that fail are logged and added to the unsuccessful list with error details.
+ The operation completes and returns to a non-busy state regardless of individual
+ agent success/failure.
+
+ Note: The OTP duration (360 minutes) is currently hardcoded and could be
+ made configurable in future versions.
+ """
+ self.selected_operation = "local_approval"
+ self.operation_in_progress = True
+
+ status_label = self.query_one("#status_label", Static)
+ status_label.update("âœ”ï¸ Moving agents to local approval...")
+
+ # Get API from app
+ api = self.app.api
+
+ successful = []
+ unsuccessful = []
+
+ try:
+ import time
+
+ from services.agenthandler import moveAgentToRelatedPolicy
+
+ # Generate batch ID
+ batch = int(time.time())
+ duration = 360 # Default 6 hours, could make this configurable
+
+ for agent in self.agents:
+ try:
+ # Add local approval OTP
+ addLocalApproval(api, batch, duration, agent.agentid)
+ # Move to audit mode
+ result = moveAgentToRelatedPolicy(api, agent, "audit")
+ successful.append((agent, result))
+ logger.info(
+ f"Successfully moved {agent.hostname} to local approval"
+ )
+ except Exception as e:
+ unsuccessful.append((agent, str(e)))
+ logger.error(
+ f"Failed to move {agent.hostname} to local approval: {e}"
+ )
+
+ except Exception as e:
+ logger.error(f"Error during local approval operation: {e}")
+ status_label.update(f"⌠Error: {str(e)}")
+ self.operation_in_progress = False
+ return
+
+ self.operation_in_progress = False
+ status_label.update("✅ Operation complete!")
+
+ # Display results in the widget
+ self._display_results("Local Approval Mode", successful, unsuccessful)
+
+ # Also post message for potential parent handling
+ self.post_message(
+ self.OperationComplete(
+ "Local Approval Mode", self.agents, successful, unsuccessful
+ )
+ )
+
+ def _start_export_csv_operation(self) -> None:
+ self.selected_operation = "export_csv"
+ self.operation_in_progress = True
+ successful = []
+ unsuccessful = []
+ status_label = self.query_one("#status_label", Static)
+ status_label.update("Exporting CSV...")
+ self.app.refresh_data()
+ agents = self.agents
+ policies = self.app.policies
+ path = self.app.working_dir
+
+ try:
+ # Enrich each agent with policies and status text
+ for agent in agents:
+ agent.enrich_with_policies(policies)
+
+ # Convert each Agent to a dictionary, including all fields
+ data = []
+ for agent in agents:
+ row = asdict(agent)
+ # Remove the class-level status_map from the row
+ row.pop("status_map", None)
+ data.append(row)
+
+ # Create DataFrame
+ df = pd.DataFrame(data)
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ filename = f"agentsearch_{timestamp}.csv"
+ file_path = os.path.join(str(path), filename)
+ df.to_csv(file_path, index=False)
+ successful.append(file_path)
+ status_label.update(f"✅ Exported to {file_path}")
+ except Exception:
+ status_label.update("⌠Failed")
+
+ self.operation_in_progress = False
+
+ """
+ # Display results in the widget
+ self._display_results("CSV Export", successful, unsuccessful)
+
+ # Also post message for potential parent handling
+ self.post_message(
+ self.OperationComplete(
+ "CSV Export", self.agents, successful, unsuccessful
+ )
+ )
+ """
+
+ def _start_toggle_enforcement_operation(self) -> None:
+ """
+ Toggle agents between enforcement and audit policy modes.
+
+ This operation intelligently switches each agent between enforcement and
+ audit modes based on its current state:
+ - If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing , move to audit
+ - Otherwise: currently in audit, move to enforcement
+
+ The operation:
+ - Retrieves the enforcement/audit policy relationship map from protected config
+ - Sets operation state flags and updates status label
+ - Iterates through agents, determining current mode and toggling to opposite
+ - Tracks successful toggles with the new mode in the result message
+ - Logs both successes and failures
+ - Displays results and posts OperationComplete message
+
+ The policy relationship map (POLICY_MAP_ENF_AUD) must be present in protected
+ configuration and maps enforcement policy IDs to audit policy IDs. If the map
+ is empty or not found, all agents are assumed to be in audit mode and will
+ be moved to enforcement.
+
+ Returns to a non-busy state after completion regardless of individual results.
+ """
+ self.selected_operation = "toggle_enforcement"
+ self.operation_in_progress = True
+
+ status_label = self.query_one("#status_label", Static)
+ status_label.update("â³ Toggling enforcement mode...")
+
+ # Get API from app
+ api = self.app.api
+
+ successful = []
+ unsuccessful = []
+
+ try:
+ from services.agenthandler import moveAgentToRelatedPolicy
+ from utils.configmanager import get_system_json
+
+ policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
+
+ for agent in self.agents:
+ try:
+ # Determine current mode and toggle
+ if agent.groupid in policy_relationship_map:
+ # Currently in enforcement, move to audit
+ result = moveAgentToRelatedPolicy(api, agent, "audit")
+ mode = "audit"
+ else:
+ # Currently in audit, move to enforcement
+ result = moveAgentToRelatedPolicy(api, agent, "enforcement")
+ mode = "enforcement"
+
+ successful.append((agent, f"Moved to {mode}: {result}"))
+ logger.info(f"Successfully toggled {agent.hostname} to {mode}")
+ self.app.refresh_data()
+
+ except Exception as e:
+ unsuccessful.append((agent, str(e)))
+ logger.error(f"Failed to toggle {agent.hostname}: {e}")
+
+ except Exception as e:
+ logger.error(f"Error during toggle enforcement operation: {e}")
+ status_label.update(f"⌠Error: {str(e)}")
+ self.operation_in_progress = False
+ return
+
+ self.operation_in_progress = False
+ status_label.update("✅ Operation complete!")
+
+ # Display results in the widget
+ self._display_results("Toggle Audit/Enforcement", successful, unsuccessful)
+
+ # Also post message for potential parent handling
+ self.post_message(
+ self.OperationComplete(
+ "Toggle Audit/Enforcement", self.agents, successful, unsuccessful
+ )
+ )
+
+ def _start_other_policy_operation(self) -> None:
+ """
+ Move agents to a user-selected policy (currently unimplemented).
+
+ This operation is intended to allow bulk movement of selected agents to any
+ alternative policy via a policy selection dialog. Currently, this feature
+ is not fully implemented.
+
+ Planned Implementation:
+ 1. Push a new policy selector screen (TUI modal/overlay)
+ 2. Allow user to choose target policy from available options
+ 3. Move all selected agents to the chosen policy
+ 4. Display results like other operations
+
+ Current Behavior:
+ - Sets selected_operation to "other_policy"
+ - Displays "Policy selection not yet implemented" status message
+ - Clears selected_operation without performing any action
+
+ TODO: Complete implementation by:
+ - Creating a policy selector screen component
+ - Implementing the policy selection logic
+ - Integrating with moveAgentToPolicy API call
+ - Adding proper result tracking and display
+ """
+ self.selected_operation = "other_policy"
+ self.operation_in_progress = True
+
+ status_label = self.query_one("#status_label", Static)
+ status_label.update("Loading available policies...")
+
+ try:
+ # Fetch all policies from API
+ api = self.app.api
+
+ # Fetch all available policies
+ all_policies_df = api.policy_find_all()
+
+ if all_policies_df.empty:
+ status_label.update("No policies available")
+ self.operation_in_progress = False
+ self.selected_operation = ""
+ return
+
+ # Create and push the policy selector screen
+ policy_selector_screen = PolicySelectorScreen(
+ policies=all_policies_df,
+ agent_move_operations=self,
+ )
+ self.app.push_screen(policy_selector_screen)
+
+ except Exception as e:
+ logger.error(f"Error loading policies: {e}")
+ status_label.update(f"⌠Error: {str(e)}")
+ self.operation_in_progress = False
+ self.selected_operation = ""
+ self.app.notify(f"Failed to load policies: {str(e)}", severity="error")
+
+ def _start_OTP_gen_operation(self) -> None:
+ status_label = self.query_one("#status_label", Static)
+ status_label.update("Generating OTP.")
+
+ self.app.push_screen(OTPWorkflowScreen(self.agents))
+
+ def _execute_move_to_policy(self, target_policy) -> None:
+ """
+ Execute the actual move of agents to the selected policy.
+
+ Moves each agent sequentially to the target policy, tracking success/failure.
+ Updates the status label and displays results upon completion.
+
+ Args:
+ target_policy: The Policy object selected by the user.
+ """
+ status_label = self.query_one("#status_label", Static)
+ status_label.update(f"Moving agents to {target_policy.name}...")
+
+ api = self.app.api
+ successful = []
+ unsuccessful = []
+
+ try:
+ for agent in self.agents:
+ try:
+ # Move agent to target policy
+ result = api.agent_move(agent.agentid, target_policy.groupid)
+ successful.append((agent, f"Moved to {target_policy.name}"))
+ logger.info(
+ f"Successfully moved {agent.hostname} to policy {target_policy.name}"
+ )
+ except Exception as e:
+ unsuccessful.append((agent, str(e)))
+ logger.error(
+ f"Failed to move {agent.hostname} to policy {target_policy.name}: {e}"
+ )
+
+ except Exception as e:
+ logger.error(f"Error during move to policy operation: {e}")
+ status_label.update(f"Error: {str(e)}")
+ self.operation_in_progress = False
+ return
+ self.app.refresh_data()
+ self.operation_in_progress = False
+ status_label.update("Operation complete!")
+
+ # Display results in the widget
+ self._display_results(
+ f"Move to {target_policy.name}",
+ successful,
+ unsuccessful,
+ )
+
+ # Also post message for potential parent handling
+ self.post_message(
+ self.OperationComplete(
+ f"Move to {target_policy.name}",
+ self.agents,
+ successful,
+ unsuccessful,
+ )
+ )
diff --git a/TUI/allowlistselectionscreen.py b/TUI/allowlistselectionscreen.py
new file mode 100644
index 0000000..72426f2
--- /dev/null
+++ b/TUI/allowlistselectionscreen.py
@@ -0,0 +1,657 @@
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+import pandas as pd
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.screen import Screen
+from textual.widgets import (
+ Button,
+ DataTable,
+ Footer,
+ Header,
+ Static,
+ TextArea,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class AllowlistSelectionWidget(Static):
+ """
+ Widget for selecting an allowlist and adding hashes to it.
+ Can be reused in different workflows.
+ """
+
+ DEFAULT_CSS = """
+ AllowlistSelectionWidget {
+ height: 1fr;
+ layout: vertical;
+ }
+ #allowlist_main {
+ height: 1fr;
+ width: 100%;
+ }
+ #left_panel {
+ width: 50%;
+ padding: 1;
+ border: solid $primary;
+ }
+ #right_panel {
+ width: 50%;
+ padding: 1;
+ border: solid $primary;
+ }
+ #allowlist_table {
+ height: 70%;
+ margin: 1 0;
+ }
+ #allowlist_table > .datatable--header {
+ text-style: bold;
+ background: $boost;
+ }
+ #allowlist_table Row {
+ height: 1;
+ }
+ #preview_area {
+ height: 60%;
+ margin: 1 0;
+ }
+ #action_buttons {
+ height: auto;
+ min-height: 3;
+ padding: 1;
+ content-align: center middle;
+ }
+ .panel-title {
+ text-style: bold;
+ margin: 0 0 1 0;
+ }
+ .info-text {
+ margin: 1 0;
+ }
+ """
+
+ def __init__(
+ self,
+ selected_data: pd.DataFrame,
+ api=None,
+ hostname: Optional[str] = None,
+ otpid: Optional[str] = None,
+ hash_column: str = "sha256", # Default hash column name
+ ):
+ """
+ Initialize the allowlist selection widget.
+
+ Args:
+ selected_data: DataFrame containing the selected activities
+ api: API instance for making allowlist calls
+ hostname: Optional hostname for context
+ otpid: Optional OTP ID for context
+ hash_column: Name of the column containing hashes (default: "sha256")
+ """
+ super().__init__()
+ self.selected_data = selected_data
+ self.api = api
+ self.hostname = hostname
+ self.otpid = otpid
+ self.hash_column = hash_column
+ self.allowlists = []
+ self.selected_allowlist = None
+ self.hashes_to_add = []
+
+ def compose(self) -> ComposeResult:
+ with Horizontal(id="allowlist_main"):
+ # Left panel - Allowlist selection
+ with Vertical(id="left_panel"):
+ yield Static("Select Allowlist", classes="panel-title")
+ yield Static(
+ f"Choose an allowlist to add {len(self.selected_data)} selected items",
+ classes="info-text",
+ )
+
+ # Allowlist table
+ self.allowlist_table = DataTable(id="allowlist_table")
+ self.allowlist_table.cursor_type = "row"
+ yield self.allowlist_table
+
+ # Refresh button
+ self.refresh_btn = Button(
+ "🔄 Refresh Allowlists", id="refresh_allowlists_btn"
+ )
+ yield self.refresh_btn
+
+ # Right panel - Preview and actions
+ with Vertical(id="right_panel"):
+ yield Static("Preview", classes="panel-title")
+
+ # Context information
+ context_text = []
+ if self.hostname:
+ context_text.append(f"Host: {self.hostname}")
+ if self.otpid:
+ context_text.append(f"OTP: {self.otpid}")
+ context_text.append(f"Selected Activities: {len(self.selected_data)}")
+
+ yield Static(" | ".join(context_text), classes="info-text")
+
+ # Preview text area
+ self.preview_area = TextArea(
+ id="preview_area", read_only=True, language="markdown"
+ )
+ yield self.preview_area
+
+ # Hash statistics
+ self.stats_label = Static("", id="stats_label", classes="info-text")
+ yield self.stats_label
+
+ # Action buttons at bottom
+ with Horizontal(id="action_buttons"):
+ self.back_btn = Button("⬅ Back", id="back_btn")
+ self.add_btn = Button("➕ Add to Allowlist", id="add_to_allowlist_btn")
+
+ self.back_btn.styles.width = "50%"
+ self.add_btn.styles.width = "50%"
+ self.add_btn.disabled = True # Disabled until allowlist selected
+
+ yield self.back_btn
+ yield self.add_btn
+
+ async def on_mount(self) -> None:
+ """Load allowlists when widget mounts."""
+ await self.load_allowlists()
+ await self.extract_and_preview_hashes()
+
+ async def load_allowlists(self) -> None:
+ """Load available allowlists from API, grouped by policy association."""
+ if not self.api:
+ logger.error("No API available")
+ self.allowlist_table.add_column("Error")
+ self.allowlist_table.add_row("No API available")
+ return
+
+ try:
+ # First, try to get the host's policy if hostname is provided
+ host_policy_allowlists = []
+ host_policy_ids = set()
+ policy_name = "Unknown Policy" # Default value
+ group_id = None
+
+ if self.hostname:
+ try:
+ # Get agent info to find its policy
+ agents_df = self.api.agent_find_by_hostname(self.hostname)
+ if not agents_df.empty:
+ # Get the policy group ID for this host
+ group_id = agents_df.iloc[0].get("groupid")
+
+ # Look up the policy name from app's cached policies
+ if (
+ group_id
+ and hasattr(self.app, "policies")
+ and self.app.policies
+ ):
+ for policy in self.app.policies:
+ if policy.groupid == group_id:
+ policy_name = policy.name
+ logger.info(
+ f"Found policy name: '{policy_name}' for group_id: {group_id}"
+ )
+ break
+
+ logger.info(
+ f"Found host '{self.hostname}' in policy '{policy_name}' (group_id: {group_id})"
+ )
+
+ if group_id:
+ # Get allowlists for this policy
+ policy_allowlists_df = self.api.policy_list_allowlists(
+ group_id
+ )
+ if not policy_allowlists_df.empty:
+ host_policy_allowlists = policy_allowlists_df.to_dict(
+ orient="records"
+ )
+ host_policy_ids = {
+ al.get("applicationid")
+ for al in host_policy_allowlists
+ }
+ logger.info(
+ f"Found {len(host_policy_allowlists)} allowlists for host's policy"
+ )
+ except Exception as e:
+ logger.warning(f"Could not get host's policy allowlists: {e}")
+
+ # If we still don't have a policy name, try to get it from the first allowlist or use a default
+ if not policy_name:
+ # Get all policies and try to find which one has allowlists
+ try:
+ all_policies_df = self.api.policy_find_all()
+ if not all_policies_df.empty:
+ # If we have a group_id from somewhere, use it
+ if group_id:
+ policy_row = all_policies_df[
+ all_policies_df["groupid"] == group_id
+ ]
+ if not policy_row.empty:
+ policy_name = policy_row.iloc[0].get(
+ "groupname", "Unknown Policy"
+ )
+ else:
+ # Use the first policy as fallback
+ policy_name = all_policies_df.iloc[0].get(
+ "groupname", "Default Policy"
+ )
+ logger.info(f"Using first available policy: {policy_name}")
+ else:
+ policy_name = "Unknown Policy"
+ except Exception as e:
+ logger.warning(f"Could not fetch policies: {e}")
+ policy_name = "Unknown Policy"
+
+ # Get all allowlists
+ all_allowlists_df = self.api.allowlist_find_all()
+
+ if all_allowlists_df.empty:
+ self.allowlist_table.add_column("No Allowlists")
+ self.allowlist_table.add_row("No allowlists found")
+ return
+
+ all_allowlists = all_allowlists_df.to_dict(orient="records")
+
+ # Separate into two groups: policy-associated and others
+ other_allowlists = [
+ al
+ for al in all_allowlists
+ if al.get("applicationid") not in host_policy_ids
+ ]
+
+ # Sort each group alphabetically by name
+ host_policy_allowlists.sort(key=lambda x: x.get("name", "").lower())
+ other_allowlists.sort(key=lambda x: x.get("name", "").lower())
+
+ # Combine lists with policy-associated first
+ self.allowlists = host_policy_allowlists + other_allowlists
+
+ # Setup table columns
+ self.allowlist_table.clear()
+ self.allowlist_table.add_columns("Name", "Application ID", "Type")
+
+ # Track which rows are headers vs actual allowlists
+ self._row_to_allowlist_map = {}
+ current_row = 0
+
+ # Add policy-associated allowlists if any
+ if host_policy_allowlists:
+ # Add section header
+ header_text = f"=== Policy: {policy_name or 'Host Policy'} ==="
+ self.allowlist_table.add_row(header_text, "", "", key="header_policy")
+ current_row += 1
+
+ # Add policy allowlists
+ for idx, allowlist in enumerate(host_policy_allowlists):
+ name = allowlist.get("name", "Unknown")
+ app_id = allowlist.get("applicationid", "Unknown")
+
+ self.allowlist_table.add_row(
+ f" {name}", # Indent to show grouping
+ app_id,
+ "Policy",
+ key=f"policy_{idx}",
+ )
+ self._row_to_allowlist_map[current_row] = idx
+ current_row += 1
+
+ # Add other allowlists
+ if other_allowlists:
+ # Add section header
+ if host_policy_allowlists:
+ # Add spacer if we have policy allowlists above
+ self.allowlist_table.add_row("", "", "", key="spacer")
+ current_row += 1
+
+ self.allowlist_table.add_row(
+ "=== Other Available Allowlists ===", "", "", key="header_other"
+ )
+ current_row += 1
+
+ # Add other allowlists
+ for idx, allowlist in enumerate(other_allowlists):
+ name = allowlist.get("name", "Unknown")
+ app_id = allowlist.get("applicationid", "Unknown")
+
+ self.allowlist_table.add_row(
+ f" {name}", # Indent to show grouping
+ app_id,
+ "General",
+ key=f"other_{idx}",
+ )
+ # Map to the correct index in the combined list
+ actual_idx = len(host_policy_allowlists) + idx
+ self._row_to_allowlist_map[current_row] = actual_idx
+ current_row += 1
+
+ # Log summary
+ logger.info(
+ f"Loaded {len(self.allowlists)} total allowlists: "
+ f"{len(host_policy_allowlists)} policy-associated, "
+ f"{len(other_allowlists)} others"
+ )
+
+ # Update stats label if no allowlists in policy
+ if self.hostname and not host_policy_allowlists:
+ self.stats_label.update(
+ f"Note: No allowlists found for {self.hostname}'s policy | "
+ + self.stats_label.content.plain
+ )
+
+ except Exception as exc:
+ logger.exception(f"Failed to load allowlists: {exc}")
+ self.allowlist_table.add_column("Error")
+ self.allowlist_table.add_row(f"Failed to load: {str(exc)}")
+
+ async def extract_and_preview_hashes(self) -> None:
+ """Extract hashes from selected data and show preview."""
+ preview_lines = ["## Hash Extraction Summary\n"]
+
+ # Check for hash column
+ if self.hash_column not in self.selected_data.columns:
+ # Try to find a hash column
+ possible_hash_cols = [
+ "sha256",
+ "SHA256",
+ "hash",
+ "Hash",
+ "sha1",
+ "SHA1",
+ "md5",
+ "MD5",
+ "filehash",
+ "file_hash",
+ ]
+ found_col = None
+ for col in possible_hash_cols:
+ if col in self.selected_data.columns:
+ found_col = col
+ break
+
+ if found_col:
+ self.hash_column = found_col
+ preview_lines.append(f"âÅâ Found hash column: **{found_col}**\n")
+ else:
+ preview_lines.append("⚠︠**No hash column found**\n")
+ preview_lines.append("Available columns:\n")
+ for col in self.selected_data.columns:
+ if col != "_row_id":
+ preview_lines.append(f" - {col}\n")
+
+ self.preview_area.text = "".join(preview_lines)
+ self.stats_label.update("No hashes to add")
+ return
+
+ # Extract unique hashes
+ hashes = self.selected_data[self.hash_column].dropna().unique()
+ self.hashes_to_add = [h for h in hashes if h and str(h).strip()]
+
+ # Build preview
+ preview_lines.append(f"### Found {len(self.hashes_to_add)} unique hashes\n\n")
+
+ # Show sample of hashes (first 10)
+ preview_lines.append("**Sample hashes to be added:**\n```\n")
+ for i, hash_val in enumerate(self.hashes_to_add[:10]):
+ preview_lines.append(f"{i+1}. {hash_val}\n")
+ if len(self.hashes_to_add) > 10:
+ preview_lines.append(f"... and {len(self.hashes_to_add) - 10} more\n")
+ preview_lines.append("```\n\n")
+
+ # Show sample of source data
+ preview_lines.append("**Sample source activities:**\n")
+ sample_cols = [
+ col
+ for col in self.selected_data.columns
+ if col not in ["_row_id"] and col in ["filename", "path", "action", "user"]
+ ]
+ if not sample_cols:
+ sample_cols = [
+ col for col in self.selected_data.columns if col != "_row_id"
+ ][:3]
+
+ if sample_cols:
+ preview_lines.append("```\n")
+ for i, row in self.selected_data[sample_cols].head(5).iterrows():
+ row_text = " | ".join([f"{col}: {row[col]}" for col in sample_cols])
+ preview_lines.append(f"{row_text}\n")
+ preview_lines.append("```\n")
+
+ self.preview_area.text = "".join(preview_lines)
+
+ # Update statistics
+ self.stats_label.update(
+ f"Ready to add {len(self.hashes_to_add)} unique hashes | "
+ f"From {len(self.selected_data)} selected activities"
+ )
+
+ async def on_data_table_row_selected(self, event) -> None:
+ """Handle allowlist selection."""
+ try:
+ # Extract row index from event - handle different event structures
+ row_index = None
+
+ # Try to get row index from coordinate
+ if hasattr(event, "coordinate") and hasattr(event.coordinate, "row"):
+ row_index = event.coordinate.row
+ # Try cursor_row as fallback
+ elif hasattr(event, "cursor_row"):
+ row_index = event.cursor_row
+ # Try getting from the table itself
+ else:
+ table = self.allowlist_table
+ if hasattr(table, "cursor_row"):
+ row_index = table.cursor_row
+
+ # Validate row index
+ if row_index is not None and isinstance(row_index, int):
+ # Account for group headers in the row count
+ actual_allowlist_index = self._get_allowlist_index_from_row(row_index)
+
+ if (
+ actual_allowlist_index is not None
+ and 0 <= actual_allowlist_index < len(self.allowlists)
+ ):
+ self.selected_allowlist = self.allowlists[actual_allowlist_index]
+ self.add_btn.disabled = False
+ self.add_btn.label = (
+ f"â Add to '{self.selected_allowlist.get('name', 'Unknown')}'"
+ )
+
+ # Update preview with selection
+ await self._update_preview_with_selection()
+
+ logger.info(
+ f"Selected allowlist: {self.selected_allowlist.get('name')}"
+ )
+ else:
+ logger.debug(f"Row {row_index} is a header or invalid")
+ else:
+ logger.warning(f"Could not extract valid row index from event: {event}")
+
+ except Exception as exc:
+ logger.exception(f"Failed to select allowlist: {exc}")
+
+ def _get_allowlist_index_from_row(self, row_index: int) -> Optional[int]:
+ """Convert table row index to allowlist list index, accounting for group headers."""
+ # This will be updated when we have group headers
+ if hasattr(self, "_row_to_allowlist_map"):
+ return self._row_to_allowlist_map.get(row_index)
+ return row_index
+
+ async def _update_preview_with_selection(self) -> None:
+ """Update preview when an allowlist is selected."""
+ if not self.selected_allowlist:
+ return
+
+ current_text = self.preview_area.text
+ # Remove any existing selection header
+ if "### Selected Allowlist:" in current_text:
+ lines = current_text.split("\n")
+ # Find and remove the selection lines
+ new_lines = []
+ skip_next = False
+ for line in lines:
+ if line.startswith("### Selected Allowlist:"):
+ skip_next = True
+ continue
+ if skip_next and line.startswith("Application ID:"):
+ skip_next = False
+ continue
+ if not skip_next:
+ new_lines.append(line)
+ current_text = "\n".join(new_lines)
+
+ # Add new selection at the top
+ selection_text = (
+ f"### Selected Allowlist: **{self.selected_allowlist.get('name')}**\n"
+ f"Application ID: {self.selected_allowlist.get('applicationid')}\n\n"
+ )
+ self.preview_area.text = selection_text + current_text
+
+ async def on_button_pressed(self, event) -> None:
+ """Handle button presses."""
+ btn = getattr(event, "button", None) or getattr(event, "sender", None)
+ btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None)
+
+ if btn is self.back_btn or btn_id == "back_btn":
+ await self.app.pop_screen()
+ event.stop()
+ return
+
+ if btn is self.refresh_btn or btn_id == "refresh_allowlists_btn":
+ await self.load_allowlists()
+ event.stop()
+ return
+
+ if btn is self.add_btn or btn_id == "add_to_allowlist_btn":
+ await self.add_hashes_to_allowlist()
+ event.stop()
+ return
+
+ async def add_hashes_to_allowlist(self) -> None:
+ """Add the extracted hashes to the selected allowlist."""
+ if not self.selected_allowlist or not self.hashes_to_add:
+ self.app.notify(
+ "No allowlist selected or no hashes to add", severity="warning"
+ )
+ return
+
+ if not self.api:
+ self.app.notify("API not available", severity="error")
+ return
+
+ try:
+ # Disable button during operation
+ self.add_btn.disabled = True
+ self.add_btn.label = "⏳ Adding hashes..."
+
+ # Call API to add hashes
+ app_id = self.selected_allowlist.get("applicationid")
+ allowlist_name = self.selected_allowlist.get("name", "Unknown")
+
+ logger.info(
+ f"Adding {len(self.hashes_to_add)} hashes to allowlist {allowlist_name} (ID: {app_id})"
+ )
+
+ result = self.api.hash_add_to_allowlist(app_id, self.hashes_to_add)
+
+ # Success notification
+ self.app.notify(
+ f"✅ Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'",
+ title="Success",
+ severity="information",
+ timeout=5,
+ )
+
+ # Update preview to show success
+ self.preview_area.text = (
+ f"## ✅ SUCCESS\n\n"
+ f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n"
+ f"**{allowlist_name}** (ID: {app_id})\n\n"
+ f"### Operation Details:\n"
+ f"- Source: {self.hostname or 'Multiple hosts'}\n"
+ f"- OTP ID: {self.otpid or 'N/A'}\n"
+ f"- Activities processed: {len(self.selected_data)}\n"
+ f"- Unique hashes added: {len(self.hashes_to_add)}\n"
+ )
+
+ # Change button to "Done"
+ self.add_btn.label = "✅ Done"
+ self.add_btn.disabled = True
+
+ except Exception as exc:
+ logger.exception(f"Failed to add hashes to allowlist: {exc}")
+ self.app.notify(
+ f"❌ Failed to add hashes: {str(exc)}",
+ title="Error",
+ severity="error",
+ timeout=10,
+ )
+
+ # Re-enable button
+ self.add_btn.disabled = False
+ self.add_btn.label = "⟳ Retry Add to Allowlist"
+
+
+class AllowlistSelectionScreen(Screen):
+ """
+ Screen wrapper for the AllowlistSelectionWidget.
+ """
+
+ BINDINGS = [
+ Binding("b", "back", "Back"),
+ Binding("r", "refresh", "Refresh Allowlists"),
+ Binding("enter", "confirm", "Add to Allowlist"),
+ ]
+
+ def __init__(
+ self,
+ selected_data: pd.DataFrame,
+ api=None,
+ hostname: Optional[str] = None,
+ otpid: Optional[str] = None,
+ hash_column: str = "sha256",
+ ):
+ super().__init__()
+ self.selected_data = selected_data
+ self.api = api
+ self.hostname = hostname
+ self.otpid = otpid
+ self.hash_column = hash_column
+
+ def compose(self) -> ComposeResult:
+ yield Header(show_clock=True)
+ self.widget = AllowlistSelectionWidget(
+ self.selected_data,
+ api=self.api,
+ hostname=self.hostname,
+ otpid=self.otpid,
+ hash_column=self.hash_column,
+ )
+ yield self.widget
+ yield Footer()
+
+ async def action_back(self) -> None:
+ """Go back to previous screen."""
+ await self.app.pop_screen()
+
+ async def action_refresh(self) -> None:
+ """Refresh the allowlists."""
+ if hasattr(self, "widget") and self.widget:
+ await self.widget.load_allowlists()
+
+ async def action_confirm(self) -> None:
+ """Confirm and add to allowlist."""
+ if hasattr(self, "widget") and self.widget:
+ if self.widget.selected_allowlist and self.widget.hashes_to_add:
+ await self.widget.add_hashes_to_allowlist()
diff --git a/TUI/moveagentworkflowscreen.py b/TUI/moveagentworkflowscreen.py
new file mode 100644
index 0000000..8398ddf
--- /dev/null
+++ b/TUI/moveagentworkflowscreen.py
@@ -0,0 +1,61 @@
+from typing import List, Optional
+
+from textual.app import ComposeResult
+from textual.screen import Screen
+
+from models.agent import Agent
+from TUI.agentmoveoperations import AgentMoveOperations
+from TUI.multiagentselector import MultiAgentSelector
+from TUI.resultsdisplay import ResultsDisplay
+
+
+class MoveAgentWorkflowScreen(Screen):
+ """Screen that handles the agent movement workflow."""
+
+ def __init__(self, all_agents: Optional[List[Agent]]):
+ super().__init__()
+ self.all_agents = all_agents
+ self.selected_agents = None
+
+ def compose(self) -> ComposeResult:
+ """Start with the multi-agent selector."""
+ yield MultiAgentSelector(self.all_agents)
+
+ def on_multi_agent_selector_agents_selected(
+ self, message: MultiAgentSelector.AgentsSelected
+ ) -> None:
+ """Handle selected agents - switch to operations screen."""
+ self.selected_agents = message.selected_agents
+
+ # Remove the MultiAgentSelector
+ selector = self.query_one(MultiAgentSelector)
+ selector.remove()
+
+ # Mount the AgentMoveOperations with the selected Agent objects
+ self.mount(AgentMoveOperations(self.selected_agents))
+
+ def on_agent_move_operations_operation_complete(
+ self, message: AgentMoveOperations.OperationComplete
+ ) -> None:
+ """Handle completion of move operation - transition to results screen."""
+ # Format successful results
+ success_lines = []
+ for agent, result in message.successful:
+ success_lines.append(f"✓ {agent.hostname}")
+
+ # Format unsuccessful results
+ failure_lines = []
+ for agent, error in message.unsuccessful:
+ failure_lines.append(f"✗ {agent.hostname}: {error}")
+
+ successful_text = "\n".join(success_lines) if success_lines else "(none)"
+ unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)"
+
+ # Remove the operations widget
+ ops_widget = self.query_one(AgentMoveOperations)
+ ops_widget.remove()
+
+ # Mount the results display
+ self.mount(
+ ResultsDisplay(message.operation, successful_text, unsuccessful_text)
+ )
diff --git a/widgets/multiagentselector.py b/TUI/multiagentselector.py
similarity index 92%
rename from widgets/multiagentselector.py
rename to TUI/multiagentselector.py
index e46c636..afe8e14 100644
--- a/widgets/multiagentselector.py
+++ b/TUI/multiagentselector.py
@@ -1,12 +1,20 @@
import difflib
import re
-from typing import List
+from typing import List, Optional
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.widget import Widget
-from textual.widgets import Button, SelectionList, Static, Switch, TextArea
+from textual.widgets import (
+ Button,
+ Footer,
+ Header,
+ SelectionList,
+ Static,
+ Switch,
+ TextArea,
+)
from models.agent import Agent
@@ -17,7 +25,7 @@ class MultiAgentSelector(Widget):
super().__init__()
self.selected_agents = selected_agents
- def __init__(self, all_agents: List[Agent]):
+ def __init__(self, all_agents: Optional[List[Agent]]):
super().__init__()
self.all_agents = all_agents
self._match_type = "exact"
@@ -31,7 +39,8 @@ class MultiAgentSelector(Widget):
self._match_type = value
def compose(self):
- title_text = Static("🖧 Multi-Agent Selector", id="selector_title")
+ yield Header(show_clock=True, icon="⚙")
+ title_text = Static("🖧 Agent Selector", id="selector_title")
title_text.styles.margin = (0, 0, 0, 1)
yield title_text
@@ -70,20 +79,21 @@ class MultiAgentSelector(Widget):
with Horizontal() as select_buttons:
select_buttons.styles.margin = (0, 0, 0, 0)
- select_all_button = Button("✅ Select All", id="select_all")
- select_all_button.styles.margin = (1, 1, 0, 1)
- yield select_all_button
-
select_none_button = Button("🚫 Select None", id="select_none")
- select_none_button.styles.margin = (1, 0, 0, 1)
+ select_none_button.styles.margin = (1, 1, 0, 1)
yield select_none_button
+ select_all_button = Button("✅ Select All", id="select_all")
+ select_all_button.styles.margin = (1, 0, 0, 1)
+ yield select_all_button
+
with Horizontal() as button_row:
button_row.styles.height = "auto"
button_row.styles.margin = (1, 0, 0, 0)
back_button = Button("← Back", id="back_button")
back_button.styles.width = "1fr"
+ back_button.styles.margin = (0, 0, 0, 1)
yield back_button
submit_button = Button(
@@ -98,6 +108,7 @@ class MultiAgentSelector(Widget):
right_pane.styles.width = "2fr"
yield SelectionList(id="match_results")
yield Static(id="unmatched_label")
+ yield Footer()
def on_switch_changed(self, event: Switch.Changed):
self.match_type = "fuzzy" if event.value else "exact"
diff --git a/TUI/otpactivityscreen.py b/TUI/otpactivityscreen.py
new file mode 100644
index 0000000..5947cee
--- /dev/null
+++ b/TUI/otpactivityscreen.py
@@ -0,0 +1,897 @@
+from __future__ import annotations
+
+from datetime import datetime
+import logging
+import os
+
+import pandas as pd
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.screen import Screen
+from textual.widgets import Button, DataTable, Footer, Header, Static
+
+from TUI.allowlistselectionscreen import AllowlistSelectionScreen
+from utils.configmanager import load_env
+
+logger = logging.getLogger(__name__)
+
+
+def _load_working_dir() -> str:
+ """
+ Load the working directory from environment variables or use the current working directory.
+ """
+ wd = os.environ.get("WORKING_DIR")
+ if wd:
+ return wd
+ return os.getcwd()
+
+
+class OTPActivitiesWidget(Static):
+ """
+ Reusable widget that contains the sessions table (left) and an Activity Preview (right).
+ The right side shows an Activity Preview that takes ~75% vertical space, and a lower area
+ with Back and Continue buttons. The Continue button pushes ActivityDetailScreen with the
+ currently-loaded activities.
+ """
+
+ DEFAULT_CSS = """
+ OTPActivitiesWidget {
+ height: 1fr;
+ }
+ #main_row {
+ width: 100%;
+ height: 100%;
+ layout: horizontal;
+ }
+ #left_panel {
+ width: 60%;
+ min-width: 60;
+ border: none;
+ }
+ #right_panel {
+ width: 40%;
+ min-width: 40;
+ border: none;
+ layout: vertical;
+ }
+ #activity_preview_container {
+ height: 1fr;
+ border: none;
+ padding: 1 1;
+ }
+ #activity_buttons {
+ height: auto;
+ min-height: 3;
+ padding: 1 1;
+ content-align: center middle;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ # Layout: horizontal main row with left & right panels
+ with Horizontal(id="main_row"):
+ # Left: sessions area
+ with Vertical(id="left_panel"):
+ yield Static("OTP Sessions", classes="panel-title")
+ with Vertical(id="sessions_table_container"):
+ self.sessions_table = DataTable(id="sessions_table")
+ self.sessions_table.styles.width = "100%"
+ yield self.sessions_table
+ # Right: Activity Preview (top 3/4) + buttons (bottom 1/4)
+ with Vertical(id="right_panel"):
+ # Activity preview area (takes ~75% of right panel)
+ yield Static("Activity Preview", classes="panel-title")
+ with Vertical(id="activity_preview_container"):
+ self.activities_table = DataTable(id="activity_preview_table")
+ yield self.activities_table
+ # Buttons area at the bottom (Back, Continue)
+ with Horizontal(id="activity_buttons"):
+ # Back takes left side, Continue right side
+ self.back_btn = Button("Back", id="activity_back_btn")
+ self.continue_btn = Button("Continue", id="activity_continue_btn")
+ # Stretch buttons nicely
+ self.back_btn.styles.width = "50%"
+ self.continue_btn.styles.width = "50%"
+ yield self.back_btn
+ yield self.continue_btn
+
+ async def on_mount(self) -> None:
+ # Configure sessions table and activities preview
+ self.sessions_table.clear()
+ self.sessions_table.add_columns(
+ "otpid", "hostname", "status", "purpose", "granted"
+ )
+ self.activities_table.clear()
+ # activities_table columns are dynamically added when activities are loaded.
+ # Selection behavior
+ self.sessions_table.cursor_type = "row"
+ try:
+ self.sessions_table.zebra_stripes = True
+ except Exception:
+ pass
+ self.activities_table.cursor_type = "row"
+ try:
+ self.activities_table.zebra_stripes = True
+ except Exception:
+ pass
+ # Store state
+ self._sessions_df: pd.DataFrame | None = None
+ self._activities_df: pd.DataFrame | None = None
+ self._selected_session_otpid: str | int | None = None
+
+ async def on_button_pressed(self, event) -> None: # type: ignore[override]
+ """
+ Handle Back / Continue buttons for the Activity Preview area.
+ """
+ # Try to resolve the button object from the event
+ btn = (
+ getattr(event, "button", None)
+ or getattr(event, "sender", None)
+ or getattr(event, "control", None)
+ or getattr(event, "widget", None)
+ )
+ btn_id = (
+ getattr(btn, "id", None)
+ or getattr(event, "button_id", None)
+ or getattr(event, "id", None)
+ )
+ # ---- Back ----
+ if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None):
+ while len(self.app.screen_stack) > 2:
+ self.app.pop_screen()
+ event.stop()
+ return
+ # ---- Continue ----
+ if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None):
+ if self._activities_df is None or self._activities_df.empty:
+ logger.info("Continue pressed but no activities loaded.")
+ await self.post_message(
+ Static("No activities loaded to continue with.")
+ )
+ return
+ # Copy activities DataFrame to pass to new screen
+ activities_copy = self._activities_df.copy()
+ otpid = self._selected_session_otpid
+ # Optionally include hostname if available
+ hostname = None
+ try:
+ if self._sessions_df is not None:
+ df = self._sessions_df.reset_index(drop=True)
+ match = df[df["otpid"] == otpid]
+ if not match.empty:
+ hostname = match.iloc[0].get("hostname")
+ except Exception:
+ hostname = None
+ # Create and push ActivityDetailScreen, handing the data
+ try:
+ detail_screen = ActivityDetailScreen(
+ activities_copy, otpid=otpid, hostname=hostname
+ )
+ await self.app.push_screen(detail_screen)
+ except Exception as exc:
+ logger.exception("Failed to push ActivityDetailScreen: %s", exc)
+ return
+ # Unknown button on widget
+ logger.debug(
+ "Unhandled OTPActivitiesWidget button pressed (resolved btn=%r, id=%r)",
+ btn,
+ btn_id,
+ )
+
+ async def on_data_table_row_selected(self, event) -> None: # type: ignore[override]
+ """
+ Robust handler for DataTable row-selection across Textual micro-versions.
+ Tries many attribute names and shapes:
+ - numeric index (row_key, row_index, index)
+ - coordinate object or tuple (coordinate.row or (row, col))
+ - direct row values (row, values, cells) -> we try to map those back to the sessions DF
+ - table.cursor_row fallback
+ """
+ # 1) Determine the sending table (best-effort)
+ sender = None
+ for attr in ("sender", "table", "data_table", "control"):
+ sender = getattr(event, attr, None)
+ if sender is not None:
+ break
+ if sender is None:
+ sender = self.sessions_table # Assume sessions_table if unknown
+ # Only respond to selections in the sessions table
+ if sender is not self.sessions_table:
+ return
+
+ # Helper to log and return
+ def _bad(msg: str, *args):
+ logger.warning(msg, *args)
+ return None
+
+ # 2) Try to extract a numeric index
+ row_key = None
+ for attr in ("row_key", "row", "row_index", "index"):
+ row_key = getattr(event, attr, None)
+ if row_key is not None:
+ break
+ # If coordinate: try to extract .row or tuple[0]
+ if row_key is None:
+ coord = getattr(event, "coordinate", None) or getattr(
+ event, "cursor_coordinate", None
+ )
+ if coord is not None:
+ if hasattr(coord, "row"):
+ row_key = coord.row
+ elif isinstance(coord, (tuple, list)) and len(coord) >= 1:
+ row_key = coord[0]
+ # If still nothing, maybe the event provides the row's cell values directly
+ row_values = None
+ for attr in ("values", "cells", "row", "row_values", "selected_row_values"):
+ val = getattr(event, attr, None)
+ if val:
+ # Prefer actual sequence of cell values
+ row_values = val
+ break
+ # If we have row_values, try to map them back to the sessions DataFrame
+ if row_values is not None:
+ # Normalize into list of strings for comparison
+ try:
+ vals = [
+ "" if pd.isna(v) else str(v)
+ for v in (
+ list(row_values)
+ if not isinstance(row_values, str)
+ else [row_values]
+ )
+ ]
+ except Exception:
+ vals = [str(row_values)]
+ # Try to match against the expected columns order we render
+ if self._sessions_df is None or self._sessions_df.empty:
+ logger.warning(
+ "Sessions DataFrame is empty; cannot map selected row values."
+ )
+ return
+ df_ordered = self._sessions_df.reset_index(drop=True)
+ expected_cols = ["otpid", "hostname", "status", "purpose", "granted"]
+
+ # Build stringified candidates for each row in df using the same columns we show
+ def _row_to_vals(sr):
+ out = []
+ for c in expected_cols:
+ if c in sr:
+ v = sr[c]
+ out.append("" if pd.isna(v) else str(v))
+ else:
+ out.append("")
+ return out
+
+ match_idx = None
+ for i, sr in df_ordered.iterrows():
+ cand = _row_to_vals(sr)
+ # Compare prefix: row values might be a subset (e.g. only first 3 cols), so compare prefix only
+ if len(vals) <= len(cand) and all(
+ vals[j] == cand[j] for j in range(len(vals))
+ ):
+ match_idx = i
+ break
+ if match_idx is None:
+ # Try looser match: compare first cell only (otpid)
+ first = vals[0] if vals else None
+ if first is not None:
+ for i, sr in df_ordered.iterrows():
+ cand0 = "" if pd.isna(sr.get("otpid")) else str(sr.get("otpid"))
+ if cand0 == first:
+ match_idx = i
+ break
+ if match_idx is None:
+ logger.warning(
+ "Unable to locate DataFrame row matching selected row values: %r",
+ vals,
+ )
+ return
+ idx = int(match_idx)
+ else:
+ # 3) If we have a row_key, try to normalize to an int index
+ if row_key is not None:
+ try:
+ idx = int(row_key)
+ except Exception:
+ # Try converting via string
+ try:
+ idx = int(str(row_key))
+ except Exception:
+ idx = None
+ if idx is None:
+ # Final numeric fallback: use sessions_table.cursor_row if present
+ try:
+ idx = getattr(self.sessions_table, "cursor_row")
+ except Exception:
+ idx = None
+ if idx is None:
+ _bad("Failed to normalize row/key from event: %r", row_key)
+ return
+ else:
+ # 4) Try table cursor_row as last resort
+ try:
+ idx = getattr(self.sessions_table, "cursor_row")
+ except Exception:
+ logger.warning(
+ "Could not determine selected row from event: %r", event
+ )
+ # Helpful debug hint for you to paste back if still failing:
+ logger.debug("Event repr for debugging: %r", event)
+ return
+ # At this point we should have an integer idx
+ try:
+ idx = int(idx)
+ except Exception:
+ logger.exception(
+ "Final normalization of selected row index failed: %r", idx
+ )
+ return
+ # Validate sessions df
+ if self._sessions_df is None or self._sessions_df.empty:
+ logger.warning("Sessions DataFrame empty; nothing to select.")
+ return
+ df_ordered = self._sessions_df.reset_index(drop=True)
+ if idx < 0 or idx >= len(df_ordered):
+ logger.warning(
+ "Selected row index %s out of range (0..%d)", idx, len(df_ordered) - 1
+ )
+ return
+ row_series = df_ordered.iloc[idx]
+ otpid = row_series.get("otpid")
+ hostname = row_series.get("hostname")
+ # Store selected session and fetch activities
+ self._selected_session_otpid = otpid
+ # Obtain api from app (try multiple places)
+ api = (
+ getattr(self.app, "api", None)
+ or getattr(self, "api", None)
+ or getattr(self.app, "airlock_api", None)
+ )
+ if api is None:
+ logger.error("No API available on self.app.api - cannot fetch activities")
+ return
+ logger.info(
+ "Fetching activities for otpid=%s host=%s (selected row=%s)",
+ otpid,
+ hostname,
+ idx,
+ )
+ await self._fetch_activities_for_otpid(api, otpid, hostname=hostname)
+
+ async def load_sessions_from_api(self, api) -> None:
+ """
+ Pulls OTP session lists, adds status column, concatenates and populates the sessions table.
+ """
+ try:
+ active = api.otp_find_active()
+ awaiting = api.otp_find_awaiting()
+ enforced = api.otp_find_enforced()
+ revoked = api.otp_find_revoked()
+ except Exception as exc:
+ logger.exception("Failed to fetch OTP session lists: %s", exc)
+ # Present empty
+ active = awaiting = enforced = revoked = pd.DataFrame()
+
+ # Ensure DataFrame objects
+ def _ensure_df(df):
+ return df if isinstance(df, pd.DataFrame) else pd.DataFrame(df)
+
+ active = _ensure_df(active)
+ awaiting = _ensure_df(awaiting)
+ enforced = _ensure_df(enforced)
+ revoked = _ensure_df(revoked)
+ for df, status in [
+ (active, "active"),
+ (awaiting, "awaiting"),
+ (enforced, "enforced"),
+ (revoked, "revoked"),
+ ]:
+ if "status" not in df.columns:
+ df["status"] = status
+ combined = pd.concat([active, awaiting, enforced, revoked], ignore_index=True)
+ if "otpid" in combined.columns:
+ combined = combined.sort_values(by="otpid", ascending=False)
+ self._sessions_df = combined
+ # Populate DataTable
+ self.sessions_table.clear()
+ # Ensure columns exist in DF and when missing add empty column
+ expected_cols = ["otpid", "hostname", "status", "purpose", "granted"]
+ for col in expected_cols:
+ if col not in combined.columns:
+ combined[col] = ""
+ self.sessions_table.add_columns(*expected_cols)
+ # Add rows
+ for _, row in combined[expected_cols].iterrows():
+ # Convert values to str for safe insertion
+ vals = ["" if pd.isna(v) else v for v in row.to_list()]
+ self.sessions_table.add_row(*[str(v) for v in vals])
+ logger.info("Loaded %d OTP sessions.", len(combined))
+
+ async def _fetch_activities_for_otpid(self, api, otpid, hostname=None) -> None:
+ """
+ Fetch activities DataFrame for a given otpid and populate activities_table.
+ """
+ try:
+ result = api.otp_get_activities(otpid)
+ result_df = (
+ result if isinstance(result, pd.DataFrame) else pd.DataFrame(result)
+ )
+ except Exception as exc:
+ logger.exception("Failed to fetch activities for otpid %s: %s", otpid, exc)
+ result_df = pd.DataFrame()
+ # Attach hostname if provided
+ if hostname is not None:
+ result_df["hostname"] = hostname
+ if result_df.empty:
+ logger.info("No activities found for otpid %s (host: %s)", otpid, hostname)
+ self._activities_df = pd.DataFrame()
+ self.activities_table.clear()
+ return
+ # Store and render
+ self._activities_df = result_df.copy()
+ # Rebuild activities_table columns from result_df
+ self.activities_table.clear()
+ # Ensure stable column order
+ for col in result_df.columns:
+ self.activities_table.add_column(col)
+ # Add rows
+ for _, arow in result_df.iterrows():
+ values = ["" if pd.isna(v) else v for v in arow.to_list()]
+ self.activities_table.add_row(*[str(v) for v in values])
+ logger.info(
+ "Loaded %d activity rows for otpid %s (host: %s)",
+ len(result_df),
+ otpid,
+ hostname,
+ )
+
+ async def export_activities(self) -> None:
+ """
+ Export currently-loaded activities DataFrame to CSV.
+ Can be called directly (programmatically) or from the button handler.
+ """
+ if self._activities_df is None or self._activities_df.empty:
+ logger.info("No activities loaded to export.")
+ # On-screen short message
+ await self.post_message(Static("No activities to export."))
+ return
+ working_dir = _load_working_dir()
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ filename = f"otp_activities_{self._selected_session_otpid}_{timestamp}.csv"
+ file_path = os.path.join(working_dir, filename)
+ try:
+ self._activities_df.to_csv(file_path, index=False)
+ logger.info("Exported activities to %s", file_path)
+ await self.post_message(Static(f"✅ Exported activities to: {file_path}"))
+ except Exception as exc:
+ logger.exception("Failed to export activities to %s: %s", file_path, exc)
+ await self.post_message(Static("Failed to export activities; check logs."))
+
+
+class ActivityDetailWidget(Static):
+ """
+ Interactive widget for Activity Detail screen.
+ Shows the provided DataFrame in a DataTable and offers Export + Back buttons.
+ Now includes Select All/None and Add to Allowlist functionality.
+ """
+
+ DEFAULT_CSS = """
+ ActivityDetailWidget {
+ height: 1fr;
+ layout: vertical;
+ }
+ #detail_table_container {
+ height: 1fr;
+ padding: 1 1;
+ }
+ #selection_buttons {
+ height: auto;
+ min-height: 3;
+ padding: 1 1;
+ content-align: center middle;
+ }
+ #detail_buttons {
+ height: auto;
+ min-height: 3;
+ padding: 1 1;
+ content-align: center middle;
+ }
+ """
+
+ def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None:
+ super().__init__()
+ self.activities_df = (
+ activities_df.copy()
+ if isinstance(activities_df, pd.DataFrame)
+ else pd.DataFrame(activities_df)
+ )
+ # Add a unique identifier column if not present
+ if "_row_id" not in self.activities_df.columns:
+ self.activities_df["_row_id"] = range(len(self.activities_df))
+
+ self.otpid = otpid
+ self.hostname = hostname
+ self.selected_row_ids = set() # Track selected rows by unique ID
+ self.row_key_to_id = {} # Map DataTable row keys to unique row IDs
+ self.table_row_to_id = {} # Map table row indices to unique row IDs
+ self._last_sort = None # Track last sort column and order
+
+ def compose(self) -> ComposeResult:
+ yield Static(
+ f"Activity Detail (otpid={self.otpid} host={self.hostname})",
+ classes="panel-title",
+ )
+ # Table container
+ with Vertical(id="detail_table_container"):
+ self.detail_table = DataTable(id="detail_table")
+ yield self.detail_table
+
+ # Original buttons at bottom
+ with Horizontal(id="detail_buttons"):
+ self.detail_back_btn = Button("Back", id="detail_back_btn")
+ self.add_allowlist_btn = Button(
+ "📋 Add Selected to Allowlist", id="add_allowlist_btn"
+ )
+ yield self.add_allowlist_btn
+ yield self.detail_back_btn
+
+ async def on_mount(self) -> None:
+ await self._build_table(rebuild=True)
+ self._update_button_states()
+
+ def _update_button_states(self) -> None:
+ """Update button states based on selection."""
+ has_selection = len(self.selected_row_ids) > 0
+ self.add_allowlist_btn.disabled = not has_selection
+
+ # Update button labels with count
+ count = len(self.selected_row_ids)
+ total = len(self.activities_df)
+
+ if has_selection:
+ self.add_allowlist_btn.label = f"📋 Add {count} Selected to Allowlist"
+ else:
+ self.add_allowlist_btn.label = "📋 Add Selected to Allowlist"
+
+ async def _build_table(self, rebuild: bool = True) -> None:
+ """Rebuild the DataTable. If rebuild=False, only refresh rows."""
+ if rebuild:
+ # Full rebuild: clear columns and rows
+ self.detail_table.clear()
+ self.detail_table.columns.clear()
+ self.row_key_to_id.clear()
+ self.table_row_to_id.clear()
+
+ if self.activities_df is None or self.activities_df.empty:
+ logger.info("ActivityDetailWidget mounted with empty dataframe.")
+ return
+
+ # Add columns (checkbox + data columns, excluding internal _row_id)
+ self.detail_table.add_column("Select", key="select")
+ for col in self.activities_df.columns:
+ if col != "_row_id": # Don't display the internal ID column
+ self.detail_table.add_column(col)
+ else:
+ # Partial rebuild: clear rows only
+ self.detail_table.clear()
+ self.row_key_to_id.clear()
+ self.table_row_to_id.clear()
+
+ # Add rows
+ for table_idx, (df_idx, row) in enumerate(self.activities_df.iterrows()):
+ # Get the unique row ID
+ row_id = row["_row_id"]
+
+ # Build values list (excluding _row_id column)
+ vals = []
+ for col in self.activities_df.columns:
+ if col != "_row_id":
+ v = row[col]
+ vals.append("" if pd.isna(v) else str(v))
+
+ # Check if this row is selected
+ checkbox = "☑" if row_id in self.selected_row_ids else "☐"
+
+ # Add row to table
+ row_key = self.detail_table.add_row(checkbox, *vals)
+
+ # Map the row key and table index to the unique row ID
+ self.row_key_to_id[row_key] = row_id
+ self.table_row_to_id[table_idx] = row_id
+
+ async def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
+ # Toggle selection when the "Select" column is clicked
+ if event.cell_key.column_key.value == "select":
+ table_row_index = event.coordinate.row
+
+ # Get the unique row ID for this table row
+ row_id = self.table_row_to_id.get(table_row_index)
+ if row_id is not None:
+ # Get the row key for updating the cell
+ row_key = event.cell_key.row_key
+
+ if row_id in self.selected_row_ids:
+ self.selected_row_ids.remove(row_id)
+ self.detail_table.update_cell(row_key, "select", "☐") # Unchecked
+ else:
+ self.selected_row_ids.add(row_id)
+ self.detail_table.update_cell(row_key, "select", "☑") # Checked
+
+ self._update_button_states()
+
+ async def on_data_table_header_selected(
+ self, event: DataTable.HeaderSelected
+ ) -> None:
+ column_key = event.column_key.value if event.column_key else None
+ if not column_key:
+ col_index = event.column_index
+ if col_index == 0: # First column is "Select"
+ return
+ # Adjust for hidden _row_id column
+ visible_cols = [
+ col for col in self.activities_df.columns if col != "_row_id"
+ ]
+ if col_index - 1 < len(visible_cols):
+ column_key = visible_cols[col_index - 1]
+ else:
+ return
+ if column_key == "select" or column_key == "_row_id":
+ return
+
+ ascending = True
+ if self._last_sort == (column_key, True):
+ ascending = False
+ self._last_sort = (column_key, ascending)
+
+ try:
+ self.activities_df.sort_values(
+ by=column_key, ascending=ascending, inplace=True
+ )
+ except Exception as exc:
+ logger.exception("Failed to sort by column %s: %s", column_key, exc)
+ return
+
+ # ✅ Only refresh rows, not columns
+ await self._build_table(rebuild=False)
+
+ async def on_button_pressed(self, event) -> None:
+ btn = getattr(event, "button", None) or getattr(event, "sender", None)
+ btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None)
+
+ if btn is self.detail_back_btn or btn_id == "detail_back_btn":
+ while len(self.app.screen_stack) > 2:
+ self.app.pop_screen()
+ event.stop()
+ return
+
+ if btn is self.add_allowlist_btn or btn_id == "add_allowlist_btn":
+ await self._open_allowlist_screen()
+ return
+
+ async def _select_all(self) -> None:
+ """Select all rows in the table."""
+ # Add all row IDs to selected set
+ self.selected_row_ids = set(self.activities_df["_row_id"].tolist())
+
+ # Update all checkboxes in the table
+ for row_key, row_id in self.row_key_to_id.items():
+ self.detail_table.update_cell(row_key, "select", "☑")
+
+ self._update_button_states()
+ logger.info(f"Selected all {len(self.selected_row_ids)} rows")
+
+ async def _select_none(self) -> None:
+ """Deselect all rows in the table."""
+ # Clear selected set
+ self.selected_row_ids.clear()
+
+ # Update all checkboxes in the table
+ for row_key, row_id in self.row_key_to_id.items():
+ self.detail_table.update_cell(row_key, "select", "☐")
+
+ self._update_button_states()
+ logger.info("Cleared all selections")
+
+ async def _open_allowlist_screen(self) -> None:
+ """Open the allowlist selection screen with selected activities."""
+ if not self.selected_row_ids:
+ self.app.notify("No rows selected", severity="warning")
+ return
+
+ # Get selected data
+ selected_df = self.get_selected_data()
+
+ # Get API from app
+ api = getattr(self.app, "api", None)
+ if api is None:
+ logger.error("No API available on self.app.api")
+ self.app.notify("API not available", severity="error")
+ return
+
+ # Create and push AllowlistSelectionScreen
+ try:
+ allowlist_screen = AllowlistSelectionScreen(
+ selected_df, api=api, hostname=self.hostname, otpid=self.otpid
+ )
+ await self.app.push_screen(allowlist_screen)
+ logger.info(
+ f"Opened allowlist screen with {len(selected_df)} selected activities"
+ )
+ except ImportError as e:
+ logger.error(f"Failed to import AllowlistSelectionScreen: {e}")
+ self.app.notify("Allowlist screen module not found", severity="error")
+ except Exception as e:
+ logger.exception(f"Failed to open allowlist screen: {e}")
+ self.app.notify(
+ f"Error opening allowlist screen: {str(e)}", severity="error"
+ )
+
+ async def _export_detail_activities(self) -> None:
+ if self.activities_df is None or self.activities_df.empty:
+ logger.info("No activities to export.")
+ await self.mount(
+ Static("⌠No activities to export.", classes="notification")
+ )
+ return
+ if not self.selected_row_ids:
+ logger.info("No rows selected for export.")
+ await self.mount(
+ Static("⌠No rows selected for export.", classes="notification")
+ )
+ return
+ try:
+ working_dir = load_env("WORKING_DIR") or os.getcwd()
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ filename = f"otp_activities_detail_{timestamp}.csv"
+ file_path = os.path.join(working_dir, filename)
+ selected_df = self.get_selected_data()
+ selected_df.to_csv(file_path, index=False)
+ logger.info("Exported selected activities to %s", file_path)
+ await self.mount(
+ Static(
+ f"✅ Exported selected activities to: {filename}",
+ classes="notification",
+ )
+ )
+ except Exception as exc:
+ logger.exception("Failed to export detail activities: %s", exc)
+ await self.mount(
+ Static(
+ "⌠Failed to export activities; check logs.",
+ classes="notification",
+ )
+ )
+
+ # ✅ Helper methods
+ def get_selected_data(self) -> pd.DataFrame:
+ """Return a DataFrame of the selected rows."""
+ if not self.selected_row_ids:
+ return pd.DataFrame()
+ # Filter by selected row IDs and drop the internal _row_id column
+ selected_df = self.activities_df[
+ self.activities_df["_row_id"].isin(self.selected_row_ids)
+ ].copy()
+ if "_row_id" in selected_df.columns:
+ selected_df = selected_df.drop(columns=["_row_id"])
+ return selected_df
+
+ def get_selected_records(self) -> list[dict]:
+ """Return selected rows as a list of dicts."""
+ if not self.selected_row_ids:
+ return []
+ # Filter by selected row IDs and drop the internal _row_id column
+ selected_df = self.activities_df[
+ self.activities_df["_row_id"].isin(self.selected_row_ids)
+ ].copy()
+ if "_row_id" in selected_df.columns:
+ selected_df = selected_df.drop(columns=["_row_id"])
+ return selected_df.to_dict(orient="records")
+
+
+class ActivityDetailScreen(Screen):
+ """
+ Screen that wraps ActivityDetailWidget. Expects a DataFrame passed on init.
+ """
+
+ BINDINGS = [
+ Binding("b", "back", "Back"),
+ Binding("e", "export", "Export"),
+ Binding("a", "select_all", "Select All"),
+ Binding("n", "select_none", "Select None"),
+ ]
+
+ def __init__(self, activities_df: pd.DataFrame, otpid=None, hostname=None) -> None:
+ super().__init__()
+ self._activities_df = (
+ activities_df.copy()
+ if isinstance(activities_df, pd.DataFrame)
+ else pd.DataFrame(activities_df)
+ )
+ self._otpid = otpid
+ self._hostname = hostname
+
+ def compose(self) -> ComposeResult:
+ self.widget = ActivityDetailWidget(
+ self._activities_df, otpid=self._otpid, hostname=self._hostname
+ )
+ yield Header(show_clock=True)
+ yield self.widget
+ yield Footer()
+
+ async def action_back(self) -> None:
+ try:
+ await self.app.pop_screen()
+ except Exception:
+ logger.debug("ActivityDetailScreen.action_back pop_screen failed.")
+
+ async def action_export(self) -> None:
+ # Delegate to widget export helper
+ if hasattr(self, "widget") and self.widget is not None:
+ await self.widget._export_detail_activities()
+
+ async def action_select_all(self) -> None:
+ """Handle 'a' key for select all."""
+ if hasattr(self, "widget") and self.widget is not None:
+ await self.widget._select_all()
+
+ async def action_select_none(self) -> None:
+ """Handle 'n' key for select none."""
+ if hasattr(self, "widget") and self.widget is not None:
+ await self.widget._select_none()
+
+
+class OTPActivitiesScreen(Screen):
+ """
+ A Screen intended to be pushed into an existing Textual App.
+ Usage:
+ app.push_screen(OTPActivitiesScreen())
+ or create this screen and call `await screen.load()` inside your app lifecycle.
+ The screen expects `self.app.api` to exist and be an AirlockAPIWrapper instance.
+ """
+
+ BINDINGS = [
+ Binding("r", "refresh_sessions", "Refresh Sessions"),
+ Binding("e", "export_activities", "Export activities"),
+ Binding("q", "quit", "Quit"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ self.widget = OTPActivitiesWidget()
+ yield self.widget
+ yield Footer()
+
+ async def on_show(self) -> None:
+ """Restore focus to the left sessions table when the screen becomes visible."""
+ if hasattr(self, "widget") and hasattr(self.widget, "sessions_table"):
+ self.widget.sessions_table.focus()
+
+ async def on_mount(self) -> None:
+ # Try to load sessions immediately
+ api = getattr(self.app, "api", None)
+ if api is None:
+ logger.warning("OTPActivitiesScreen mounted but no self.app.api found.")
+ else:
+ await self.widget.load_sessions_from_api(api)
+
+ # Simple actions bound to keys
+ async def action_refresh_sessions(self) -> None:
+ api = getattr(self.app, "api", None)
+ if api is None:
+ logger.error("No API on app; cannot refresh sessions.")
+ return
+ logger.info("Refreshing OTP sessions via API.")
+ await self.widget.load_sessions_from_api(api)
+
+ async def action_quit(self) -> None:
+ # Pop the screen or exit app
+ await self.app.pop_screen()
+
+ # If you want an explicit method to fetch activities for a particular otpid from outside:
+ async def fetch_activities_for_otpid(self, otpid, hostname=None) -> None:
+ api = getattr(self.app, "api", None)
+ if api is None:
+ logger.error("No API on app; cannot fetch activities.")
+ return
+ await self.widget._fetch_activities_for_otpid(api, otpid, hostname=hostname)
diff --git a/TUI/otpworkflowscreen.py b/TUI/otpworkflowscreen.py
new file mode 100644
index 0000000..d7d7322
--- /dev/null
+++ b/TUI/otpworkflowscreen.py
@@ -0,0 +1,24 @@
+# otp_workflow_screen.py
+
+from typing import List, Optional
+
+from textual.app import ComposeResult
+from textual.screen import Screen
+
+from models.agent import Agent
+from TUI.OTP_generate import OTPGenerator
+
+
+class OTPWorkflowScreen(Screen):
+ """Screen that handles the OTP generation workflow without agent selection."""
+
+ def __init__(self, selected_agents: Optional[List[Agent]]):
+ super().__init__()
+ self.selected_agents = selected_agents
+
+ def compose(self) -> ComposeResult:
+ """Directly show the OTP generator for the selected agents."""
+ yield OTPGenerator(self.selected_agents)
+
+ def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None:
+ """Handle OTP generation request - pass it up to the app level if needed."""
diff --git a/TUI/policyselector.py b/TUI/policyselector.py
new file mode 100644
index 0000000..d6e4bdf
--- /dev/null
+++ b/TUI/policyselector.py
@@ -0,0 +1,488 @@
+"""
+Policy Selector Widget Module
+
+Provides a Textual widget for selecting target policies for bulk agent operations.
+Allows users to browse available policies and select one as the destination for
+moving agents. Automatically excludes parent/logical policies.
+"""
+
+import logging
+import re
+from typing import Optional
+
+import pandas as pd
+from textual.containers import Horizontal, Vertical
+from textual.message import Message
+from textual.widget import Widget
+from textual.widgets import Button, DataTable, Static, TextArea
+
+from models.policy import Policy
+
+logger = logging.getLogger(__name__)
+
+
+class PolicySelector(Widget):
+ """
+ A Textual widget for selecting a target policy for agent operations.
+
+ This widget displays available policies in a table and allows users to select
+ one policy as the destination for bulk agent movements. It automatically excludes:
+ - Parent/logical policies (where parent == "global-policy-settings")
+ - Specified policy IDs (e.g., the current policy)
+
+ Features:
+ - Wildcard filtering (* and ?)
+ - Interactive table for policy browsing
+ - Explicit confirm button for selection
+ - Cancel/back button to dismiss
+
+ Attributes:
+ policies (list[Policy]): List of available Policy objects to display.
+ excluded_policy_ids (set[str]): Set of policy IDs to exclude from selection.
+ selected_policy (Optional[Policy]): The currently selected policy (if any).
+
+ Automatically Filtered Out:
+ - Policies with parent == "global-policy-settings" (parent policies for organization)
+ - Any policies in excluded_policy_ids set
+
+ Example:
+ ```python
+ policies = [policy1, policy2, policy3]
+ widget = PolicySelector(policies, excluded_policy_ids={current_policy.groupid})
+ ```
+ """
+
+ class PolicySelected(Message):
+ """
+ Message posted when a policy is selected.
+
+ Attributes:
+ policy (Policy): The selected policy object.
+ """
+
+ def __init__(self, policy: Policy):
+ super().__init__()
+ self.policy = policy
+
+ def __init__(self, policies: list):
+ """
+ Initialize the PolicySelector widget.
+
+ Args:
+ policies (list): List of Policy objects or DataFrame rows to display.
+ Can be a list of Policy objects or a pandas DataFrame of policy data.
+ """
+ super().__init__()
+ self.policies = policies
+ self.selected_policy: Optional[Policy] = None
+ self._filtered_policies = []
+ self._displayed_policies = [] # Track what's currently shown in the table
+ self._filter_text = ""
+
+ def compose(self):
+ """
+ Build the UI layout for the PolicySelector widget.
+
+ The layout includes:
+ - Title indicating policy selection
+ - Search/filter text area with wildcard support
+ - Filter help text showing wildcard options
+ - Apply Filter button
+ - Clear Filter button
+ - Confirm Selection button
+ - Policy table displaying available policies
+ - Back buttons for navigation
+ """
+ title_text = Static(
+ "🎯 Select Target Policy",
+ id="policy_selector_title",
+ )
+ title_text.styles.margin = (0, 0, 1, 0)
+ yield title_text
+
+ with Horizontal() as main_layout:
+ main_layout.styles.height = "auto"
+
+ # Left side - Filter and controls
+ with Vertical() as left_side:
+ left_side.styles.width = "1fr"
+ left_side.styles.height = "auto"
+ left_side.styles.margin = (0, 1, 0, 1)
+
+ filter_label = Static("Filter Policies:")
+ filter_label.styles.margin = (0, 0, 0, 0)
+ yield filter_label
+
+ filter_input = TextArea(
+ id="policy_filter",
+ text="",
+ )
+ filter_input.styles.height = 3
+ filter_input.styles.margin = (0, 0, 1, 0)
+ yield filter_input
+
+ filter_help = Static("(Use * and ? for wildcards)", id="filter_help")
+ filter_help.styles.margin = (0, 0, 1, 0)
+ yield filter_help
+
+ apply_button = Button("✓ Apply Filter", id="filter_button")
+ apply_button.styles.width = "100%"
+ apply_button.styles.margin = (0, 0, 1, 0)
+ yield apply_button
+
+ clear_button = Button("Clear Filter", id="clear_filter_button")
+ clear_button.styles.width = "100%"
+ clear_button.styles.margin = (0, 0, 1, 0)
+ yield clear_button
+
+ confirm_button = Button("✅ Confirm Selection", id="confirm_button")
+ confirm_button.styles.width = "100%"
+ confirm_button.styles.margin = (1, 0, 1, 0)
+ yield confirm_button
+
+ selected_label = Static("", id="selected_policy_label")
+ selected_label.styles.margin = (2, 0, 1, 0)
+ yield selected_label
+
+ cancel_button = Button("← Back", id="back_button")
+ cancel_button.styles.width = "100%"
+ cancel_button.styles.margin = (1, 0, 1, 0)
+ yield cancel_button
+
+ # Right side - Policy table
+ with Vertical() as right_side:
+ right_side.styles.width = "2fr"
+ right_side.styles.height = "auto"
+
+ table_label = Static("Available Policies:")
+ table_label.styles.margin = (0, 0, 0, 0)
+ yield table_label
+
+ policy_table = DataTable(id="policy_table", cursor_type="row")
+ policy_table.styles.height = "1fr"
+ policy_table.styles.margin = (1, 0, 1, 0)
+ yield policy_table
+
+ def on_mount(self) -> None:
+ """
+ Initialize the policy table when the widget is mounted.
+
+ Populates the table with column (Policy Name) and rows for each
+ available policy (excluding those in excluded_policy_ids and parent policies).
+ Sets up event handlers for table row selection.
+
+ Filters out:
+ - Parent policies (where parent == "global-policy-settings")
+ """
+ table = self.query_one("#policy_table", DataTable)
+
+ # Configure table for row selection
+ table.cursor_type = "row"
+ table.zebra_stripes = True
+
+ # Only add Policy Name column
+ table.add_columns("Policy Name")
+
+ # Filter out excluded policies and convert to list if DataFrame
+ if isinstance(self.policies, pd.DataFrame):
+ policies_list = self.policies.to_dict("records")
+ else:
+ policies_list = self.policies
+ policies_list = sorted(policies_list)
+
+ self._filtered_policies = []
+ self._displayed_policies = [] # Initialize displayed list
+
+ for policy_data in policies_list:
+ # Handle both Policy objects and dict/DataFrame rows
+ if isinstance(policy_data, Policy):
+ policy_id = policy_data.groupid
+ policy_name = policy_data.name
+ parent = policy_data.parent
+ else:
+ policy_id = policy_data.get("groupid", "Unknown")
+ policy_name = policy_data.get("name", "Unknown")
+ parent = policy_data.get("parent", None)
+
+ # Skip parent policies (logical policies that shouldn't have devices)
+ if parent == "global-policy-settings":
+ logger.debug(f"Skipping parent policy: {policy_name}")
+ continue
+
+ self._filtered_policies.append(policy_data)
+ self._displayed_policies.append(policy_data) # Add to displayed list
+
+ table.add_row(
+ policy_name,
+ key=policy_id,
+ )
+
+ def on_button_pressed(self, event: Button.Pressed):
+ """
+ Handle button press events from the widget.
+
+ Routes to:
+ - back_button (Cancel): Pop screen without selecting
+ - filter_button (Apply Filter): Filter policies with wildcard support
+ - clear_filter_button: Clear filter and show all policies
+ - confirm_button: Confirm selection and post message
+
+ Args:
+ event (Button.Pressed): The button press event.
+ """
+ btn_id = event.button.id
+
+ if btn_id == "back_button":
+ while len(self.app.screen_stack) > 2:
+ self.app.pop_screen()
+ event.stop()
+
+ elif btn_id == "filter_button":
+ self._apply_filter()
+ event.stop()
+
+ elif btn_id == "clear_filter_button":
+ self._clear_filter()
+ event.stop()
+
+ elif btn_id == "confirm_button":
+ self._confirm_selection()
+ event.stop()
+
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
+ """
+ Handle row selection in the policy table.
+
+ Updates the selected_policy and displays the selection in the UI.
+
+ Args:
+ event: DataTable.RowSelected event containing the selected row data.
+ """
+ try:
+ # Get the row key from the event
+ row_key = event.row_key
+ if row_key is None:
+ return
+
+ # Find the policy with matching groupid
+ for policy_data in self._displayed_policies:
+ if isinstance(policy_data, Policy):
+ if policy_data.groupid == row_key.value:
+ self.selected_policy = policy_data
+ break
+ else:
+ if policy_data.get("groupid") == row_key.value:
+ self.selected_policy = Policy(
+ groupid=policy_data.get("groupid"),
+ hidden=policy_data.get("hidden", False),
+ name=policy_data.get("name"),
+ parent=policy_data.get("parent"),
+ )
+ break
+
+ if self.selected_policy:
+ # Update selection display
+ label = self.query_one("#selected_policy_label", Static)
+ label.update(f"✓ Selected: {self.selected_policy.name}")
+
+ # Log for debugging
+ logger.debug(
+ f"Selected policy: {self.selected_policy.name} (ID: {self.selected_policy.groupid})"
+ )
+ self.app.notify(
+ f"Selected: {self.selected_policy.name}",
+ severity="information",
+ timeout=1,
+ )
+
+ except Exception as e:
+ logger.error(f"Error handling row selection: {e}")
+ self.app.notify(f"Selection error: {str(e)}", severity="error")
+
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
+ """
+ Handle row highlighting (cursor movement) in the table.
+
+ This provides immediate visual feedback when navigating rows.
+ """
+ try:
+ # Get the row key from the event
+ row_key = event.row_key
+ if row_key is None:
+ return
+
+ # Find the highlighted policy
+ highlighted_name = None
+ for policy_data in self._displayed_policies:
+ if isinstance(policy_data, Policy):
+ if policy_data.groupid == row_key.value:
+ highlighted_name = policy_data.name
+ break
+ else:
+ if policy_data.get("groupid") == row_key.value:
+ highlighted_name = policy_data.get("name")
+ break
+
+ if highlighted_name:
+ label = self.query_one("#selected_policy_label", Static)
+ label.update(f"→ Highlighting: {highlighted_name}")
+
+ except Exception as e:
+ logger.error(f"Error handling row highlight: {e}")
+
+ def _apply_filter(self) -> None:
+ """
+ Apply filter text to policy list with wildcard support.
+
+ Supports wildcards:
+ - * matches any sequence of characters
+ - ? matches a single character
+
+ Examples:
+ - "policy*" matches "policy_prod", "policy_dev", etc.
+ - "policy?" matches "policy1", "policy2", etc.
+ - "*audit*" matches anything containing "audit"
+ - "*test*" matches "AT Testing", "test_policy", etc.
+
+ Filters policies by name or ID (case-insensitive) and refreshes the table display
+ with only matching policies. Only filters from already-filtered list
+ (which excludes parent policies and excluded IDs).
+ """
+ try:
+ filter_input = self.query_one("#policy_filter", TextArea)
+ filter_text = filter_input.text.strip()
+
+ table = self.query_one("#policy_table", DataTable)
+ table.clear()
+
+ # Clear the displayed policies list
+ self._displayed_policies = []
+
+ # Compile wildcard pattern if filter text is provided
+ pattern = None
+ if filter_text:
+ # Escape special regex chars but preserve wildcards
+ pattern_text = re.escape(filter_text.lower())
+ pattern_text = pattern_text.replace(r"\*", ".*").replace(r"\?", ".")
+ # Use search() for partial matching
+ pattern = re.compile(pattern_text, re.IGNORECASE)
+
+ # Filter policies based on search text
+ for policy_data in self._filtered_policies:
+ # Handle both Policy objects and dict/DataFrame rows
+ if isinstance(policy_data, Policy):
+ policy_name = policy_data.name.lower()
+ policy_id = policy_data.groupid.lower()
+ display_name = policy_data.name
+ key_id = policy_data.groupid
+ else:
+ policy_name = str(policy_data.get("name", "")).lower()
+ policy_id = str(policy_data.get("groupid", "Unknown")).lower()
+ display_name = policy_data.get("name")
+ key_id = policy_data.get("groupid")
+
+ # Match against filter text with wildcard support
+ if pattern:
+ # Use search() for partial matching
+ matches = pattern.search(policy_name) or pattern.search(policy_id)
+ else:
+ matches = True
+
+ if matches:
+ # Add to displayed policies list
+ self._displayed_policies.append(policy_data)
+
+ # Add row to table
+ table.add_row(
+ display_name,
+ key=key_id,
+ )
+
+ displayed_count = len(self._displayed_policies)
+ status_text = f"📊 Showing {displayed_count} of {len(self._filtered_policies)} policies"
+ self.app.notify(status_text, severity="information", timeout=2)
+
+ # Clear selection when filter is applied
+ self.selected_policy = None
+ label = self.query_one("#selected_policy_label", Static)
+ label.update("")
+
+ except Exception as e:
+ logger.error(f"Error applying filter: {e}")
+ self.app.notify(f"❌ Filter error: {str(e)}", severity="error")
+
+ def _clear_filter(self) -> None:
+ """
+ Clear the filter and display all available policies.
+
+ Resets the filter text and refreshes the table to show all policies
+ (already excluding parent policies and excluded IDs).
+ """
+ try:
+ filter_input = self.query_one("#policy_filter", TextArea)
+ filter_input.text = ""
+
+ table = self.query_one("#policy_table", DataTable)
+ table.clear()
+
+ # Reset displayed policies to all filtered policies
+ self._displayed_policies = list(self._filtered_policies)
+
+ # Reload all policies
+ for policy_data in self._filtered_policies:
+ if isinstance(policy_data, Policy):
+ policy_id = policy_data.groupid
+ policy_name = policy_data.name
+ else:
+ policy_id = policy_data.get("groupid", "Unknown")
+ policy_name = policy_data.get("name", "Unknown")
+
+ # Add row with only policy name
+ table.add_row(
+ policy_name,
+ key=policy_id,
+ )
+
+ self.selected_policy = None
+ label = self.query_one("#selected_policy_label", Static)
+ label.update("")
+
+ except Exception as e:
+ logger.error(f"Error clearing filter: {e}")
+
+ def on_text_area_changed(self, event) -> None:
+ """
+ Handle TextArea change events - specifically for Enter key in filter.
+
+ When the user types in the filter TextArea and the text ends with a newline,
+ treat it as pressing Enter and apply the filter.
+ """
+ if event.text_area.id == "policy_filter":
+ # Check if the text ends with a newline (Enter was pressed)
+ if event.text_area.text.endswith("\n"):
+ # Remove the newline that was added
+ event.text_area.text = event.text_area.text.rstrip("\n")
+ # Apply the filter
+ self._apply_filter()
+
+ def _confirm_selection(self) -> None:
+ """
+ Confirm the selected policy and post selection message.
+
+ Posts a PolicySelected message to the parent widget/screen with the
+ selected policy. If no policy is selected, displays an error notification.
+ """
+ if self.selected_policy is None:
+ self.app.notify(
+ "⚠️ Please select a policy first by clicking on a row in the table",
+ severity="warning",
+ timeout=3,
+ )
+ return
+
+ # Log confirmation for debugging
+ logger.info(f"Confirming selection of policy: {self.selected_policy.name}")
+ self.app.notify(
+ f"✅ Confirmed: {self.selected_policy.name}", severity="success", timeout=2
+ )
+ self.post_message(self.PolicySelected(self.selected_policy))
diff --git a/TUI/policyselectorscreen.py b/TUI/policyselectorscreen.py
new file mode 100644
index 0000000..07ec2ee
--- /dev/null
+++ b/TUI/policyselectorscreen.py
@@ -0,0 +1,91 @@
+# Copyright (C) 2025 James Brotosky, Brandon Wickline
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""
+Policy Selector Screen Module
+
+Provides a Textual Screen wrapper for the PolicySelector widget that manages
+the policy selection workflow.
+"""
+
+import logging
+
+from textual.app import ComposeResult
+from textual.screen import Screen
+
+from TUI.policyselector import PolicySelector
+
+logger = logging.getLogger(__name__)
+
+
+class PolicySelectorScreen(Screen):
+ """
+ A Textual Screen for policy selection in agent move operations.
+
+ This screen wraps the PolicySelector widget and manages the workflow
+ of selecting a target policy for bulk agent movements.
+
+ Attributes:
+ policies: List of available policies (Policy objects or DataFrame).
+ agent_move_operations: Reference to the parent AgentMoveOperations widget.
+ """
+
+ CSS = """
+ Screen {
+ layout: vertical;
+ background: $surface;
+ }
+ """
+
+ def __init__(
+ self,
+ policies,
+ agent_move_operations=None,
+ ):
+ """
+ Initialize the PolicySelectorScreen.
+
+ Args:
+ policies: List of available policies to display.
+ agent_move_operations: Reference to parent AgentMoveOperations widget.
+ Used to call back when policy selection is confirmed.
+ """
+ super().__init__()
+ self.policies = policies
+ self.agent_move_operations = agent_move_operations
+
+ def compose(self) -> ComposeResult:
+ """Create the PolicySelector widget."""
+ yield PolicySelector(self.policies)
+
+ def on_policy_selector_policy_selected(
+ self, message: PolicySelector.PolicySelected
+ ) -> None:
+ """
+ Handle policy selection from the PolicySelector widget.
+
+ When a policy is selected, this handler:
+ 1. Closes the selector screen
+ 2. Calls the parent AgentMoveOperations to execute the move
+
+ Args:
+ message (PolicySelector.PolicySelected): Contains the selected policy.
+ """
+ # Pop this screen to return to AgentMoveOperations
+ self.app.pop_screen()
+
+ # Call parent widget's method to execute the move
+ if self.agent_move_operations:
+ self.agent_move_operations._execute_move_to_policy(message.policy)
diff --git a/widgets/policytreewidget.py b/TUI/policytreewidget.py
similarity index 53%
rename from widgets/policytreewidget.py
rename to TUI/policytreewidget.py
index 3a0efff..5e01fdf 100644
--- a/widgets/policytreewidget.py
+++ b/TUI/policytreewidget.py
@@ -1,9 +1,10 @@
+from collections import defaultdict
import logging
from rich.text import Text
from textual.containers import Horizontal, Vertical
from textual.widget import Widget
-from textual.widgets import Input, OptionList, Static, Tree
+from textual.widgets import Input, OptionList, Static, Switch, Tree
from textual.widgets.option_list import Option
logger = logging.getLogger(__name__)
@@ -17,67 +18,145 @@ class PolicyTreeWidget(Widget):
self.policies = policies
self.devices = devices
self.last_highlighted_node = None
+ self.leaf_counts = defaultdict(int)
+ self.match_type = "Count" # Default to sorting by count
def compose(self):
- # Left: Policy Tree
- policy_tree = Tree("Policies", id="policy_tree")
+ # Create the switch and its label
+ switch = Switch(value=False, id="match_switch")
+ switch.styles.margin = (0, 0, 0, 0) # top, right, bottom, left
+ switch.styles.padding = (0, 0, 0, 0)
+
+ switch_label = Static("Sort: Count", id="match_switch_label")
+ switch_label.styles.margin = (1, 0, 0, 0)
+ switch_label.styles.padding = (0, 0, 0, 0)
+
+ # Create the tree
+ policy_tree = Tree("", id="policy_tree") # Label set in on_mount
policy_tree.styles.width = "2fr"
policy_tree.styles.height = "100%"
- # Right: Search + Details
+ # Create the search box and details pane
label = Static("Device Search:")
search_box = Input(
placeholder="Search policies or devices...", id="tree_search"
)
details_pane = Static("", id="details_pane")
+ # Layout the UI
with Horizontal():
yield policy_tree
with Vertical() as right_pane:
right_pane.styles.width = "3fr"
+ # Use a Horizontal container for the switch and label
+ with Horizontal() as switch_container:
+ switch_container.styles.height = 3
+ switch_container.styles.margin = (0, 0, 0, 1)
+ switch_container.styles.padding = (0, 0, 0, 0)
+ yield switch
+ yield switch_label
+ # Add the search box and details pane
yield label
yield search_box
yield details_pane
def on_mount(self) -> None:
- """Build the tree after mounting."""
- self._build_tree()
-
- def _build_tree(self) -> None:
- """Build the policy tree structure."""
+ self._precompute_leaf_counts()
+ # Update root label with total leaf count
+ total_leaves = sum(
+ self.leaf_counts.get(policy.groupid, 0)
+ for policy in self.policies
+ if policy.parent == "global-policy-settings"
+ )
policy_tree = self.query_one("#policy_tree", Tree)
- node_map = {}
+ policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})")
+ self._build_tree()
+ # Expand the root node
+ policy_tree.root.expand()
+
+ def _precompute_leaf_counts(self):
+ """Precompute leaf counts for each policy group."""
+ device_counts = defaultdict(int)
+ for device in self.devices:
+ device_counts[device.groupid] += 1
+
+ child_map = defaultdict(list)
+ for policy in self.policies:
+ child_map[policy.parent].append(policy.groupid)
+
+ def count_leaves(groupid):
+ count = device_counts[groupid]
+ for child_id in child_map.get(groupid, []):
+ count += count_leaves(child_id)
+ self.leaf_counts[groupid] = count
+ return count
- # Top-level policies
for policy in self.policies:
if policy.parent == "global-policy-settings":
- node = policy_tree.root.add(label=policy.name, data=policy)
- node_map[policy.groupid] = node
+ count_leaves(policy.groupid)
- # Child policies
+ def _build_tree(self):
+ policy_tree = self.query_one("#policy_tree", Tree)
+ policy_tree.clear() # Clear existing nodes
+ node_map = {}
+
+ # Sort top-level policies
+ top_policies = [
+ p for p in self.policies if p.parent == "global-policy-settings"
+ ]
+
+ # Sort by count (default) or alphabetically
+ if getattr(self, "match_type", "Count") == "Count":
+ top_policies.sort(
+ key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
+ )
+ else: # Alphabetical
+ top_policies.sort(key=lambda p: p.name.lower())
+
+ for policy in top_policies:
+ label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
+ node = policy_tree.root.add(label=label, data=policy)
+ node_map[policy.groupid] = node
+
+ # Sort and add child policies
+ children_by_parent = defaultdict(list)
for policy in self.policies:
- parent_id = policy.parent
- if parent_id in node_map:
- parent_node = node_map[parent_id]
- node = parent_node.add(label=policy.name, data=policy)
- node_map[policy.groupid] = node
+ if policy.parent != "global-policy-settings":
+ children_by_parent[policy.parent].append(policy)
- # Devices under policies
+ for parent_id, children in children_by_parent.items():
+ if getattr(self, "match_type", "Count") == "Count":
+ children.sort(
+ key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
+ )
+ else: # Alphabetical
+ children.sort(key=lambda p: p.name.lower())
+
+ parent_node = node_map.get(parent_id)
+ if parent_node:
+ for policy in children:
+ label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
+ node = parent_node.add(label=label, data=policy)
+ node_map[policy.groupid] = node
+
+ # Add devices (leaf nodes) - always sort alphabetically
+ devices_by_group = defaultdict(list)
for device in self.devices:
- group_id = device.groupid
- if group_id in node_map:
- parent_node = node_map[group_id]
- label = device.hostname
- parent_node.add(label=label, data=device)
+ devices_by_group[device.groupid].append(device)
+
+ for group_id, devices in devices_by_group.items():
+ devices.sort(key=lambda d: d.hostname.lower()) # Always sort alphabetically
+ parent_node = node_map.get(group_id)
+ if parent_node:
+ for device in devices:
+ parent_node.add(label=device.hostname, data=device)
def _collect_tree_nodes(self, node, all_nodes):
- """Helper to recursively collect all nodes from a tree."""
all_nodes.append(node)
for child in node.children:
self._collect_tree_nodes(child, all_nodes)
def _remove_match_selector(self):
- """Safely remove match selector widgets."""
try:
existing = self.query("#match_selector")
for widget in existing:
@@ -87,20 +166,16 @@ class PolicyTreeWidget(Widget):
logger.debug("Failed to remove match_selector: %s", exc)
def on_tree_node_selected(self, message: Tree.NodeSelected) -> None:
- """Handle tree node selection."""
node = message.node
data = node.data
details_pane = self.query_one("#details_pane", Static)
- # Reset previous highlight
if self.last_highlighted_node is not None:
original_label = str(self.last_highlighted_node.label).strip()
- # Remove any styling
if isinstance(self.last_highlighted_node.label, Text):
original_label = self.last_highlighted_node.label.plain
self.last_highlighted_node.set_label(original_label)
- # Apply highlight to current node
label_text = str(node.label).strip()
if isinstance(node.label, Text):
label_text = node.label.plain
@@ -108,9 +183,7 @@ class PolicyTreeWidget(Widget):
node.set_label(highlighted_label)
self.last_highlighted_node = node
- # Update details pane
if data:
- # Work with dataclass objects using __dict__
details = "\n".join(
f"{key}: {value}" for key, value in data.__dict__.items()
)
@@ -118,12 +191,16 @@ class PolicyTreeWidget(Widget):
details = f"Selected: {node.label}"
details_pane.update(details)
- # Stop event from bubbling
message.stop()
+ def on_switch_changed(self, event: Switch.Changed):
+ self.match_type = "Alpha" if event.value else "Count"
+ self.query_one("#match_switch_label", Static).update(
+ f"Sort: {self.match_type.capitalize()}"
+ )
+ self._build_tree()
+
def on_input_submitted(self, message: Input.Submitted) -> None:
- """Handle search input submission."""
- # Remove existing match selector FIRST
self._remove_match_selector()
query = message.value.strip().lower()
@@ -138,7 +215,6 @@ class PolicyTreeWidget(Widget):
label_text = str(node.label).lower()
label_to_node[label_text] = node
if node.data:
- # Use __dict__ for dataclass objects
data_dict = (
node.data.__dict__ if hasattr(node.data, "__dict__") else node.data
)
@@ -146,18 +222,15 @@ class PolicyTreeWidget(Widget):
if isinstance(value, str):
label_to_node[value.lower()] = node
- # Wildcard-style substring match
matches = sorted([label for label in label_to_node if query in label])
if matches:
- # Try to reuse existing match_selector or create new one
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.clear_options()
- option_list.display = True # Ensure it's visible
+ option_list.display = True
except:
option_list = OptionList(id="match_selector")
- # Mount to the details pane's parent (the Vertical container)
details_pane.parent.mount(option_list)
for label in matches:
@@ -165,17 +238,14 @@ class PolicyTreeWidget(Widget):
details_pane.update(f"Found {len(matches)} matches. Select one below.")
else:
- # Hide or remove the match_selector when no matches
self._remove_match_selector()
details_pane.update("No matches found.")
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
- """Handle selection from search results."""
selected_id = event.option.id.replace("match_", "")
tree = self.query_one("#policy_tree", Tree)
details_pane = self.query_one("#details_pane", Static)
- # Find the node
all_nodes = []
self._collect_tree_nodes(tree.root, all_nodes)
@@ -183,7 +253,6 @@ class PolicyTreeWidget(Widget):
match_node = label_to_node.get(selected_id.lower())
if match_node:
- # Expand path (original working logic)
node = match_node
path = []
while node:
@@ -198,7 +267,6 @@ class PolicyTreeWidget(Widget):
match_node.set_label(Text(str(match_node.label), style="reverse bold"))
details_pane.update(f"Selected: {match_node.label}")
- # Remove the match_selector after selection
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.remove()
diff --git a/TUI/quietagentworkflowscreen.py b/TUI/quietagentworkflowscreen.py
new file mode 100644
index 0000000..552f214
--- /dev/null
+++ b/TUI/quietagentworkflowscreen.py
@@ -0,0 +1,848 @@
+# Copyright (C) 2025 James Brotosky, Brandon Wickline
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""
+Quiet Agent Workflow Screen Module
+
+Provides a TUI workflow for identifying quiet agents and moving them to target policies.
+This screen replaces the legacy quietAgent.py with a comprehensive TUI interface that:
+1. Allows selection of an initial policy to analyze
+2. Categorizes devices into "Enforce Ready" and "Non-Enforce Ready" based on activity
+3. Allows users to select target policies for each category
+4. Uses the API to move devices to their target policies
+"""
+
+import datetime
+import logging
+import os
+from typing import List, Optional
+
+import pandas as pd
+from textual.app import ComposeResult
+from textual.containers import Horizontal, Vertical
+from textual.reactive import reactive
+from textual.screen import Screen
+from textual.widgets import Button, DataTable, Footer, Header, Static
+
+from models.policy import Policy
+from services.API import AirlockAPIWrapper
+from services.policyhandler import getPolicyInfo
+from TUI.policyselector import PolicySelector
+from utils.configmanager import load_env
+
+logger = logging.getLogger(__name__)
+
+
+class QuietAgentWorkflowScreen(Screen):
+ """
+ A Textual screen for the Quiet Agent analysis and migration workflow.
+
+ This screen provides a multi-step workflow:
+ 1. Select initial policy to analyze
+ 2. View categorized agents (enforce ready vs. non-enforce ready)
+ 3. Select target policies for each category
+ 4. Execute agent migrations
+
+ Attributes:
+ api (AirlockAPIWrapper): API wrapper for Airlock operations
+ policies (List[Policy]): List of all available policies
+ selected_policy (Optional[Policy]): The initially selected policy to analyze
+ history_days (int): Number of days of history to pull (default: 150)
+ quiet_days (int): Number of days without execution to be considered quiet (default: 45)
+ agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results
+ enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement
+ non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement
+ workflow_stage (str): Current stage of the workflow
+ """
+
+ BINDINGS = [
+ ("escape", "go_back", "Back"),
+ ]
+
+ workflow_stage = reactive("select_policy") # Tracks current workflow stage
+
+ def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]):
+ """
+ Initialize the QuietAgentWorkflowScreen.
+
+ Args:
+ api (AirlockAPIWrapper): API wrapper for Airlock operations
+ policies (List[Policy]): List of all available policies
+ """
+ super().__init__()
+ self.api = api
+ self.policies = policies
+ self.selected_policy: Optional[Policy] = None
+ self.history_days = 150 # Fixed as per requirements
+ self.quiet_days = 45 # Default value
+ self.agents_df: Optional[pd.DataFrame] = None
+ self.enforce_ready_df: Optional[pd.DataFrame] = None
+ self.non_enforce_ready_df: Optional[pd.DataFrame] = None
+ self.enforce_ready_target_policy: Optional[Policy] = None
+ self.non_enforce_ready_target_policy: Optional[Policy] = None
+
+ def compose(self) -> ComposeResult:
+ """Build the UI layout for the workflow screen."""
+ # Include Header and Footer like other standalone screens
+ yield Header(show_clock=True, icon="⚙")
+
+ # Title area
+ title = Static("🔒 Quiet Agent Workflow", id="workflow_title")
+ title.styles.margin = (0, 0, 0, 1)
+ yield title
+
+ # Status area
+ status = Static("Step 1: Select Policy to Analyze", id="workflow_status")
+ status.styles.margin = (0, 0, 1, 1)
+ yield status
+
+ # Content area - dynamically populated based on workflow stage
+ yield Vertical(id="content_area")
+
+ yield Footer()
+
+ def on_mount(self) -> None:
+ """Initialize the screen when mounted."""
+ # Show initial policy selection
+ self._show_policy_selection()
+
+ def watch_workflow_stage(self, old_value: str, new_value: str) -> None:
+ """React to workflow stage changes."""
+ logger.debug(f"Workflow stage changed from {old_value} to {new_value}")
+ self._update_status_message()
+
+ def _update_status_message(self) -> None:
+ """Update the status message based on current workflow stage."""
+ status_widget = self.query_one("#workflow_status", Static)
+
+ stage_messages = {
+ "select_policy": "Step 1: Select Policy to Analyze",
+ "select_quiet_days": "Step 2: Select Quiet Time Period",
+ "analyzing": "📊 Analyzing agent activity...",
+ "view_results": "Step 3: Review Categorized Agents",
+ "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents",
+ "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents",
+ "confirm_migration": "Step 6: Confirm and Execute Migration",
+ "executing": "⏳ Executing agent migrations...",
+ "complete": "✅ Migration Complete",
+ }
+
+ status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage"))
+
+ def _show_policy_selection(self) -> None:
+ """Show the initial policy selection screen."""
+ self.workflow_stage = "select_policy"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Create policy selector widget
+ policy_selector = PolicySelector(self.policies)
+ content.mount(policy_selector)
+
+ def on_policy_selector_policy_selected(
+ self, message: PolicySelector.PolicySelected
+ ) -> None:
+ """Handle policy selection from PolicySelector widget."""
+ # Handle based on current workflow stage
+ if self.workflow_stage == "select_policy":
+ # Initial policy selection for analysis
+ self.selected_policy = message.policy
+ logger.info(f"Selected policy for analysis: {self.selected_policy.name}")
+ self._show_quiet_days_selection()
+ elif self.workflow_stage == "select_enforce_target":
+ # Target policy selection for enforce ready agents
+ self.enforce_ready_target_policy = message.policy
+ logger.info(
+ f"Selected target policy for enforce ready: {message.policy.name}"
+ )
+ self._show_non_enforce_target_selection()
+ elif self.workflow_stage == "select_non_enforce_target":
+ # Target policy selection for non-enforce ready agents
+ self.non_enforce_ready_target_policy = message.policy
+ logger.info(
+ f"Selected target policy for non-enforce ready: {message.policy.name}"
+ )
+ self._show_migration_confirmation()
+
+ def _show_quiet_days_selection(self) -> None:
+ """Show the quiet days selection screen."""
+ self.workflow_stage = "select_quiet_days"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Create info text
+ info_widget = Static(
+ f"Policy Selected: {self.selected_policy.name}\n\n"
+ f"History Period: {self.history_days} days\n\n"
+ "Select quiet time period (days without untrusted execution):",
+ id="quiet_days_info",
+ )
+ info_widget.styles.margin = (0, 0, 2, 0)
+ content.mount(info_widget)
+
+ # Create button container and mount it first
+ button_container = Vertical(id="quiet_days_buttons")
+ button_container.styles.height = "auto"
+ content.mount(button_container)
+
+ # Now add buttons to the mounted container
+ for days in [15, 30, 45, 60]:
+ btn = Button(
+ f"{days} days {'(Default)' if days == 45 else ''}",
+ id=f"quiet_days_{days}",
+ classes="quiet_day_btn",
+ )
+ btn.styles.width = "100%"
+ btn.styles.margin = (0, 0, 1, 0)
+ button_container.mount(btn)
+
+ back_btn = Button("← Back", id="back_to_policy_selection")
+ back_btn.styles.width = "100%"
+ back_btn.styles.margin = (2, 0, 0, 0)
+ button_container.mount(back_btn)
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handle button press events."""
+ button_id = event.button.id
+
+ # Quiet days selection buttons
+ if button_id and button_id.startswith("quiet_days_"):
+ days = int(button_id.split("_")[-1])
+ self.quiet_days = days
+ logger.info(f"Selected quiet days: {days}")
+ self._start_analysis()
+ return
+
+ # Navigation buttons
+ if button_id == "back_to_policy_selection":
+ self._show_policy_selection()
+ return
+
+ if button_id == "back_to_results":
+ self._show_results()
+ return
+
+ if button_id == "select_enforce_target_btn":
+ self._show_enforce_target_selection()
+ return
+
+ if button_id == "select_non_enforce_target_btn":
+ self._show_non_enforce_target_selection()
+ return
+
+ if button_id == "skip_enforce_target_btn":
+ # Skip enforce ready target selection
+ self.enforce_ready_target_policy = None
+ self._show_non_enforce_target_selection()
+ return
+
+ if button_id == "skip_non_enforce_target_btn":
+ # Skip non-enforce ready target selection
+ self.non_enforce_ready_target_policy = None
+ self._show_migration_confirmation()
+ return
+
+ if button_id == "confirm_migration_btn":
+ self._execute_migration()
+ return
+
+ if button_id == "cancel_migration_btn":
+ self._show_results()
+ return
+
+ if button_id == "export_results_btn":
+ self._export_results()
+ return
+
+ if button_id == "start_over_btn":
+ self._show_policy_selection()
+ return
+
+ def _start_analysis(self) -> None:
+ """Start the agent activity analysis."""
+ self.workflow_stage = "analyzing"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Show analyzing message with detailed steps
+ analyzing_msg = Static(
+ f"📊 Analyzing Agent Activity\n"
+ f"{'=' * 50}\n\n"
+ f"Policy: {self.selected_policy.name}\n"
+ f"History Period: {self.history_days} days\n"
+ f"Quiet Threshold: {self.quiet_days} days\n\n"
+ f"Progress:\n"
+ f"⏳ Step 1/4: Fetching agents from policy...\n"
+ f"⏱️ Step 2/4: Pulling execution history (this may take a moment)...\n"
+ f"⏱️ Step 3/4: Analyzing activity patterns...\n"
+ f"⏱️ Step 4/4: Categorizing agents...\n\n"
+ f"Please wait - this operation cannot be cancelled.",
+ id="analyzing_message",
+ )
+ analyzing_msg.styles.margin = (2, 1)
+ content.mount(analyzing_msg)
+
+ # Show notification
+ self.app.notify(
+ "Starting analysis - this may take several minutes for large policies",
+ severity="information",
+ timeout=5,
+ )
+
+ # Perform the analysis asynchronously
+ self.call_later(self._perform_analysis)
+
+ def _perform_analysis(self) -> None:
+ """Perform the actual agent activity analysis."""
+ try:
+ # Update status: Fetching agents
+ self._update_analysis_status("Step 1/4: Fetching agents from policy...")
+
+ # Get agents in the selected policy
+ agents = self.api.agents_find_by_group(self.selected_policy.groupid)
+
+ if agents.empty:
+ self.app.notify(
+ f"No agents found in policy: {self.selected_policy.name}",
+ severity="warning",
+ timeout=5,
+ )
+ self._show_policy_selection()
+ return
+
+ agent_count = len(agents)
+ self.app.notify(
+ f"Found {agent_count} agents - fetching execution history...",
+ severity="information",
+ timeout=3,
+ )
+
+ # Update status: Pulling execution history
+ self._update_analysis_status(
+ f"Step 2/4: Pulling execution history for {agent_count} agents...\n"
+ f"(This may take several minutes - progress shown in terminal)"
+ )
+
+ # Get execution history (this shows progress bars in terminal via airlock_libs)
+ policy_exec_history = getPolicyInfo(
+ self.api, self.selected_policy, [1, 2, 6, 7], self.history_days
+ )
+
+ # Update status: Analyzing patterns
+ self._update_analysis_status("Step 3/4: Analyzing activity patterns...")
+ self.app.notify(
+ "History retrieved - analyzing patterns...",
+ severity="information",
+ timeout=2,
+ )
+
+ if policy_exec_history.empty:
+ logger.info(
+ "No execution history found for the selected policy and time range."
+ )
+ # All agents are quiet (no executions)
+ agents["execution_count"] = 0
+ agents["days_since"] = None
+ agents["required_quiet"] = self.quiet_days
+ agents["enforce_ready"] = True
+ else:
+ # Convert datetime column
+ policy_exec_history["datetime"] = pd.to_datetime(
+ policy_exec_history["datetime"],
+ format="%Y-%m-%dT%H:%M:%SZ",
+ utc=True,
+ )
+
+ # Calculate days ago
+ now = datetime.datetime.now(datetime.timezone.utc)
+ policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply(
+ lambda dt: (now - dt).days
+ )
+
+ # Count total executions per hostname
+ hostname_counts = policy_exec_history["hostname"].value_counts()
+ agents["execution_count"] = (
+ agents["hostname"].map(hostname_counts).fillna(0).astype(int)
+ )
+
+ # Find most recent execution per hostname
+ most_recent_exec = policy_exec_history.sort_values(
+ by="days_ago"
+ ).drop_duplicates(subset="hostname", keep="first")
+
+ # Map most recent execution age to agents
+ agents["days_since"] = agents["hostname"].map(
+ most_recent_exec.set_index("hostname")["days_ago"]
+ )
+
+ # Check for enforcement readiness
+ agents["required_quiet"] = self.quiet_days
+ agents["enforce_ready"] = agents["days_since"].apply(
+ lambda x: True if pd.isna(x) or x > self.quiet_days else False
+ )
+
+ # Update status: Categorizing
+ self._update_analysis_status("Step 4/4: Categorizing agents...")
+
+ # Sort agents
+ agents = agents.sort_values(
+ by=["execution_count", "hostname"], ascending=[True, True]
+ )
+
+ # Store the results
+ self.agents_df = agents
+
+ # Categorize agents into DataFrames
+ self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy()
+ self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy()
+
+ logger.info(
+ f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, "
+ f"{len(self.non_enforce_ready_df)} non-enforce ready"
+ )
+
+ self.app.notify(
+ f"Analysis complete! Found {len(self.enforce_ready_df)} enforce ready, "
+ f"{len(self.non_enforce_ready_df)} not ready",
+ severity="success",
+ timeout=5,
+ )
+
+ # Show results
+ self._show_results()
+
+ except Exception as e:
+ logger.error(f"Error during analysis: {e}", exc_info=True)
+ self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5)
+ self._show_policy_selection()
+
+ def _update_analysis_status(self, status_text: str) -> None:
+ """Update the analysis status message."""
+ try:
+ analyzing_msg = self.query_one("#analyzing_message", Static)
+
+ # Build updated message
+ updated_text = (
+ f"📊 Analyzing Agent Activity\n"
+ f"{'=' * 50}\n\n"
+ f"Policy: {self.selected_policy.name}\n"
+ f"History Period: {self.history_days} days\n"
+ f"Quiet Threshold: {self.quiet_days} days\n\n"
+ f"Progress:\n"
+ f"✅ {status_text}\n\n"
+ f"Please wait - this operation cannot be cancelled."
+ )
+
+ analyzing_msg.update(updated_text)
+ except Exception as e:
+ logger.debug(f"Could not update analysis status: {e}")
+
+ def _show_results(self) -> None:
+ """Show the categorized results."""
+ self.workflow_stage = "view_results"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Create results display container and mount it first
+ results_container = Vertical(id="results_container")
+ results_container.styles.height = "auto"
+ results_container.styles.margin = (1, 1)
+ content.mount(results_container)
+
+ # Summary statistics
+ total_agents = len(self.enforce_ready_df) + len(self.non_enforce_ready_df)
+ ready_count = len(self.enforce_ready_df)
+ not_ready_count = len(self.non_enforce_ready_df)
+ ready_percentage = (ready_count / total_agents * 100) if total_agents > 0 else 0
+
+ summary = Static(
+ f"Analysis Results for: {self.selected_policy.name}\n\n"
+ f"📊 Total Agents: {total_agents}\n"
+ f"✅ Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n"
+ f"❌ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n"
+ f"Quiet Threshold: {self.quiet_days} days\n"
+ f"History Period: {self.history_days} days",
+ id="results_summary",
+ )
+ summary.styles.margin = (0, 0, 2, 0)
+ results_container.mount(summary)
+
+ # Action buttons
+ button_container = Horizontal(id="results_buttons")
+ button_container.styles.height = "auto"
+ results_container.mount(button_container)
+
+ if ready_count > 0:
+ enforce_btn = Button(
+ f"Select Target for Enforce Ready ({ready_count})",
+ id="select_enforce_target_btn",
+ )
+ enforce_btn.styles.margin = (0, 1, 1, 0)
+ button_container.mount(enforce_btn)
+
+ if not_ready_count > 0:
+ non_enforce_btn = Button(
+ f"Select Target for Non-Enforce Ready ({not_ready_count})",
+ id="select_non_enforce_target_btn",
+ )
+ non_enforce_btn.styles.margin = (0, 1, 1, 0)
+ button_container.mount(non_enforce_btn)
+
+ export_btn = Button("💾 Export Results", id="export_results_btn")
+ export_btn.styles.margin = (0, 1, 1, 0)
+ button_container.mount(export_btn)
+
+ start_over_btn = Button("🔄 Start Over", id="start_over_btn")
+ start_over_btn.styles.margin = (0, 0, 1, 0)
+ button_container.mount(start_over_btn)
+
+ # Tables showing agents
+ tables_container = Horizontal()
+ tables_container.styles.height = "1fr"
+ results_container.mount(tables_container)
+
+ # Enforce Ready table
+ if ready_count > 0:
+ enforce_col = Vertical()
+ enforce_col.styles.width = "1fr"
+ enforce_col.styles.margin = (1, 1, 0, 0)
+ tables_container.mount(enforce_col)
+
+ enforce_label = Static("✅ Enforce Ready Agents")
+ enforce_label.styles.margin = (0, 0, 1, 0)
+ enforce_col.mount(enforce_label)
+
+ enforce_table = DataTable(id="enforce_ready_table")
+ enforce_table.styles.height = "1fr"
+ enforce_table.add_columns("Hostname", "Last Exec (days)")
+
+ # Display first 50 agents
+ for idx, row in self.enforce_ready_df.head(50).iterrows():
+ days_since = row["days_since"]
+ days_str = f"{int(days_since)}" if not pd.isna(days_since) else "Never"
+ enforce_table.add_row(row["hostname"], days_str)
+
+ if len(self.enforce_ready_df) > 50:
+ enforce_table.add_row(
+ f"... and {len(self.enforce_ready_df) - 50} more", ""
+ )
+
+ enforce_col.mount(enforce_table)
+
+ # Non-Enforce Ready table
+ if not_ready_count > 0:
+ non_enforce_col = Vertical()
+ non_enforce_col.styles.width = "1fr"
+ non_enforce_col.styles.margin = (1, 0, 0, 1)
+ tables_container.mount(non_enforce_col)
+
+ non_enforce_label = Static("❌ Non-Enforce Ready Agents")
+ non_enforce_label.styles.margin = (0, 0, 1, 0)
+ non_enforce_col.mount(non_enforce_label)
+
+ non_enforce_table = DataTable(id="non_enforce_ready_table")
+ non_enforce_table.styles.height = "1fr"
+ non_enforce_table.add_columns("Hostname", "Last Exec (days)")
+
+ # Display first 50 agents
+ for idx, row in self.non_enforce_ready_df.head(50).iterrows():
+ days_since = row["days_since"]
+ days_str = f"{int(days_since)}" if not pd.isna(days_since) else "N/A"
+ non_enforce_table.add_row(row["hostname"], days_str)
+
+ if len(self.non_enforce_ready_df) > 50:
+ non_enforce_table.add_row(
+ f"... and {len(self.non_enforce_ready_df) - 50} more", ""
+ )
+
+ non_enforce_col.mount(non_enforce_table)
+
+ def _show_enforce_target_selection(self) -> None:
+ """Show policy selection for enforce ready agents."""
+ self.workflow_stage = "select_enforce_target"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Info message
+ info = Static(
+ f"Select target policy for {len(self.enforce_ready_df)} Enforce Ready agents\n"
+ f"Source Policy: {self.selected_policy.name}",
+ id="enforce_target_info",
+ )
+ info.styles.margin = (0, 0, 2, 0)
+ content.mount(info)
+
+ # Policy selector
+ policy_selector = PolicySelector(self.policies)
+ content.mount(policy_selector)
+
+ # Skip button
+ skip_btn = Button("⭕️ Skip - No Migration", id="skip_enforce_target_btn")
+ skip_btn.styles.width = "50%"
+ skip_btn.styles.margin = (2, 0, 0, 0)
+ content.mount(skip_btn)
+
+ def _show_non_enforce_target_selection(self) -> None:
+ """Show policy selection for non-enforce ready agents."""
+ self.workflow_stage = "select_non_enforce_target"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Info message
+ info = Static(
+ f"Select target policy for {len(self.non_enforce_ready_df)} Non-Enforce Ready agents\n"
+ f"Source Policy: {self.selected_policy.name}",
+ id="non_enforce_target_info",
+ )
+ info.styles.margin = (0, 0, 2, 0)
+ content.mount(info)
+
+ # Policy selector
+ policy_selector = PolicySelector(self.policies)
+ content.mount(policy_selector)
+
+ # Skip button
+ skip_btn = Button("⭕️ Skip - No Migration", id="skip_non_enforce_target_btn")
+ skip_btn.styles.width = "50%"
+ skip_btn.styles.margin = (2, 0, 0, 0)
+ content.mount(skip_btn)
+
+ def _show_migration_confirmation(self) -> None:
+ """Show migration confirmation screen."""
+ self.workflow_stage = "confirm_migration"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Build confirmation message
+ confirmation_lines = [
+ "🔐 Migration Summary\n",
+ f"Source Policy: {self.selected_policy.name}\n",
+ ]
+
+ if self.enforce_ready_target_policy:
+ confirmation_lines.append(
+ f"\n✅ Enforce Ready Migration:\n"
+ f" • Agents: {len(self.enforce_ready_df)}\n"
+ f" • Target: {self.enforce_ready_target_policy.name}\n"
+ )
+
+ if self.non_enforce_ready_target_policy:
+ confirmation_lines.append(
+ f"\n❌ Non-Enforce Ready Migration:\n"
+ f" • Agents: {len(self.non_enforce_ready_df)}\n"
+ f" • Target: {self.non_enforce_ready_target_policy.name}\n"
+ )
+
+ if (
+ not self.enforce_ready_target_policy
+ and not self.non_enforce_ready_target_policy
+ ):
+ confirmation_lines.append("\n⚠️ No migrations will be performed.")
+
+ confirmation = Static("".join(confirmation_lines), id="migration_confirmation")
+ confirmation.styles.margin = (1, 1, 2, 1)
+ content.mount(confirmation)
+
+ # Action buttons - mount container first, then add buttons
+ button_container = Horizontal(id="confirmation_buttons")
+ button_container.styles.height = "auto"
+ button_container.styles.margin = (1, 1)
+ content.mount(button_container)
+
+ if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy:
+ confirm_btn = Button("✅ Confirm Migration", id="confirm_migration_btn")
+ confirm_btn.styles.margin = (0, 1, 0, 0)
+ button_container.mount(confirm_btn)
+
+ cancel_btn = Button("❌ Cancel", id="cancel_migration_btn")
+ button_container.mount(cancel_btn)
+
+ def _execute_migration(self) -> None:
+ """Execute the agent migrations."""
+ self.workflow_stage = "executing"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Show executing message
+ executing_msg = Static(
+ "⏳ Executing agent migrations...\nPlease wait...",
+ id="executing_message",
+ )
+ executing_msg.styles.margin = (2, 1)
+ content.mount(executing_msg)
+
+ # Perform migrations asynchronously
+ self.call_later(self._perform_migrations)
+
+ def _perform_migrations(self) -> None:
+ """Perform the actual agent migrations."""
+ successful_migrations = []
+ failed_migrations = []
+
+ try:
+ # Migrate enforce ready agents
+ if self.enforce_ready_target_policy:
+ for idx, row in self.enforce_ready_df.iterrows():
+ try:
+ result = self.api.agent_move(
+ row["agentid"], self.enforce_ready_target_policy.groupid
+ )
+ successful_migrations.append(
+ (row["hostname"], self.enforce_ready_target_policy.name)
+ )
+ logger.debug(
+ f"Moved {row['hostname']} to {self.enforce_ready_target_policy.name}"
+ )
+ except Exception as e:
+ failed_migrations.append((row["hostname"], str(e)))
+ logger.error(f"Failed to move {row['hostname']}: {e}")
+
+ # Migrate non-enforce ready agents
+ if self.non_enforce_ready_target_policy:
+ for idx, row in self.non_enforce_ready_df.iterrows():
+ try:
+ result = self.api.agent_move(
+ row["agentid"], self.non_enforce_ready_target_policy.groupid
+ )
+ successful_migrations.append(
+ (row["hostname"], self.non_enforce_ready_target_policy.name)
+ )
+ logger.debug(
+ f"Moved {row['hostname']} to {self.non_enforce_ready_target_policy.name}"
+ )
+ except Exception as e:
+ failed_migrations.append((row["hostname"], str(e)))
+ logger.error(f"Failed to move {row['hostname']}: {e}")
+
+ # Show completion results
+ self._show_completion_results(successful_migrations, failed_migrations)
+
+ except Exception as e:
+ logger.error(f"Error during migration execution: {e}", exc_info=True)
+ self.app.notify(f"Migration failed: {str(e)}", severity="error", timeout=5)
+ self._show_results()
+
+ def _show_completion_results(
+ self, successful: List[tuple], failed: List[tuple]
+ ) -> None:
+ """Show migration completion results."""
+ self.workflow_stage = "complete"
+ content = self.query_one("#content_area", Vertical)
+ content.remove_children()
+
+ # Results summary
+ total_attempted = len(successful) + len(failed)
+ success_rate = (
+ (len(successful) / total_attempted * 100) if total_attempted > 0 else 0
+ )
+
+ results = Static(
+ f"✅ Migration Complete\n\n"
+ f"Total Agents Migrated: {len(successful)}\n"
+ f"Failed Migrations: {len(failed)}\n"
+ f"Success Rate: {success_rate:.1f}%",
+ id="completion_summary",
+ )
+ results.styles.margin = (1, 1, 2, 1)
+ content.mount(results)
+
+ # Details tables
+ if successful:
+ success_container = Vertical()
+ success_container.styles.margin = (0, 1)
+ content.mount(success_container)
+
+ success_label = Static("✅ Successful Migrations")
+ success_label.styles.margin = (0, 0, 1, 0)
+ success_container.mount(success_label)
+
+ success_table = DataTable(id="success_table")
+ success_table.styles.height = "auto"
+ success_table.add_columns("Hostname", "Target Policy")
+
+ for hostname, target_policy in successful[:25]: # Show first 25
+ success_table.add_row(hostname, target_policy)
+
+ if len(successful) > 25:
+ success_table.add_row(f"... and {len(successful) - 25} more", "")
+
+ success_container.mount(success_table)
+
+ if failed:
+ failed_container = Vertical()
+ failed_container.styles.margin = (2, 1, 0, 1)
+ content.mount(failed_container)
+
+ failed_label = Static("❌ Failed Migrations")
+ failed_label.styles.margin = (0, 0, 1, 0)
+ failed_container.mount(failed_label)
+
+ failed_table = DataTable(id="failed_table")
+ failed_table.styles.height = "auto"
+ failed_table.add_columns("Hostname", "Error")
+
+ for hostname, error in failed[:25]: # Show first 25
+ failed_table.add_row(hostname, error[:50]) # Truncate error
+
+ if len(failed) > 25:
+ failed_table.add_row(f"... and {len(failed) - 25} more", "")
+
+ failed_container.mount(failed_table)
+
+ # Action button
+ done_btn = Button("✔ Done", id="start_over_btn")
+ done_btn.styles.width = "50%"
+ done_btn.styles.margin = (2, 0, 0, 0)
+ content.mount(done_btn)
+
+ def _export_results(self) -> None:
+ """Export analysis results to CSV."""
+ try:
+ working_dir = load_env("WORKING_DIR") or os.getcwd()
+ filename = os.path.join(
+ working_dir,
+ f"{self.selected_policy.name}_quiet_analysis_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
+ )
+
+ self.agents_df.to_csv(filename, index=False)
+ logger.info(f"Exported results to {filename}")
+ self.app.notify(
+ f"Results exported to:\n{filename}",
+ severity="information",
+ timeout=5,
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to export results: {e}")
+ self.app.notify(f"Export failed: {str(e)}", severity="error", timeout=5)
+
+ def action_go_back(self) -> None:
+ """Handle back/escape action."""
+ # Depending on stage, go back to previous stage or exit
+ if self.workflow_stage in ["select_policy", "view_results", "complete"]:
+ self.app.pop_screen()
+ elif self.workflow_stage == "select_quiet_days":
+ self._show_policy_selection()
+ elif self.workflow_stage == "select_enforce_target":
+ self._show_results()
+ elif self.workflow_stage == "select_non_enforce_target":
+ if self.enforce_ready_target_policy:
+ self._show_enforce_target_selection()
+ else:
+ self._show_results()
+ elif self.workflow_stage == "confirm_migration":
+ self._show_non_enforce_target_selection()
+ else:
+ self.app.pop_screen()
diff --git a/TUI/resultsdisplay.py b/TUI/resultsdisplay.py
new file mode 100644
index 0000000..ebabce7
--- /dev/null
+++ b/TUI/resultsdisplay.py
@@ -0,0 +1,178 @@
+import logging
+
+from textual.containers import Horizontal, Vertical
+from textual.message import Message
+from textual.widget import Widget
+from textual.widgets import Button, Footer, Header, Static
+
+logger = logging.getLogger(__name__)
+
+
+class ResultsDisplay(Widget):
+ """Widget for displaying operation results in a two-column layout."""
+
+ CSS = """
+ ResultsDisplay {
+ height: 100%;
+ }
+
+ #results_screen {
+ height: 100%;
+ }
+
+ #results_title {
+ text-align: center;
+ margin: 1 0;
+ text-style: bold;
+ }
+
+ #results_layout {
+ height: 1fr;
+ margin: 1 0;
+ }
+
+ #left_column, #right_column {
+ width: 1fr;
+ height: 100%;
+ border: solid green;
+ padding: 1;
+ }
+
+ #right_column {
+ border: solid red;
+ }
+
+ #success_label, #failure_label {
+ text-style: bold;
+ margin-bottom: 1;
+ }
+
+ #success_results, #failure_results {
+ height: 1fr;
+ overflow-y: auto;
+ background: $surface;
+ border: round $primary;
+ padding: 1;
+ }
+
+ .copy_button {
+ margin-top: 1;
+ width: 100%;
+ }
+
+ #button_row {
+ height: auto;
+ margin: 1 0 0 0;
+ }
+
+ #back_button {
+ width: 1fr;
+ }
+ """
+
+ class CopySuccess(Message):
+ """Posted when success results are copied."""
+
+ pass
+
+ class CopyFailure(Message):
+ """Posted when failure results are copied."""
+
+ pass
+
+ class GoBack(Message):
+ """Posted when back button is pressed."""
+
+ pass
+
+ def __init__(
+ self, operation: str, successful_results: str, unsuccessful_results: str
+ ) -> None:
+ super().__init__()
+ self.operation = operation
+ self.successful_results = successful_results
+ self.unsuccessful_results = unsuccessful_results
+
+ def compose(self):
+ with Vertical(id="results_screen"):
+ yield Header(show_clock=True, icon="⚙")
+ # Title
+ title = Static(f"📊 {self.operation} - Results", id="results_title")
+ yield title
+
+ # Two-column layout
+ with Horizontal(id="results_layout"):
+ # Left Column - Success
+ with Vertical(id="left_column"):
+ yield Static("✅ Successful", id="success_label")
+ yield Static(self.successful_results, id="success_results")
+ yield Button(
+ "📋✅ Copy Success List",
+ id="copy_success",
+ classes="copy_button",
+ )
+
+ # Right Column - Failure
+ with Vertical(id="right_column"):
+ yield Static("❌ Failed", id="failure_label")
+ yield Static(self.unsuccessful_results, id="failure_results")
+ yield Button(
+ "📋❌ Copy Failure List",
+ id="copy_failure",
+ classes="copy_button",
+ )
+
+ # Back Button
+ with Horizontal(id="button_row"):
+ back_button = Button("← Back", id="back_button")
+ yield back_button
+ yield Footer()
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ btn_id = event.button.id
+
+ if btn_id == "copy_success":
+ success_widget = self.query_one("#success_results", Static)
+ try:
+ import pyperclip
+
+ pyperclip.copy(str(success_widget.renderable))
+ self.app.notify(
+ "✅ Success list copied to clipboard!",
+ severity="information",
+ timeout=2,
+ )
+ self.post_message(self.CopySuccess())
+ except ImportError:
+ self.app.notify(
+ "âš ï¸ pyperclip not installed. Run: pip install pyperclip",
+ severity="warning",
+ )
+ except Exception as e:
+ self.app.notify(f"⌠Failed to copy: {str(e)}", severity="error")
+ event.stop()
+
+ elif btn_id == "copy_failure":
+ failure_widget = self.query_one("#failure_results", Static)
+ try:
+ import pyperclip
+
+ pyperclip.copy(str(failure_widget.renderable))
+ self.app.notify(
+ "✅ Failure list copied to clipboard!",
+ severity="information",
+ timeout=2,
+ )
+ self.post_message(self.CopyFailure())
+ except ImportError:
+ self.app.notify(
+ "âš ï¸ pyperclip not installed. Run: pip install pyperclip",
+ severity="warning",
+ )
+ except Exception as e:
+ self.app.notify(f"⌠Failed to copy: {str(e)}", severity="error")
+ event.stop()
+
+ elif btn_id == "back_button":
+ self.app.pop_screen()
+ event.stop()
diff --git a/TUI/theme_amber_terminal.py b/TUI/theme_amber_terminal.py
new file mode 100644
index 0000000..3662892
--- /dev/null
+++ b/TUI/theme_amber_terminal.py
@@ -0,0 +1,35 @@
+from textual.color import Color
+from textual.theme import Theme
+
+
+def get_amber_terminal_theme():
+ """Amber CRT theme with compensated brightness for blending."""
+ return Theme(
+ name="amber-terminal",
+ background=Color.parse("#000000"), # pure black
+ primary=Color.parse("#ffb733"), # bright amber
+ secondary=Color.parse("#e69500"), # strong amber
+ success=Color.parse("#ffb733"),
+ warning=Color.parse("#ffff66"),
+ error=Color.parse("#ff3300"),
+ surface=Color.parse("#49331a"), # brighter brown for blending
+ )
+
+
+AMBER_TERMINAL_CSS = """
+Screen {
+ align: center middle;
+ background: #000000; /* force black */
+ color: #ffb733; /* force amber text */
+}
+
+.widget {
+ border: tall #ffb733; /* force amber border */
+ background: #3a1f00; /* compensated surface */
+ width: 80%;
+}
+
+* {
+ font-family: "Courier New", monospace;
+}
+"""
diff --git a/TUI/theme_retro_terminal.py b/TUI/theme_retro_terminal.py
new file mode 100644
index 0000000..e0cd9c9
--- /dev/null
+++ b/TUI/theme_retro_terminal.py
@@ -0,0 +1,38 @@
+from textual.color import Color
+
+
+def get_retro_terminal_theme():
+ from textual.theme import Theme
+
+ return Theme(
+ name="retro-terminal",
+ background=Color.parse("#000000"),
+ primary=Color.parse("#00ff00"),
+ secondary=Color.parse("#00aa00"),
+ success=Color.parse("#00ff00"),
+ warning=Color.parse("#ffff00"),
+ error=Color.parse("#ff0000"),
+ surface=Color.parse("#071802"),
+ )
+
+
+RETRO_TERMINAL_CSS = """
+/* Retro terminal CRT effect */
+Screen {
+ align: center middle;
+ background: $background;
+ color: $text;
+}
+
+/* Blocky, pixelated widgets */
+.widget {
+ border: tall $primary;
+ background: $surface;
+ width: 80%;
+}
+
+/* Monospaced font */
+* {
+ font-family: "Courier New", monospace;
+}
+"""
diff --git a/widgets/themeselector.py b/TUI/themeselector.py
similarity index 66%
rename from widgets/themeselector.py
rename to TUI/themeselector.py
index fda7cd1..bcbbb99 100644
--- a/widgets/themeselector.py
+++ b/TUI/themeselector.py
@@ -15,26 +15,26 @@ class ThemeSelector(Widget):
self.theme_name = theme_name
AVAILABLE_THEMES = [
- ("textual-dark", "textual-dark"),
- ("textual-light", "textual-light"),
- ("nord", "nord"),
- ("gruvbox", "gruvbox"),
- ("catppuccin-mocha", "catppuccin-mocha"),
- ("dracula", "dracula"),
- ("tokyo-night", "tokyo-night"),
- ("monokai", "monokai"),
- ("flexoki", "flexoki"),
- ("catppuccin-latte", "catppuccin-latte"),
- ("solarized-light", "solarized-light"),
+ ("Textual Dark", "textual-dark"),
+ ("Textual Light", "textual-light"),
+ ("Nord", "nord"),
+ ("Gruvbox", "gruvbox"),
+ ("Catppuccin Mocha", "catppuccin-mocha"),
+ ("Dracula", "dracula"),
+ ("Tokyo Night", "tokyo-night"),
+ ("Monokai", "monokai"),
+ ("Flexoki", "flexoki"),
+ ("Catppuccin Latte", "catppuccin-latte"),
+ ("Solarized Light", "solarized-light"),
+ ("Retro Terminal", "retro-terminal"),
+ ("Amber Terminal", "amber-terminal"), # your custom theme
]
def compose(self):
yield Static("Theme Options", id="theme_title")
-
with Vertical() as column:
column.styles.width = "1fr"
column.styles.height = "auto"
-
for label, btn_id in self.AVAILABLE_THEMES:
yield Button(label, id=f"set_theme_{btn_id}", compact=True)
diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock
index a3d9b52..fb2d657 100644
--- a/airlock_libs/Cargo.lock
+++ b/airlock_libs/Cargo.lock
@@ -15,19 +15,36 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "airlock_libs"
-version = "2.0.0"
+version = "3.1.2"
dependencies = [
"chrono",
"indicatif",
"mongodb",
+ "opentelemetry 0.18.0",
+ "opentelemetry-otlp",
+ "opentelemetry-proto",
+ "opentelemetry-semantic-conventions",
"pyo3",
"reqwest",
"serde",
"serde-pyobject",
"serde_json",
"tokio",
+ "tonic",
+ "tracing",
+ "tracing-opentelemetry",
+ "tracing-subscriber",
]
[[package]]
@@ -39,6 +56,34 @@ dependencies = [
"libc",
]
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -47,7 +92,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -62,12 +107,63 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "axum"
+version = "0.6.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "bitflags 1.3.2",
+ "bytes",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper 0.1.2",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "mime",
+ "rustversion",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -144,9 +240,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
-version = "1.2.41"
+version = "1.2.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
+checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -236,6 +332,21 @@ dependencies = [
"libc",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -244,9 +355,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
@@ -273,7 +384,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -284,7 +395,20 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
- "syn",
+ "syn 2.0.110",
+]
+
+[[package]]
+name = "dashmap"
+version = "5.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
+dependencies = [
+ "cfg-if",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
]
[[package]]
@@ -311,7 +435,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -322,7 +446,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -335,7 +459,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -357,7 +481,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -366,6 +490,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -387,10 +517,10 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
- "heck",
+ "heck 0.5.0",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -421,6 +551,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -457,6 +593,20 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -464,6 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
+ "futures-sink",
]
[[package]]
@@ -497,7 +648,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -518,9 +669,11 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
+ "futures-channel",
"futures-core",
"futures-io",
"futures-macro",
+ "futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
@@ -530,9 +683,9 @@ dependencies = [
[[package]]
name = "generic-array"
-version = "0.14.9"
+version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
@@ -565,6 +718,25 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap 2.12.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "h2"
version = "0.4.12"
@@ -576,7 +748,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.3.1",
"indexmap 2.12.0",
"slab",
"tokio",
@@ -590,12 +762,24 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -625,7 +809,7 @@ dependencies = [
"ipnet",
"once_cell",
"rand 0.8.5",
- "thiserror",
+ "thiserror 1.0.69",
"tinyvec",
"tokio",
"tracing",
@@ -648,7 +832,7 @@ dependencies = [
"rand 0.8.5",
"resolv-conf",
"smallvec",
- "thiserror",
+ "thiserror 1.0.69",
"tokio",
"tracing",
]
@@ -662,6 +846,26 @@ dependencies = [
"digest",
]
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.3.1"
@@ -673,6 +877,17 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -680,7 +895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.3.1",
]
[[package]]
@@ -691,8 +906,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -703,18 +918,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
-name = "hyper"
-version = "1.7.0"
+name = "httpdate"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.12",
+ "http 1.3.1",
+ "http-body 1.0.1",
"httparse",
"itoa",
"pin-project-lite",
@@ -730,16 +975,28 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
- "hyper",
+ "http 1.3.1",
+ "hyper 1.8.0",
"hyper-util",
- "rustls",
+ "rustls 0.23.35",
"rustls-pki-types",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower-service",
]
+[[package]]
+name = "hyper-timeout"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1"
+dependencies = [
+ "hyper 0.14.32",
+ "pin-project-lite",
+ "tokio",
+ "tokio-io-timeout",
+]
+
[[package]]
name = "hyper-tls"
version = "0.6.0"
@@ -748,7 +1005,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
- "hyper",
+ "hyper 1.8.0",
"hyper-util",
"native-tls",
"tokio",
@@ -767,9 +1024,9 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.3.1",
+ "http-body 1.0.1",
+ "hyper 1.8.0",
"ipnet",
"libc",
"percent-encoding",
@@ -808,9 +1065,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.0.0"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
@@ -821,9 +1078,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.0.0"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
@@ -834,11 +1091,10 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.0.0"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
- "displaydoc",
"icu_collections",
"icu_normalizer_data",
"icu_properties",
@@ -849,42 +1105,38 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.0.0"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]]
name = "icu_properties"
-version = "2.0.1"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
+checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
dependencies = [
- "displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
- "potential_utf",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
-version = "2.0.1"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
+checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
[[package]]
name = "icu_provider"
-version = "2.0.0"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
- "stable_deref_trait",
- "tinystr",
"writeable",
"yoke",
"zerofrom",
@@ -944,9 +1196,9 @@ dependencies = [
[[package]]
name = "indicatif"
-version = "0.18.2"
+version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65"
+checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [
"console",
"portable-atomic",
@@ -957,9 +1209,12 @@ dependencies = [
[[package]]
name = "indoc"
-version = "2.0.6"
+version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
[[package]]
name = "ipconfig"
@@ -981,14 +1236,23 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
-version = "0.7.8"
+version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
dependencies = [
"memchr",
"serde",
]
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.15"
@@ -997,14 +1261,20 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
-version = "0.3.81"
+version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
+checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
dependencies = [
"once_cell",
"wasm-bindgen",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "libc"
version = "0.2.177"
@@ -1017,6 +1287,12 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -1025,9 +1301,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
-version = "0.8.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "lock_api"
@@ -1062,7 +1338,7 @@ dependencies = [
"macro_magic_core",
"macro_magic_macros",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1076,7 +1352,7 @@ dependencies = [
"macro_magic_core_macros",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1087,7 +1363,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1098,9 +1374,15 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869"
dependencies = [
"macro_magic_core",
"quote",
- "syn",
+ "syn 2.0.110",
]
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1191,7 +1473,7 @@ dependencies = [
"percent-encoding",
"rand 0.8.5",
"rustc_version_runtime",
- "rustls",
+ "rustls 0.23.35",
"rustversion",
"serde",
"serde_bytes",
@@ -1202,9 +1484,9 @@ dependencies = [
"stringprep",
"strsim",
"take_mut",
- "thiserror",
+ "thiserror 1.0.69",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tokio-util",
"typed-builder",
"uuid",
@@ -1220,9 +1502,15 @@ dependencies = [
"macro_magic",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
+[[package]]
+name = "multimap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
+
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1240,6 +1528,15 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -1263,9 +1560,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
-version = "0.10.74"
+version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
+checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
@@ -1284,7 +1581,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1295,9 +1592,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
-version = "0.9.110"
+version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
+checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
@@ -1305,6 +1602,123 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "opentelemetry"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69d6c3d7288a106c0a363e4b0e8d308058d56902adefb16f4936f417ffef086e"
+dependencies = [
+ "opentelemetry_api",
+ "opentelemetry_sdk 0.18.0",
+]
+
+[[package]]
+name = "opentelemetry"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror 2.0.17",
+]
+
+[[package]]
+name = "opentelemetry-otlp"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c928609d087790fc936a1067bdc310ae702bdf3b090c3f281b713622c8bbde"
+dependencies = [
+ "async-trait",
+ "futures",
+ "futures-util",
+ "http 0.2.12",
+ "opentelemetry 0.18.0",
+ "opentelemetry-proto",
+ "prost",
+ "thiserror 1.0.69",
+ "tokio",
+ "tonic",
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d61a2f56df5574508dd86aaca016c917489e589ece4141df1b5e349af8d66c28"
+dependencies = [
+ "futures",
+ "futures-util",
+ "opentelemetry 0.18.0",
+ "prost",
+ "tonic",
+ "tonic-build",
+]
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b02e0230abb0ab6636d18e2ba8fa02903ea63772281340ccac18e0af3ec9eeb"
+dependencies = [
+ "opentelemetry 0.18.0",
+]
+
+[[package]]
+name = "opentelemetry_api"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c24f96e21e7acc813c7a8394ee94978929db2bcc46cf6b5014fc612bf7760c22"
+dependencies = [
+ "fnv",
+ "futures-channel",
+ "futures-util",
+ "indexmap 1.9.3",
+ "js-sys",
+ "once_cell",
+ "pin-project-lite",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ca41c4933371b61c2a2f214bf16931499af4ec90543604ec828f7a625c09113"
+dependencies = [
+ "async-trait",
+ "crossbeam-channel",
+ "dashmap",
+ "fnv",
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "once_cell",
+ "opentelemetry_api",
+ "percent-encoding",
+ "rand 0.8.5",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-stream",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry 0.31.0",
+ "percent-encoding",
+ "rand 0.9.2",
+ "thiserror 2.0.17",
+]
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1343,6 +1757,36 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.12.0",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1369,9 +1813,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "potential_utf"
-version = "0.1.3"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"zerovec",
]
@@ -1392,19 +1836,83 @@ dependencies = [
]
[[package]]
-name = "proc-macro2"
-version = "1.0.101"
+name = "prettyplease"
+version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86"
+dependencies = [
+ "proc-macro2",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
-name = "pyo3"
-version = "0.27.0"
+name = "prost"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa8e48c12afdeb26aa4be4e5c49fb5e11c3efa0878db783a960eea2b9ac6dd19"
+checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270"
+dependencies = [
+ "bytes",
+ "heck 0.4.1",
+ "itertools",
+ "lazy_static",
+ "log",
+ "multimap",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn 1.0.109",
+ "tempfile",
+ "which",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf"
dependencies = [
"indoc",
"libc",
@@ -1419,9 +1927,9 @@ dependencies = [
[[package]]
name = "pyo3-build-config"
-version = "0.27.0"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc1989dbf2b60852e0782c7487ebf0b4c7f43161ffe820849b56cf05f945cee1"
+checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb"
dependencies = [
"python3-dll-a",
"target-lexicon",
@@ -1429,9 +1937,9 @@ dependencies = [
[[package]]
name = "pyo3-ffi"
-version = "0.27.0"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c808286da7500385148930152e54fb6883452033085bf1f857d85d4e82ca905c"
+checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be"
dependencies = [
"libc",
"pyo3-build-config",
@@ -1439,27 +1947,27 @@ dependencies = [
[[package]]
name = "pyo3-macros"
-version = "0.27.0"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83a0543c16be0d86cf0dbf2e2b636ece9fd38f20406bb43c255e0bc368095f92"
+checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
name = "pyo3-macros-backend"
-version = "0.27.0"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a00da2ce064dcd582448ea24a5a26fa9527e0483103019b741ebcbe632dcd29"
+checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b"
dependencies = [
- "heck",
+ "heck 0.5.0",
"proc-macro2",
"pyo3-build-config",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1473,9 +1981,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.41"
+version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
@@ -1577,9 +2085,38 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
+[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
[[package]]
name = "reqwest"
version = "0.12.24"
@@ -1590,11 +2127,11 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.12",
+ "http 1.3.1",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
+ "hyper 1.8.0",
"hyper-rustls",
"hyper-tls",
"hyper-util",
@@ -1608,10 +2145,10 @@ dependencies = [
"serde",
"serde_json",
"serde_urlencoded",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
- "tower",
+ "tower 0.5.2",
"tower-http",
"tower-service",
"url",
@@ -1626,6 +2163,21 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
+[[package]]
+name = "ring"
+version = "0.16.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
+dependencies = [
+ "cc",
+ "libc",
+ "once_cell",
+ "spin",
+ "untrusted 0.7.1",
+ "web-sys",
+ "winapi",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -1636,7 +2188,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.16",
"libc",
- "untrusted",
+ "untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -1659,6 +2211,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.10.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rustix"
version = "1.1.2"
@@ -1668,19 +2233,31 @@ dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.33"
+version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c"
+checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99"
+dependencies = [
+ "log",
+ "ring 0.16.20",
+ "sct",
+ "webpki",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"log",
"once_cell",
- "ring",
+ "ring 0.17.14",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -1688,23 +2265,44 @@ dependencies = [
]
[[package]]
-name = "rustls-pki-types"
-version = "1.12.0"
+name = "rustls-native-certs"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
+dependencies = [
+ "openssl-probe",
+ "rustls-pemfile",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
+dependencies = [
+ "base64 0.21.7",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
-version = "0.103.7"
+version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
+checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
- "ring",
+ "ring 0.17.14",
"rustls-pki-types",
- "untrusted",
+ "untrusted 0.9.0",
]
[[package]]
@@ -1742,9 +2340,9 @@ dependencies = [
[[package]]
name = "schemars"
-version = "1.0.5"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce"
+checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289"
dependencies = [
"dyn-clone",
"ref-cast",
@@ -1758,6 +2356,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring 0.17.14",
+ "untrusted 0.9.0",
+]
+
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -1835,7 +2443,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1876,7 +2484,7 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.12.0",
"schemars 0.9.0",
- "schemars 1.0.5",
+ "schemars 1.1.0",
"serde_core",
"serde_json",
"serde_with_macros",
@@ -1892,7 +2500,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1917,6 +2525,15 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1964,6 +2581,12 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1995,15 +2618,32 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.107"
+version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "2.0.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -2021,7 +2661,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2072,7 +2712,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
- "rustix",
+ "rustix 1.1.2",
"windows-sys 0.61.2",
]
@@ -2082,7 +2722,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl 2.0.17",
]
[[package]]
@@ -2093,7 +2742,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
]
[[package]]
@@ -2138,9 +2807,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"zerovec",
@@ -2178,6 +2847,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "tokio-io-timeout"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "tokio-macros"
version = "2.6.0"
@@ -2186,7 +2865,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2199,21 +2878,43 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
+dependencies = [
+ "rustls 0.20.9",
+ "tokio",
+ "webpki",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls",
+ "rustls 0.23.35",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
-version = "0.7.16"
+version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
+checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
@@ -2223,6 +2924,74 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tonic"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum",
+ "base64 0.13.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper-timeout",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "prost-derive",
+ "rustls-native-certs",
+ "rustls-pemfile",
+ "tokio",
+ "tokio-rustls 0.23.4",
+ "tokio-stream",
+ "tokio-util",
+ "tower 0.4.13",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+ "tracing-futures",
+]
+
+[[package]]
+name = "tonic-build"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "prost-build",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand 0.8.5",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "tower"
version = "0.5.2"
@@ -2232,7 +3001,7 @@ dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
"tokio",
"tower-layer",
"tower-service",
@@ -2247,11 +3016,11 @@ dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-util",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"iri-string",
"pin-project-lite",
- "tower",
+ "tower 0.5.2",
"tower-layer",
"tower-service",
]
@@ -2287,7 +3056,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2297,6 +3066,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-opentelemetry"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e"
+dependencies = [
+ "js-sys",
+ "opentelemetry 0.31.0",
+ "opentelemetry_sdk 0.31.0",
+ "rustversion",
+ "smallvec",
+ "thiserror 2.0.17",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-subscriber",
+ "web-time",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
]
[[package]]
@@ -2322,7 +3146,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2339,9 +3163,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
-version = "1.0.19"
+version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
@@ -2376,6 +3200,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817"
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2412,6 +3242,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -2450,9 +3286,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.104"
+version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
+checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
dependencies = [
"cfg-if",
"once_cell",
@@ -2461,25 +3297,11 @@ dependencies = [
"wasm-bindgen-shared",
]
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.104"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.54"
+version = "0.4.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
+checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
dependencies = [
"cfg-if",
"js-sys",
@@ -2490,9 +3312,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.104"
+version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
+checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2500,31 +3322,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.104"
+version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
+checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
- "syn",
- "wasm-bindgen-backend",
+ "syn 2.0.110",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.104"
+version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
+checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
-version = "0.3.81"
+version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
+checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2540,6 +3362,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
+dependencies = [
+ "ring 0.17.14",
+ "untrusted 0.9.0",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -2558,12 +3390,46 @@ dependencies = [
"rustls-pki-types",
]
+[[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.44",
+]
+
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2585,7 +3451,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2596,7 +3462,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2898,9 +3764,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "writeable"
-version = "0.6.1"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wyz"
@@ -2913,11 +3779,10 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
- "serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
@@ -2925,13 +3790,13 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"synstructure",
]
@@ -2952,7 +3817,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -2972,7 +3837,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"synstructure",
]
@@ -2984,9 +3849,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
-version = "0.2.2"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
"displaydoc",
"yoke",
@@ -2995,9 +3860,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.4"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"yoke",
"zerofrom",
@@ -3006,11 +3871,11 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.1"
+version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml
index 3a853d9..1fb0612 100644
--- a/airlock_libs/Cargo.toml
+++ b/airlock_libs/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "airlock_libs"
-version = "2.0.0"
+version = "3.1.2"
edition = "2024"
[lib]
@@ -10,12 +10,20 @@ crate-type = ["cdylib"]
chrono = "0.4.42"
indicatif = "0.18.2"
mongodb = "3.3.0"
+opentelemetry = { version = "0.18.0", features = ["rt-tokio", "metrics", "trace"] }
+opentelemetry-otlp = { version = "0.11.0", features = ["trace", "metrics"] }
+opentelemetry-semantic-conventions = { version = "0.10.0" }
+opentelemetry-proto = { version = "0.1.0"}
pyo3 = { version = "0.27.0", features = ["extension-module", "generate-import-lib"] }
reqwest = { version = "0.12.24", features = ["json", "native-tls"] }
serde = "1.0.228"
serde-pyobject = "0.8.0"
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
+tonic = { version = "0.8.2", features = ["tls-roots"] }
+tracing = "0.1.41"
+tracing-subscriber = "0.3.20"
+tracing-opentelemetry = "0.32.0"
[package.metadata.maturin]
generate-abi-stubs = true
diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml
index e7cd8aa..b646277 100644
--- a/airlock_libs/pyproject.toml
+++ b/airlock_libs/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "airlock_libs"
-version = "2.0.0"
+version = "3.1.2"
description = "Airlock Digital API Wrapper"
readme = "README.md"
license = { text = "AGPL-3.0-only" }
diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs
index 8870d5c..c38adeb 100644
--- a/airlock_libs/src/services.rs
+++ b/airlock_libs/src/services.rs
@@ -1,6 +1,13 @@
use chrono::{Duration, Local, NaiveDate};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use mongodb::bson::oid::ObjectId;
+use opentelemetry::global::shutdown_tracer_provider;
+use opentelemetry::sdk::Resource;
+use opentelemetry::trace::noop::NoopTracerProvider;
+use opentelemetry::trace::{Status, TraceContextExt, TraceError};
+use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer};
+use opentelemetry::{Key, global};
+use opentelemetry_otlp::WithExportConfig;
use pyo3::{prelude::*, types::PyString};
use reqwest::{
Client,
@@ -18,6 +25,12 @@ use std::{
str::FromStr,
};
+#[derive(Deserialize, Debug)]
+struct TelemetryConfig {
+ TELEMETRY: bool,
+ TELEM_URL: Option,
+}
+
#[derive(Debug, Deserialize, Serialize)]
struct ApiResponse {
error: String,
@@ -61,6 +74,12 @@ pub fn pull_policy_exec_histories(
exec_types: String,
days: i64,
) -> Py {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ rt.block_on(async {
+ let _ = init_tracer();
+ });
+ let tracer = global::tracer("global_tracer");
+ let _cx = Context::new();
let file_path: PathBuf = format!(
"{}\\cache\\chunkinator.json",
get_base_directory().display()
@@ -93,102 +112,141 @@ pub fn pull_policy_exec_histories(
.unwrap(),
);
progress_bar.enable_steady_tick(std::time::Duration::from_millis(100));
- let client = build_client(py, &py_self);
+ let client = tracer.in_span("Building HTTP Client", |cx| {
+ let client_result = build_client(py, &py_self);
+ match client_result {
+ Ok(client_result) => {
+ cx.span().add_event(
+ "info",
+ vec![KeyValue::new(
+ "Client Built Successfully",
+ format!("{:?}", client_result),
+ )],
+ );
+ client_result
+ }
+ Err(client_result) => {
+ cx.span().add_event(
+ "warn",
+ vec![KeyValue::new(
+ "Client Failed to Build",
+ format!("{:?}", &client_result),
+ )],
+ );
+ cx.span()
+ .set_status(Status::error("Client Failed to Build"));
+ panic!("Failed to Build Client: {:?}", client_result);
+ }
+ }
+ });
let api: Py = py_self;
let cutoff = Local::now().naive_local() - Duration::days(days);
let mut f = File::open(&writeable_filepath).unwrap();
- loop {
- f.seek(SeekFrom::Start(0)).unwrap();
- let execution_histories = history_logging(
- py,
- &api,
- &exec_types,
- &checkpoint_number,
- &policy_names,
- &client,
- );
- let parsed_responses = execution_histories.response.exechistories;
- if parsed_responses.is_empty() {
- break;
- }
- let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() {
- let mut contents = String::new();
- f.read_to_string(&mut contents).unwrap();
- let existing_data: ApiResponse =
- serde_json::from_str(&contents).unwrap_or(ApiResponse {
- error: "Success".to_string(),
- response: ExecHistories {
- exechistories: vec![],
- },
- });
- existing_data
- .response
- .exechistories
- .into_iter()
- .map(|entry| {
- (
- (
- entry.sha256.clone(),
- entry.filename.clone(),
- entry.hostname.clone(),
- ),
- entry,
- )
- })
- .collect()
- } else {
- HashMap::new()
- };
- for (index, executions) in parsed_responses.iter().enumerate() {
- if executions.checkpoint.is_empty() || executions.datetime.is_empty() {
- continue;
- }
- if index == parsed_responses.len() - 1 {
- checkpoint_number = executions.checkpoint.clone();
+ tracer.in_span("Airlock Data Retreival", |cx| {
+ let span = cx.span();
+ span.set_attribute(Key::new("Days").string(days.to_string().to_string()));
+ loop {
+ f.seek(SeekFrom::Start(0)).unwrap();
+ let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| {
+ let results: ApiResponse = history_logging(
+ py,
+ &api,
+ &exec_types,
+ &checkpoint_number,
+ &policy_names,
+ &client,
+ );
+ cx.span().set_attribute(KeyValue::new(
+ "items_in_response",
+ results.response.exechistories.len().to_string(),
+ ));
+ results
+ });
+ let parsed_responses = execution_histories.response.exechistories;
+ if parsed_responses.is_empty() {
break;
}
- let history_date = match NaiveDate::parse_from_str(
- &executions.datetime.replace(" +0000 UTC", ""),
- "%Y-%m-%dT%H:%M:%SZ",
- ) {
- Ok(date) => date,
- Err(_) => continue,
+ let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists()
+ {
+ let mut contents = String::new();
+ f.read_to_string(&mut contents).unwrap();
+ let existing_data: ApiResponse =
+ serde_json::from_str(&contents).unwrap_or(ApiResponse {
+ error: "Success".to_string(),
+ response: ExecHistories {
+ exechistories: vec![],
+ },
+ });
+ existing_data
+ .response
+ .exechistories
+ .into_iter()
+ .map(|entry| {
+ (
+ (
+ entry.sha256.clone(),
+ entry.filename.clone(),
+ entry.hostname.clone(),
+ ),
+ entry,
+ )
+ })
+ .collect()
+ } else {
+ HashMap::new()
};
- if history_date >= cutoff.into() {
- let key = (
- executions.sha256.clone(),
- executions.filename.clone(),
- executions.hostname.clone(),
- );
- seen.entry(key).or_insert(executions.clone());
+ for (index, executions) in parsed_responses.iter().enumerate() {
+ if executions.checkpoint.is_empty() || executions.datetime.is_empty() {
+ continue;
+ }
+ if index == parsed_responses.len() - 1 {
+ checkpoint_number = executions.checkpoint.clone();
+ break;
+ }
+ let history_date = match NaiveDate::parse_from_str(
+ &executions.datetime.replace(" +0000 UTC", ""),
+ "%Y-%m-%dT%H:%M:%SZ",
+ ) {
+ Ok(date) => date,
+ Err(_) => continue,
+ };
+ if history_date >= cutoff.into() {
+ let key = (
+ executions.sha256.clone(),
+ executions.filename.clone(),
+ executions.hostname.clone(),
+ );
+ seen.entry(key).or_insert(executions.clone());
+ }
+ }
+ let final_response = ApiResponse {
+ error: "Success".to_string(),
+ response: ExecHistories {
+ exechistories: seen.values().cloned().collect(),
+ },
+ };
+ let data_write = serde_json::to_string_pretty(&final_response).unwrap();
+ fs::write(&writeable_filepath, data_write).unwrap();
+ if let Some(last_item) = &final_response.response.exechistories.last()
+ && let Ok(last_date) = NaiveDate::parse_from_str(
+ &last_item.datetime.replace(" +0000 UTC", ""),
+ "%Y-%m-%dT%H:%M:%SZ",
+ )
+ {
+ let date_diff = Local::now().naive_local().date() - last_date;
+ let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0;
+ progress_bar.set_position(percentage_diff.round() as u64);
+ progress_bar.set_message("Total Percent Complete");
}
}
- let final_response = ApiResponse {
- error: "Success".to_string(),
- response: ExecHistories {
- exechistories: seen.values().cloned().collect(),
- },
- };
- let data_write = serde_json::to_string_pretty(&final_response).unwrap();
- fs::write(&writeable_filepath, data_write).unwrap();
- if let Some(last_item) = &final_response.response.exechistories.last()
- && let Ok(last_date) = NaiveDate::parse_from_str(
- &last_item.datetime.replace(" +0000 UTC", ""),
- "%Y-%m-%dT%H:%M:%SZ",
- )
- {
- let date_diff = Local::now().naive_local().date() - last_date;
- let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0;
- progress_bar.set_position(percentage_diff.round() as u64);
- progress_bar.set_message("Total Percent Complete");
- }
- }
+ });
progress_bar.finish_with_message("All Checkpoints Complete");
let return_data = fs::read_to_string(&writeable_filepath).unwrap();
+ shutdown_tracer_provider();
PyString::new(py, &return_data).into()
}
-fn build_client(py: Python<'_>, py_self: &Py) -> Client {
+fn build_client(py: Python<'_>, py_self: &Py) -> Result {
let headers = py_self.getattr(py, "headers").unwrap().to_string();
let headers_replace = headers.replace('\'', "\"");
let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap();
@@ -205,9 +263,8 @@ fn build_client(py: Python<'_>, py_self: &Py) -> Client {
Client::builder()
.danger_accept_invalid_certs(true)
.default_headers(header_map)
- .timeout(std::time::Duration::from_secs(30))
+ .timeout(std::time::Duration::from_secs(300))
.build()
- .unwrap()
}
#[tokio::main]
@@ -276,3 +333,48 @@ fn skipback(days: i64) -> ObjectId {
let objectid_hex = format!("{}0000000000000000", hex_timestamp);
ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex")
}
+
+fn load_telemetry_config() -> TelemetryConfig {
+ let cfg_path = get_base_directory().join("config\\user_config.json");
+ if !cfg_path.exists() {
+ return TelemetryConfig {
+ TELEMETRY: false,
+ TELEM_URL: None,
+ };
+ }
+ match fs::read_to_string(&cfg_path) {
+ Ok(contents) => {
+ serde_json::from_str::(&contents).unwrap_or(TelemetryConfig {
+ TELEMETRY: false,
+ TELEM_URL: None,
+ })
+ }
+ Err(_) => TelemetryConfig {
+ TELEMETRY: false,
+ TELEM_URL: None,
+ },
+ }
+}
+
+fn init_tracer() -> Result