nicegui_app/main.py

411 lines
20 KiB
Python

from nicegui import ui, app
import psutil
import datetime
import multiprocessing
import subprocess
import os
from services import (
init_db, get_note_content, save_note_content_process,
tarea_backup_process, tarea_alarma_thread,
trafico_red, tareas_activas,
tarea_scraping_thread, tarea_reproducir_mp3_thread,
ejecutar_en_thread, ejecutar_en_proceso
)
import time
# Inicializar DB
init_db()
# =============================================================================
# UI LAYOUT
# =============================================================================
@ui.page('/')
def main():
# Estilos globales
ui.add_head_html('<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">')
ui.colors(primary='#5898d4') # Un azul tipo VS Code
# Estado local para las alarmas de este cliente
# Usamos una lista para comunicar el thread con la UI
alarmas_completadas = []
scraping_resultados = []
mp3_estado = []
with ui.header().classes('items-center justify-between bg-slate-800 text-white'):
ui.label('PSP Dashboard').classes('text-xl font-bold')
ui.label('NiceGUI + Python').classes('text-sm opacity-70')
# Contenedor principal
with ui.column().classes('w-full p-4 gap-4'):
# --- FILA 1: RELOJ Y RED ---
with ui.row().classes('w-full gap-4 items-stretch'):
# Reloj
with ui.card().classes('flex-1 w-0'):
ui.label('Hora del sistema').classes('text-gray-500 text-sm')
lbl_clock = ui.label().classes('text-4xl font-mono')
# Timer para actualizar reloj
ui.timer(1.0, lambda: lbl_clock.set_text(datetime.datetime.now().strftime('%H:%M:%S')))
# Red
with ui.card().classes('flex-1 w-0 items-end'):
ui.label('Tráfico de red').classes('text-gray-500 text-sm')
with ui.row().classes('gap-6 mt-2 justify-end'):
# Bajada
with ui.column().classes('items-end'):
with ui.row().classes('items-center gap-2 mb-1'):
ui.label('Bajada').classes('text-xs text-gray-600')
ui.icon('download', size='sm').classes('text-green-600')
lbl_rx = ui.label('0 KB/s').classes('text-2xl font-bold text-green-600')
# Subida
with ui.column().classes('items-end'):
with ui.row().classes('items-center gap-2 mb-1'):
ui.label('Subida').classes('text-xs text-gray-600')
ui.icon('upload', size='sm').classes('text-blue-600')
lbl_tx = ui.label('0 KB/s').classes('text-2xl font-bold text-blue-600')
def update_net():
lbl_rx.set_text(f"{trafico_red['bajada_kbps']:.1f} KB/s")
lbl_tx.set_text(f"{trafico_red['subida_kbps']:.1f} KB/s")
ui.timer(1.0, update_net)
# --- FILA 2: RECURSOS ---
with ui.card().classes('w-full'):
ui.label('Recursos del sistema').classes('text-gray-500 text-sm mb-2')
with ui.row().classes('w-full justify-around'):
# CPU
with ui.column().classes('items-center'):
ui.label('CPU').classes('text-xs mb-2')
with ui.element('div').classes('relative'):
progress_cpu = ui.circular_progress(value=0, show_value=False, color='red').props('size=80px thickness=0.15')
lbl_cpu = ui.label('0%').classes('text-lg font-bold absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2')
# RAM
with ui.column().classes('items-center'):
ui.label('RAM').classes('text-xs mb-2')
with ui.element('div').classes('relative'):
progress_ram = ui.circular_progress(value=0, show_value=False, color='purple').props('size=80px thickness=0.15')
lbl_ram = ui.label('0%').classes('text-lg font-bold absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2')
# Threads
with ui.column().classes('items-center'):
ui.label('Tareas Activas').classes('text-xs mb-2')
lbl_threads = ui.label('0').classes('text-3xl font-mono')
lbl_threads_detalle = ui.label('').classes('text-xs text-gray-500 mt-1')
def update_resources():
cpu = psutil.cpu_percent()
ram = psutil.virtual_memory().percent
progress_cpu.set_value(cpu / 100)
lbl_cpu.set_text(f"{cpu:.0f}%")
progress_ram.set_value(ram / 100)
lbl_ram.set_text(f"{ram:.0f}%")
# Contamos tareas activas en los pools
total_tareas = tareas_activas['threads'] + tareas_activas['procesos']
lbl_threads.set_text(str(total_tareas))
lbl_threads_detalle.set_text(f"{tareas_activas['threads']} threads • {tareas_activas['procesos']} procesos")
ui.timer(2.0, update_resources)
# --- T1: MULTIPROCESOS ---
ui.label('T1 · Multiprocesos').classes('text-lg font-bold mt-4')
with ui.row().classes('w-full gap-4 items-stretch'):
# Lanzar Apps
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Lanzar Apps').classes('text-gray-500 text-sm')
sel_app = ui.select(['Safari', 'Google Chrome'], value='Safari').classes('w-full')
inp_url = ui.input(placeholder='https://...').classes('w-full')
def launch():
try:
subprocess.Popen(['open', '-a', sel_app.value, inp_url.value])
ui.notify(f'Lanzando {sel_app.value}...', type='positive')
except Exception as e:
ui.notify(f'Error: {e}', type='negative')
with ui.element('div').classes('flex-grow'):
pass
ui.button('Lanzar', on_click=launch).classes('w-full mt-2 bg-cyan-600')
# Backup
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Backup').classes('text-gray-500 text-sm')
inp_source = ui.input(placeholder='Ruta (vacío=actual)').classes('w-full')
def do_backup():
origen = inp_source.value or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
destino = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
# Lanzar proceso usando el pool (no bloqueante)
ejecutar_en_proceso(tarea_backup_process, origen, destino)
ui.notify('Backup iniciado en segundo plano...', type='info')
with ui.element('div').classes('flex-grow'):
pass
ui.button('Crear ZIP', on_click=do_backup).classes('w-full mt-2 bg-indigo-600')
# Editor
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Editor (Persistencia)').classes('text-gray-500 text-sm')
# Cargar contenido inicial
txt_editor = ui.textarea(value=get_note_content()).classes('w-full flex-grow').props('rows=3')
def save_editor():
content = txt_editor.value
# Lanzar proceso usando el pool (no bloqueante)
ejecutar_en_proceso(save_note_content_process, content)
ui.notify('Guardando notas...', type='info')
ui.button('Guardar', on_click=save_editor).classes('w-full mt-2 bg-teal-600')
# --- T2: MULTIHILOS ---
ui.label('T2 · Multihilos').classes('text-lg font-bold mt-4')
with ui.row().classes('w-full gap-4 items-stretch'):
# Alarma
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Alarma').classes('text-gray-500 text-sm')
with ui.column().classes('w-full flex-grow items-center justify-center'):
# Reloj visual con progreso circular
with ui.element('div').classes('relative mb-3'):
alarm_progress = ui.circular_progress(value=0, show_value=False, color='orange').props('size=100px thickness=0.1')
with ui.column().classes('absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 items-center'):
ui.icon('alarm', size='xl').classes('text-orange-500')
lbl_alarm_countdown = ui.label('').classes('text-xs font-mono text-gray-600')
# Controles compactos
with ui.row().classes('w-full gap-2'):
inp_sec = ui.number(label='Segundos', value=10, min=1, max=300).classes('flex-1')
inp_msg = ui.input(label='Mensaje', value='¡Tiempo!').classes('flex-1')
# Variables para el countdown
alarm_state = {'tiempo_restante': 0, 'tiempo_total': 0, 'activa': False}
def update_alarm_display():
if alarm_state['activa'] and alarm_state['tiempo_restante'] > 0:
alarm_state['tiempo_restante'] -= 0.1
if alarm_state['tiempo_restante'] < 0:
alarm_state['tiempo_restante'] = 0
# Actualizar progreso circular (invertido: empieza en 1 y va a 0)
progreso = alarm_state['tiempo_restante'] / alarm_state['tiempo_total']
alarm_progress.set_value(1 - progreso)
# Mostrar tiempo restante
segundos = int(alarm_state['tiempo_restante'])
lbl_alarm_countdown.set_text(f"{segundos}s")
elif not alarm_state['activa']:
lbl_alarm_countdown.set_text('')
alarm_progress.set_value(0)
ui.timer(0.1, update_alarm_display)
def check_completed_alarms():
# Revisamos si hay alarmas en la lista compartida
while alarmas_completadas:
msg = alarmas_completadas.pop(0)
alarm_state['activa'] = False
alarm_state['tiempo_restante'] = 0
lbl_alarm_countdown.set_text('¡ALARMA!')
ui.notify(f"⏰ ALARMA: {msg}", type='warning', close_button=True, timeout=0)
ui.run_javascript('new Audio("https://actions.google.com/sounds/v1/alarms/beep_short.ogg").play()')
# Timer que revisa la cola de alarmas cada 500ms
ui.timer(0.5, check_completed_alarms)
def start_alarm():
sec = inp_sec.value
msg = inp_msg.value
if sec <= 0 or sec > 300:
ui.notify('Tiempo debe estar entre 1 y 300 segundos', type='negative')
return
# Lógica del thread de alarma usando el pool
def thread_logic(s, m, lista_salida):
time.sleep(s)
lista_salida.append(m)
# Ejecutar en el pool de threads (no bloqueante)
ejecutar_en_thread(thread_logic, sec, msg, alarmas_completadas)
# Iniciar countdown visual
alarm_state['tiempo_total'] = sec
alarm_state['tiempo_restante'] = sec
alarm_state['activa'] = True
ui.notify(f'Alarma configurada para {sec}s', type='positive')
ui.button('⏰ Iniciar Alarma', on_click=start_alarm).classes('w-full mt-2 bg-orange-500')
# Scraping
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Web Scraping').classes('text-gray-500 text-sm')
inp_scraping_url = ui.input(placeholder='https://example.com').classes('w-full')
with ui.column().classes('w-full flex-grow mt-2'):
lbl_scraping_titulo = ui.label('').classes('text-xs font-bold text-gray-700')
lbl_scraping_stats = ui.label('').classes('text-xs text-gray-600')
lbl_scraping_desc = ui.label('').classes('text-xs text-gray-500 italic')
def check_scraping_results():
while scraping_resultados:
resultado = scraping_resultados.pop(0)
if 'error' in resultado:
lbl_scraping_titulo.set_text("❌ Error")
lbl_scraping_stats.set_text(str(resultado['error'])[:60])
lbl_scraping_desc.set_text('')
ui.notify(f"Error en scraping: {resultado['error']}", type='negative')
else:
lbl_scraping_titulo.set_text(f"{resultado['titulo'][:40]}...")
lbl_scraping_stats.set_text(f"{resultado['num_links']} links • {resultado['num_imagenes']} imgs • {resultado['num_parrafos']} párrafos")
lbl_scraping_desc.set_text(resultado.get('descripcion', '')[:80])
ui.notify(f"Scraping completado: {resultado['titulo']}", type='positive')
ui.timer(0.5, check_scraping_results)
def start_scraping():
url = inp_scraping_url.value
if not url or not url.startswith('http'):
ui.notify('URL inválida', type='negative')
return
# Ejecutar en el pool de threads (no bloqueante)
ejecutar_en_thread(tarea_scraping_thread, url, scraping_resultados)
lbl_scraping_titulo.set_text('🔄 Scraping en progreso...')
lbl_scraping_stats.set_text('')
lbl_scraping_desc.set_text('')
ui.notify('Scraping iniciado...', type='info')
ui.button('Scrapear', on_click=start_scraping).classes('w-full mt-2 bg-purple-600')
# Reproductor MP3
with ui.card().classes('flex-1 min-w-0 flex flex-col'):
ui.label('Reproductor MP3').classes('text-gray-500 text-sm')
# Variable para almacenar la ruta del archivo
mp3_file_path = {'path': ''}
with ui.column().classes('w-full flex-grow'):
lbl_mp3_filename = ui.label('Sin archivo seleccionado').classes('text-xs text-gray-500 mb-2')
# Upload component para seleccionar/arrastrar archivo
async def handle_upload(e):
print("[Upload] Evento recibido")
try:
# El archivo viene en e.file (FileUpload object)
# Leer contenido del archivo (es async!)
content = await e.file.read()
# Guardar archivo temporalmente
temp_path = f"/tmp/{e.file.name}"
with open(temp_path, 'wb') as f:
f.write(content)
mp3_file_path['path'] = temp_path
lbl_mp3_filename.set_text(f"{e.file.name}")
ui.notify(f'Archivo cargado: {e.file.name}', type='positive')
print(f"[Upload] Guardado en: {temp_path}")
except Exception as ex:
print(f"[Upload] Error: {ex}")
ui.notify(f'Error al cargar: {ex}', type='negative')
ui.upload(
on_upload=handle_upload,
auto_upload=True,
max_files=1,
label='Arrastra MP3 aquí o haz clic'
).props('accept=".mp3,audio/mpeg" color="pink"').classes('w-full')
lbl_mp3_estado = ui.label('').classes('text-xs text-gray-600 mt-2')
def check_mp3_status():
while mp3_estado:
estado = mp3_estado.pop(0)
if estado['status'] == 'completed':
lbl_mp3_estado.set_text("Reproducción completada")
ui.notify('Reproducción finalizada', type='positive')
elif estado['status'] == 'error':
lbl_mp3_estado.set_text(f"Error: {estado['error']}")
ui.notify(f"Error: {estado['error']}", type='negative')
ui.timer(0.5, check_mp3_status)
def play_mp3():
archivo = mp3_file_path['path']
print(f"[Play] Intentando reproducir: '{archivo}'")
print(f"[Play] Estado mp3_file_path: {mp3_file_path}")
if not archivo:
print("[Play] No hay archivo en mp3_file_path")
ui.notify('Por favor, sube un archivo MP3 primero', type='warning')
return
if not os.path.exists(archivo):
print(f"[Play] Archivo no existe: {archivo}")
ui.notify('Archivo no encontrado', type='negative')
return
print(f"[Play] Lanzando reproducción de: {archivo}")
# Ejecutar en el pool de threads (no bloqueante)
ejecutar_en_thread(tarea_reproducir_mp3_thread, archivo, mp3_estado)
lbl_mp3_estado.set_text('Reproduciendo...')
ui.notify('Reproducción iniciada...', type='info')
ui.button('Reproducir', on_click=play_mp3).classes('w-full mt-2 bg-pink-600')
# --- T3: SOCKETS ---
ui.label('T3 · Sockets').classes('text-lg font-bold mt-4')
with ui.row().classes('w-full gap-4 items-stretch'):
# Chat
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Chat').classes('text-gray-400')
# TCP Server
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('TCP Server').classes('text-gray-400')
# Hueco
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Placeholder').classes('text-gray-400')
# --- T4: SERVICIOS ---
ui.label('T4 · Servicios').classes('text-lg font-bold mt-4')
with ui.row().classes('w-full gap-4 items-stretch'):
# HTTP Client
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('HTTP Client').classes('text-gray-400')
# Hueco
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Placeholder').classes('text-gray-400')
# Hueco
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Placeholder').classes('text-gray-400')
# --- T5: SEGURIDAD ---
ui.label('T5 · Seguridad').classes('text-lg font-bold mt-4')
with ui.row().classes('w-full gap-4 items-stretch'):
# Hash SHA256
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Hash SHA256').classes('text-gray-400')
# Hueco
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Placeholder').classes('text-gray-400')
# Hueco
with ui.card().classes('flex-1 min-w-0 bg-gray-50 flex flex-col'):
ui.label('Placeholder').classes('text-gray-400')
if __name__ in {"__main__", "__mp_main__"}:
ui.run(title='PSP Dashboard', port=8080, reload=True)