411 lines
20 KiB
Python
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)
|