551 lines
20 KiB
Python
551 lines
20 KiB
Python
from flask import Flask, render_template, request, jsonify, url_for
|
|
from flask_sock import Sock
|
|
from lib.config_manager import ConfigurationManager
|
|
import os
|
|
import json # Added import
|
|
from datetime import datetime
|
|
import time # Added for shutdown delay
|
|
import sys # Added for platform detection
|
|
import subprocess # Add this to the imports at the top
|
|
|
|
# --- Imports for System Tray Icon ---
|
|
import threading
|
|
import webbrowser
|
|
import sys
|
|
import requests # To send shutdown request
|
|
from PIL import Image
|
|
import pystray
|
|
|
|
app = Flask(
|
|
__name__, static_url_path="", static_folder="static", template_folder="templates"
|
|
)
|
|
sock = Sock(app)
|
|
config_manager = ConfigurationManager()
|
|
|
|
# Lista global para mantener las conexiones WebSocket activas
|
|
websocket_connections = set()
|
|
|
|
# --- Globals for Tray Icon ---
|
|
tray_icon = None
|
|
|
|
|
|
@sock.route("/ws")
|
|
def handle_websocket(ws):
|
|
try:
|
|
websocket_connections.add(ws)
|
|
while True:
|
|
message = ws.receive()
|
|
if message:
|
|
broadcast_message(message)
|
|
except Exception as e:
|
|
print(f"WebSocket error: {e}")
|
|
finally:
|
|
websocket_connections.remove(ws)
|
|
|
|
|
|
def broadcast_message(message):
|
|
"""Envía un mensaje a todas las conexiones WebSocket activas y guarda en log."""
|
|
dead_connections = set()
|
|
timestamp = datetime.now().strftime("[%H:%M:%S] ")
|
|
|
|
# Normalize input to a list of messages
|
|
if isinstance(message, list):
|
|
messages = message
|
|
else:
|
|
# Si es un solo mensaje, dividirlo en líneas
|
|
messages = [line.strip() for line in message.splitlines() if line.strip()]
|
|
|
|
# Procesar cada mensaje
|
|
for raw_msg in messages:
|
|
# Limpiar timestamps duplicados al inicio del mensaje
|
|
while raw_msg.startswith("[") and "]" in raw_msg:
|
|
try:
|
|
closing_bracket = raw_msg.index("]") + 1
|
|
if raw_msg[1 : closing_bracket - 1].replace(":", "").isdigit():
|
|
raw_msg = raw_msg[closing_bracket:].strip() # Update raw_msg itself
|
|
else:
|
|
break
|
|
except:
|
|
break
|
|
|
|
# Log the raw message using the config_manager's logger
|
|
# The logger will handle its own timestamping for the file.
|
|
config_manager.append_log(raw_msg)
|
|
|
|
# Format message with timestamp *for WebSocket broadcast*
|
|
formatted_msg_for_ws = f"{timestamp}{raw_msg}"
|
|
|
|
# Enviar a todos los clientes WebSocket
|
|
for ws in list(websocket_connections):
|
|
try:
|
|
if ws.connected: # Check if ws is still connected before sending
|
|
ws.send(
|
|
f"{formatted_msg_for_ws}\n"
|
|
) # Use the correct variable name here
|
|
except Exception:
|
|
dead_connections.add(ws) # Collect dead connections
|
|
|
|
# Limpiar conexiones muertas
|
|
websocket_connections.difference_update(dead_connections)
|
|
|
|
|
|
@app.route("/api/execute_script", methods=["POST"])
|
|
def execute_script():
|
|
try:
|
|
script_group = request.json["group"]
|
|
script_name = request.json["script"]
|
|
|
|
# Ejecutar el script y obtener resultado
|
|
result = config_manager.execute_script(
|
|
script_group, script_name, broadcast_message
|
|
)
|
|
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
error_msg = f"Error ejecutando script: {str(e)}"
|
|
broadcast_message(error_msg)
|
|
return jsonify({"error": error_msg})
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
script_groups = config_manager.get_script_groups()
|
|
# Para ordenar una lista de diccionarios, necesitamos especificar una clave.
|
|
# Asumimos que cada diccionario tiene una clave 'name' por la cual ordenar.
|
|
sorted_script_groups = sorted(script_groups, key=lambda group: group['name'])
|
|
return render_template("index.html", script_groups=sorted_script_groups)
|
|
|
|
|
|
@app.route("/api/config/<level>", methods=["GET", "POST"])
|
|
def handle_config(level):
|
|
group = request.args.get("group")
|
|
if request.method == "GET":
|
|
try:
|
|
return jsonify(config_manager.get_config(level, group))
|
|
except FileNotFoundError:
|
|
return jsonify({})
|
|
else:
|
|
data = request.json
|
|
config_manager.update_config(level, data, group)
|
|
return jsonify({"status": "success"})
|
|
|
|
|
|
@app.route("/api/schema/<level>", methods=["GET", "POST"])
|
|
def handle_schema(level):
|
|
group = request.args.get("group")
|
|
if request.method == "GET":
|
|
return jsonify(config_manager.get_schema(level, group))
|
|
else:
|
|
data = request.json
|
|
config_manager.update_schema(level, data, group)
|
|
return jsonify({"status": "success"})
|
|
|
|
|
|
@app.route("/api/scripts/<group>")
|
|
def get_scripts(group):
|
|
# list_scripts ahora devuelve detalles y filtra los ocultos
|
|
scripts = config_manager.list_scripts(group)
|
|
# El frontend espera 'name' y 'description', mapeamos desde 'display_name' y 'short_description'
|
|
return jsonify(
|
|
[
|
|
{
|
|
"name": s["display_name"],
|
|
"description": s["short_description"],
|
|
"filename": s["filename"],
|
|
"long_description": s["long_description"],
|
|
}
|
|
for s in scripts
|
|
]
|
|
)
|
|
|
|
|
|
@app.route("/api/working-directory", methods=["POST"])
|
|
def set_working_directory():
|
|
data = request.json
|
|
if not data:
|
|
return jsonify({"status": "error", "message": "No data provided"})
|
|
|
|
path = data.get("path")
|
|
group = data.get("group")
|
|
|
|
if not path or not group:
|
|
return jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"Missing required fields. Path: {path}, Group: {group}",
|
|
}
|
|
)
|
|
|
|
print(f"Setting working directory - Path: {path}, Group: {group}") # Debug line
|
|
return jsonify(config_manager.set_work_dir(group, path))
|
|
|
|
|
|
@app.route("/api/working-directory/<group>", methods=["GET"])
|
|
def get_working_directory(group):
|
|
path = config_manager.get_work_dir(group)
|
|
return jsonify({"path": path, "status": "success" if path else "not_set"})
|
|
|
|
|
|
@app.route("/api/browse-directories")
|
|
def browse_directories():
|
|
import tkinter as tk
|
|
from tkinter import filedialog
|
|
|
|
# Obtener el directorio inicial
|
|
current_dir = request.args.get("current_path")
|
|
if not current_dir or not os.path.exists(current_dir):
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# Crear y configurar la ventana principal de tkinter
|
|
root = tk.Tk()
|
|
root.attributes("-topmost", True) # Mantener la ventana siempre arriba
|
|
root.withdraw()
|
|
|
|
# Abrir el diálogo de selección de directorio
|
|
directory = filedialog.askdirectory(
|
|
initialdir=current_dir, title="Seleccionar Directorio de Trabajo"
|
|
)
|
|
|
|
# Destruir la ventana de tkinter
|
|
root.destroy()
|
|
|
|
if directory:
|
|
return jsonify({"status": "success", "path": directory})
|
|
return jsonify({"status": "cancelled"})
|
|
|
|
|
|
@app.route("/api/logs", methods=["GET", "DELETE"])
|
|
def handle_logs():
|
|
if request.method == "GET":
|
|
return jsonify({"logs": config_manager.read_log()})
|
|
else: # DELETE
|
|
success = config_manager.clear_log()
|
|
return jsonify({"status": "success" if success else "error"})
|
|
|
|
|
|
@app.route("/api/group-description/<group>", methods=["GET", "POST"])
|
|
def handle_group_description(group):
|
|
if request.method == "GET":
|
|
try:
|
|
details = config_manager.get_group_details(group)
|
|
if "error" in details:
|
|
return jsonify(details), 404 # Group not found
|
|
return jsonify(details)
|
|
except Exception as e:
|
|
return jsonify({"status": "error", "message": str(e)}), 500
|
|
else: # POST
|
|
try:
|
|
data = request.json
|
|
result = config_manager.update_group_description(group, data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
return jsonify({"status": "error", "message": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/script-details/<group>/<script_filename>", methods=["GET", "POST"])
|
|
def handle_script_details(group, script_filename):
|
|
if request.method == "GET":
|
|
try:
|
|
details = config_manager.get_script_details(group, script_filename)
|
|
return jsonify(details)
|
|
except Exception as e:
|
|
print(f"Error getting script details for {group}/{script_filename}: {e}")
|
|
return jsonify({"status": "error", "message": str(e)}), 500
|
|
else: # POST
|
|
try:
|
|
data = request.json
|
|
result = config_manager.update_script_details(group, script_filename, data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
print(f"Error updating script details for {group}/{script_filename}: {e}")
|
|
return jsonify({"status": "error", "message": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/directory-history/<group>")
|
|
def get_directory_history(group):
|
|
history = config_manager.get_directory_history(group)
|
|
return jsonify(history)
|
|
|
|
|
|
@app.route("/api/open-vscode/<group>", methods=["POST"])
|
|
def open_group_in_vscode(group):
|
|
try:
|
|
# Get the full path to the script group directory
|
|
script_group_path = os.path.join(config_manager.script_groups_path, group)
|
|
|
|
if not os.path.isdir(script_group_path):
|
|
return (
|
|
jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"Directorio del grupo '{group}' no encontrado",
|
|
}
|
|
),
|
|
404,
|
|
)
|
|
|
|
# VS Code executable path
|
|
vscode_path = (
|
|
r"C:\Users\migue\AppData\Local\Programs\Microsoft VS Code\Code.exe"
|
|
)
|
|
|
|
# Check if the file exists
|
|
if not os.path.isfile(vscode_path):
|
|
return (
|
|
jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"VS Code no encontrado en: {vscode_path}",
|
|
}
|
|
),
|
|
404,
|
|
)
|
|
|
|
print(f"Launching VS Code from: {vscode_path}")
|
|
print(f"Opening directory: {script_group_path}")
|
|
|
|
# Try with shell=True which can help with Windows path issues
|
|
process = subprocess.Popen(f'"{vscode_path}" "{script_group_path}"', shell=True)
|
|
|
|
# Log the process ID for debugging
|
|
print(f"Process started with PID: {process.pid}")
|
|
|
|
return jsonify(
|
|
{"status": "success", "message": f"VS Code abierto en: {script_group_path}"}
|
|
)
|
|
except Exception as e:
|
|
print(f"Error opening VS Code for group '{group}': {str(e)}")
|
|
return (
|
|
jsonify(
|
|
{"status": "error", "message": f"Error al abrir VS Code: {str(e)}"}
|
|
),
|
|
500,
|
|
)
|
|
|
|
|
|
@app.route("/api/open-miniconda", methods=["POST"])
|
|
def open_miniconda_console():
|
|
try:
|
|
# Path to the Miniconda installation
|
|
miniconda_path = r"C:\Users\migue\miniconda3"
|
|
|
|
# Check if directory exists
|
|
if not os.path.isdir(miniconda_path):
|
|
return (
|
|
jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"Miniconda no encontrado en: {miniconda_path}",
|
|
}
|
|
),
|
|
404,
|
|
)
|
|
|
|
# Path to the activate script
|
|
activate_path = os.path.join(miniconda_path, "Scripts", "activate.bat")
|
|
|
|
if not os.path.isfile(activate_path):
|
|
return (
|
|
jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"Script de activación no encontrado en: {activate_path}",
|
|
}
|
|
),
|
|
404,
|
|
)
|
|
|
|
print(f"Opening Miniconda Console from: {miniconda_path}")
|
|
|
|
# Use subprocess with CREATE_NEW_CONSOLE flag to ensure the window appears
|
|
# Start the Windows command processor and tell it to run the activate batch file
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = 1 # SW_SHOWNORMAL
|
|
|
|
# Run cmd.exe with specific flags to show a window
|
|
process = subprocess.Popen(
|
|
[
|
|
"cmd.exe",
|
|
"/K",
|
|
f"echo Activating Miniconda environment... && "
|
|
f'"{activate_path}" && '
|
|
f"echo Miniconda activated successfully. && "
|
|
f"cd /d {miniconda_path}",
|
|
],
|
|
creationflags=subprocess.CREATE_NEW_CONSOLE,
|
|
startupinfo=startupinfo,
|
|
)
|
|
|
|
print(f"Miniconda Console process started with PID: {process.pid}")
|
|
|
|
return jsonify(
|
|
{"status": "success", "message": "Miniconda Console abierta correctamente"}
|
|
)
|
|
except Exception as e:
|
|
print(f"Error opening Miniconda Console: {str(e)}")
|
|
return (
|
|
jsonify(
|
|
{
|
|
"status": "error",
|
|
"message": f"Error al abrir Miniconda Console: {str(e)}",
|
|
}
|
|
),
|
|
500,
|
|
)
|
|
|
|
|
|
@app.route("/api/open-explorer", methods=["POST"])
|
|
def open_explorer_route():
|
|
data = request.json
|
|
frontend_path = data.get("path")
|
|
group = data.get("group")
|
|
|
|
if not group:
|
|
return jsonify({"status": "error", "message": "Grupo no proporcionado."}), 400
|
|
if not frontend_path:
|
|
return jsonify({"status": "error", "message": "Ruta no proporcionada."}), 400
|
|
|
|
# Obtener el directorio de trabajo configurado y normalizado para el grupo
|
|
configured_work_dir_raw = config_manager.get_work_dir(group)
|
|
if not configured_work_dir_raw:
|
|
return jsonify({"status": "error", "message": f"No hay directorio de trabajo configurado para el grupo '{group}'."}), 404
|
|
|
|
configured_work_dir_abs = os.path.abspath(configured_work_dir_raw)
|
|
frontend_path_abs = os.path.abspath(frontend_path)
|
|
|
|
# Validar que la ruta del frontend coincide con la ruta configurada
|
|
if configured_work_dir_abs != frontend_path_abs:
|
|
print(f"Intento de acceso no válido: Grupo '{group}', Frontend Path '{frontend_path_abs}', Configured Path '{configured_work_dir_abs}'")
|
|
return jsonify({"status": "error", "message": "La ruta proporcionada no coincide con el directorio de trabajo seguro para este grupo."}), 403
|
|
|
|
# Validar que la ruta (ahora sabemos que es la configurada) realmente existe
|
|
if not os.path.isdir(configured_work_dir_abs):
|
|
return jsonify({"status": "error", "message": f"El directorio de trabajo configurado '{configured_work_dir_abs}' no es un directorio válido o no existe."}), 400
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
os.startfile(configured_work_dir_abs)
|
|
elif sys.platform == "darwin": # macOS
|
|
subprocess.Popen(["open", configured_work_dir_abs])
|
|
else: # linux variants
|
|
subprocess.Popen(["xdg-open", configured_work_dir_abs])
|
|
|
|
return jsonify({"status": "success", "message": f"Abriendo '{configured_work_dir_abs}' en el explorador."})
|
|
except Exception as e:
|
|
error_msg = f"Error al abrir el explorador en '{configured_work_dir_abs}': {str(e)}"
|
|
print(error_msg)
|
|
return jsonify({"status": "error", "message": error_msg}), 500
|
|
|
|
|
|
# --- System Tray Icon Functions ---
|
|
|
|
|
|
def run_flask():
|
|
"""Runs the Flask app."""
|
|
print("Starting Flask server on http://127.0.0.1:5000/")
|
|
try:
|
|
# use_reloader=False is important when running in a thread
|
|
# For production, consider using waitress or gunicorn instead of app.run
|
|
app.run(host="127.0.0.1", port=5000, debug=True, use_reloader=False)
|
|
except Exception as e:
|
|
print(f"Error running Flask app: {e}")
|
|
# Optionally try to stop the tray icon if Flask fails critically
|
|
if tray_icon:
|
|
print("Attempting to stop tray icon due to Flask error.")
|
|
tray_icon.stop()
|
|
|
|
|
|
def open_app_browser(icon, item):
|
|
"""Callback function to open the browser."""
|
|
print("Opening application in browser...")
|
|
webbrowser.open("http://127.0.0.1:5000/")
|
|
|
|
|
|
def shutdown_flask_server():
|
|
"""Attempts to gracefully shut down the Werkzeug server."""
|
|
try:
|
|
# This requires the development server (werkzeug)
|
|
# Send a request to a special shutdown route
|
|
requests.post("http://127.0.0.1:5000/_shutdown", timeout=1)
|
|
except Exception as e:
|
|
print(f"Could not send shutdown request to Flask server: {e}")
|
|
print("Flask server might need to be closed manually.")
|
|
|
|
|
|
def stop_icon_thread():
|
|
"""Helper function to stop the icon after a delay, allowing HTTP response."""
|
|
time.sleep(0.1) # Small delay to allow the HTTP response to be sent
|
|
if tray_icon:
|
|
print("Stopping tray icon from shutdown route...")
|
|
tray_icon.stop()
|
|
else:
|
|
print("Tray icon not available to stop.")
|
|
# As a last resort if the icon isn't running for some reason
|
|
# print("Attempting os._exit(0) as fallback.")
|
|
# os._exit(0) # Force exit - use with caution
|
|
|
|
|
|
@app.route("/_shutdown", methods=["POST"])
|
|
def shutdown_route():
|
|
"""Internal route to shut down the application via the tray icon."""
|
|
print("Shutdown endpoint called.")
|
|
# Stop the main application thread by stopping the tray icon.
|
|
# Do this in a separate thread to allow the HTTP response to return first.
|
|
stopper = threading.Thread(target=stop_icon_thread, daemon=True)
|
|
stopper.start()
|
|
print("Shutdown signal sent to tray icon thread.")
|
|
return jsonify(status="success", message="Application shutdown initiated..."), 200
|
|
|
|
|
|
def exit_application(icon, item):
|
|
"""Callback function to exit the application."""
|
|
print("Exit requested via tray menu.")
|
|
# Just stop the icon. This will end the main thread, and the daemon Flask thread will exit.
|
|
print("Stopping tray icon...")
|
|
if icon: # pystray passes the icon object
|
|
icon.stop()
|
|
elif tray_icon: # Fallback just in case
|
|
tray_icon.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# --- Start Flask in a background thread ---
|
|
flask_thread = threading.Thread(target=run_flask, daemon=True)
|
|
flask_thread.start()
|
|
|
|
# --- Setup and run the system tray icon ---
|
|
icon_path = (
|
|
r"d:\Proyectos\Scripts\ParamManagerScripts\icon.png" # Use absolute path
|
|
)
|
|
try:
|
|
image = Image.open(icon_path)
|
|
menu = pystray.Menu(
|
|
pystray.MenuItem("Abrir ParamManager", open_app_browser, default=True),
|
|
pystray.MenuItem("Salir", exit_application),
|
|
)
|
|
tray_icon = pystray.Icon("ParamManager", image, "ParamManager", menu)
|
|
print("Starting system tray icon...")
|
|
tray_icon.run() # This blocks the main thread until icon.stop() is called
|
|
except FileNotFoundError:
|
|
print(
|
|
f"Error: Icono no encontrado en '{icon_path}'. El icono de notificación no se iniciará.",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"La aplicación Flask seguirá ejecutándose en segundo plano. Presiona Ctrl+C para detenerla si es necesario."
|
|
)
|
|
# Keep the main thread alive so the Flask thread doesn't exit immediately
|
|
# This allows Flask to continue running even without the tray icon.
|
|
try:
|
|
while flask_thread.is_alive():
|
|
flask_thread.join(timeout=1.0) # Wait indefinitely
|
|
except KeyboardInterrupt:
|
|
print("\nCtrl+C detectado. Intentando detener Flask...")
|
|
shutdown_flask_server() # Try to shutdown Flask on Ctrl+C too
|
|
print("Saliendo.")
|
|
except Exception as e:
|
|
print(f"Error al iniciar el icono de notificación: {e}", file=sys.stderr)
|
|
|
|
print("Aplicación finalizada.")
|