938b895d37
- Menu bar icon (systray) + embedded web UI (Monaco + Tailwind) at localhost:7070 - REST API: status, config editor, logs, import/export rules - Control flow actions: if/else, switch/cases, for_each, per-action retry - Condition evaluators: string, numeric, date, existence operators with AND/OR/NOT - New actions: dedup, ocr (ocrmypdf/tesseract), image_resize/convert/info, read_metadata - Pipeline vars: SetVar() lets any action publish data to subsequent steps - Config: ActionDef supports nested sub-pipelines, RetryConfig, ConditionDef Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
15 KiB
HTML
356 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es" class="h-full">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>homeflow</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
.nav-item { @apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer transition-colors; }
|
|
.nav-item.active { @apply bg-indigo-600 text-white; }
|
|
.nav-item:not(.active) { @apply text-gray-600 hover:bg-gray-100; }
|
|
.badge-success { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800; }
|
|
.badge-error { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800; }
|
|
.badge-skip { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600; }
|
|
#editor { height: calc(100vh - 220px); border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
|
</style>
|
|
</head>
|
|
<body class="h-full bg-gray-50" x-data="app()" x-init="init()">
|
|
|
|
<div class="flex h-screen">
|
|
|
|
<!-- Sidebar -->
|
|
<aside class="w-56 bg-white border-r border-gray-200 flex flex-col">
|
|
<div class="p-4 border-b border-gray-200">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
|
<i class="fa-solid fa-bolt text-white text-sm"></i>
|
|
</div>
|
|
<span class="font-semibold text-gray-900">homeflow</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center gap-1.5">
|
|
<span x-show="!status.paused" class="w-2 h-2 rounded-full bg-green-500"></span>
|
|
<span x-show="status.paused" class="w-2 h-2 rounded-full bg-yellow-400"></span>
|
|
<span class="text-xs text-gray-500" x-text="status.paused ? 'Pausado' : 'Activo'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="flex-1 p-3 space-y-1">
|
|
<div class="nav-item" :class="{active: page==='dashboard'}" @click="page='dashboard'">
|
|
<i class="fa-solid fa-gauge-high w-4"></i> Dashboard
|
|
</div>
|
|
<div class="nav-item" :class="{active: page==='rules'}" @click="page='rules'; loadConfig()">
|
|
<i class="fa-solid fa-sliders w-4"></i> Reglas
|
|
</div>
|
|
<div class="nav-item" :class="{active: page==='logs'}" @click="page='logs'; loadLogs()">
|
|
<i class="fa-solid fa-list-ul w-4"></i> Logs
|
|
</div>
|
|
<div class="nav-item" :class="{active: page==='import'}" @click="page='import'">
|
|
<i class="fa-solid fa-file-import w-4"></i> Import / Export
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="p-3 border-t border-gray-200 space-y-1">
|
|
<button @click="togglePause()"
|
|
class="w-full text-left nav-item"
|
|
:class="status.paused ? 'text-green-600 hover:bg-green-50' : 'text-yellow-600 hover:bg-yellow-50'">
|
|
<i :class="status.paused ? 'fa-solid fa-play' : 'fa-solid fa-pause'" class="w-4"></i>
|
|
<span x-text="status.paused ? 'Reanudar' : 'Pausar'"></span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<main class="flex-1 overflow-auto">
|
|
|
|
<!-- ── Dashboard ─────────────────────────────────────────────────────── -->
|
|
<div x-show="page==='dashboard'" class="p-6">
|
|
<h1 class="text-xl font-semibold text-gray-900 mb-6">Dashboard</h1>
|
|
|
|
<div class="grid grid-cols-3 gap-4 mb-8">
|
|
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
|
<p class="text-sm text-gray-500">Procesados</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="status.processed_total ?? 0"></p>
|
|
</div>
|
|
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
|
<p class="text-sm text-gray-500">Errores</p>
|
|
<p class="text-3xl font-bold text-red-500 mt-1" x-text="status.errors_total ?? 0"></p>
|
|
</div>
|
|
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
|
<p class="text-sm text-gray-500">Directorios vigilados</p>
|
|
<p class="text-3xl font-bold text-gray-900 mt-1" x-text="(status.watches ?? []).length"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
|
<h2 class="text-sm font-medium text-gray-700 mb-3">Directorios monitorizados</h2>
|
|
<ul class="space-y-1">
|
|
<template x-for="w in (status.watches ?? [])">
|
|
<li class="flex items-center gap-2 text-sm text-gray-600">
|
|
<i class="fa-solid fa-folder text-indigo-400 w-4"></i>
|
|
<span x-text="w"></span>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="mt-4 bg-white rounded-xl border border-gray-200 p-5">
|
|
<h2 class="text-sm font-medium text-gray-700 mb-3">Últimas actividades</h2>
|
|
<div x-show="logs.length === 0" class="text-sm text-gray-400">Sin actividad reciente.</div>
|
|
<table x-show="logs.length > 0" class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-gray-400 text-xs border-b">
|
|
<th class="pb-2 font-medium">Estado</th>
|
|
<th class="pb-2 font-medium">Regla</th>
|
|
<th class="pb-2 font-medium">Fichero</th>
|
|
<th class="pb-2 font-medium">Hace</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="r in logs.slice(0,10)">
|
|
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
|
<td class="py-2"><span :class="badgeClass(r.Status)" x-text="r.Status"></span></td>
|
|
<td class="py-2 text-gray-700" x-text="r.Rule"></td>
|
|
<td class="py-2 text-gray-500 font-mono text-xs" x-text="basename(r.Path)"></td>
|
|
<td class="py-2 text-gray-400 text-xs" x-text="ago(r.ProcessedAt)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Rules editor ───────────────────────────────────────────────────── -->
|
|
<div x-show="page==='rules'" class="p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h1 class="text-xl font-semibold text-gray-900">Editor de Reglas</h1>
|
|
<div class="flex gap-2">
|
|
<button @click="saveConfig()"
|
|
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 flex items-center gap-2">
|
|
<i class="fa-solid fa-floppy-disk"></i> Guardar
|
|
</button>
|
|
<button @click="reloadConfig()"
|
|
class="px-4 py-2 bg-white border border-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 flex items-center gap-2">
|
|
<i class="fa-solid fa-rotate-right"></i> Recargar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div x-show="saveMsg" class="mb-3 px-3 py-2 rounded-lg text-sm"
|
|
:class="saveErr ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'"
|
|
x-text="saveMsg"></div>
|
|
<div id="editor"></div>
|
|
</div>
|
|
|
|
<!-- ── Logs ───────────────────────────────────────────────────────────── -->
|
|
<div x-show="page==='logs'" class="p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h1 class="text-xl font-semibold text-gray-900">Logs</h1>
|
|
<button @click="loadLogs()"
|
|
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-1.5">
|
|
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualizar
|
|
</button>
|
|
</div>
|
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<div x-show="logs.length === 0" class="p-6 text-center text-sm text-gray-400">Sin actividad aún.</div>
|
|
<table x-show="logs.length > 0" class="w-full text-sm">
|
|
<thead class="bg-gray-50 border-b">
|
|
<tr class="text-left text-xs text-gray-400">
|
|
<th class="px-4 py-3 font-medium">Estado</th>
|
|
<th class="px-4 py-3 font-medium">Regla</th>
|
|
<th class="px-4 py-3 font-medium">Fichero</th>
|
|
<th class="px-4 py-3 font-medium">Fecha</th>
|
|
<th class="px-4 py-3 font-medium">Error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-50">
|
|
<template x-for="r in logs">
|
|
<tr class="hover:bg-gray-50">
|
|
<td class="px-4 py-2.5"><span :class="badgeClass(r.Status)" x-text="r.Status"></span></td>
|
|
<td class="px-4 py-2.5 text-gray-700" x-text="r.Rule"></td>
|
|
<td class="px-4 py-2.5 text-gray-500 font-mono text-xs max-w-xs truncate" :title="r.Path" x-text="basename(r.Path)"></td>
|
|
<td class="px-4 py-2.5 text-gray-400 text-xs" x-text="ago(r.ProcessedAt)"></td>
|
|
<td class="px-4 py-2.5 text-red-500 text-xs max-w-xs truncate" x-text="r.ErrorMsg"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Import / Export ────────────────────────────────────────────────── -->
|
|
<div x-show="page==='import'" class="p-6">
|
|
<h1 class="text-xl font-semibold text-gray-900 mb-6">Import / Export</h1>
|
|
<div class="grid grid-cols-2 gap-6">
|
|
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h2 class="font-medium text-gray-900 mb-1">Exportar configuración</h2>
|
|
<p class="text-sm text-gray-500 mb-4">Descarga el fichero config.yaml completo.</p>
|
|
<button @click="exportConfig()"
|
|
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 flex items-center gap-2">
|
|
<i class="fa-solid fa-download"></i> Descargar config.yaml
|
|
</button>
|
|
</div>
|
|
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h2 class="font-medium text-gray-900 mb-1">Importar configuración</h2>
|
|
<p class="text-sm text-gray-500 mb-3">Sube un config.yaml para reemplazar o fusionar reglas.</p>
|
|
<div class="flex gap-2 mb-3">
|
|
<label class="flex items-center gap-1.5 text-sm">
|
|
<input type="radio" x-model="importMode" value="replace"> Reemplazar
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-sm">
|
|
<input type="radio" x-model="importMode" value="merge"> Fusionar watches
|
|
</label>
|
|
</div>
|
|
<label class="cursor-pointer">
|
|
<input type="file" accept=".yaml,.yml" class="hidden" @change="importConfig($event)">
|
|
<span class="px-4 py-2 bg-white border border-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 flex items-center gap-2 w-fit">
|
|
<i class="fa-solid fa-upload"></i> Seleccionar fichero
|
|
</span>
|
|
</label>
|
|
<div x-show="importMsg" class="mt-3 text-sm" :class="importErr ? 'text-red-600' : 'text-green-600'" x-text="importMsg"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Alpine.js -->
|
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
<!-- Monaco Editor -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
|
|
|
<script>
|
|
function app() {
|
|
return {
|
|
page: 'dashboard',
|
|
status: {},
|
|
logs: [],
|
|
configText: '',
|
|
saveMsg: '',
|
|
saveErr: false,
|
|
importMode: 'replace',
|
|
importMsg: '',
|
|
importErr: false,
|
|
editor: null,
|
|
|
|
async init() {
|
|
await this.loadStatus();
|
|
await this.loadLogs();
|
|
setInterval(() => this.loadStatus(), 5000);
|
|
this.initMonaco();
|
|
},
|
|
|
|
async loadStatus() {
|
|
try {
|
|
const r = await fetch('/api/status');
|
|
this.status = await r.json();
|
|
} catch {}
|
|
},
|
|
|
|
async loadLogs() {
|
|
try {
|
|
const r = await fetch('/api/logs');
|
|
this.logs = await r.json() || [];
|
|
} catch {}
|
|
},
|
|
|
|
async loadConfig() {
|
|
const r = await fetch('/api/config');
|
|
this.configText = await r.text();
|
|
if (this.editor) this.editor.setValue(this.configText);
|
|
},
|
|
|
|
async reloadConfig() {
|
|
await this.loadConfig();
|
|
},
|
|
|
|
async saveConfig() {
|
|
const content = this.editor ? this.editor.getValue() : this.configText;
|
|
const r = await fetch('/api/config', {
|
|
method: 'POST',
|
|
body: content,
|
|
headers: {'Content-Type': 'text/plain'},
|
|
});
|
|
const data = await r.json();
|
|
if (r.ok) {
|
|
this.saveMsg = '✓ Configuración guardada';
|
|
this.saveErr = false;
|
|
} else {
|
|
this.saveMsg = '✗ ' + (data.error || 'Error al guardar');
|
|
this.saveErr = true;
|
|
}
|
|
setTimeout(() => this.saveMsg = '', 3000);
|
|
},
|
|
|
|
exportConfig() {
|
|
window.location.href = '/api/export';
|
|
},
|
|
|
|
async importConfig(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
const content = await file.text();
|
|
const r = await fetch('/api/import?mode=' + this.importMode, {
|
|
method: 'POST',
|
|
body: content,
|
|
});
|
|
const data = await r.json();
|
|
if (r.ok) {
|
|
this.importMsg = '✓ Importado correctamente';
|
|
this.importErr = false;
|
|
await this.loadConfig();
|
|
} else {
|
|
this.importMsg = '✗ ' + (data.error || 'Error');
|
|
this.importErr = true;
|
|
}
|
|
},
|
|
|
|
async togglePause() {
|
|
const endpoint = this.status.paused ? '/api/resume' : '/api/pause';
|
|
await fetch(endpoint, {method: 'POST'});
|
|
await this.loadStatus();
|
|
},
|
|
|
|
initMonaco() {
|
|
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }});
|
|
require(['vs/editor/editor.main'], () => {
|
|
this.editor = monaco.editor.create(document.getElementById('editor'), {
|
|
value: this.configText || '# Cargando...',
|
|
language: 'yaml',
|
|
theme: 'vs',
|
|
fontSize: 13,
|
|
minimap: { enabled: false },
|
|
scrollBeyondLastLine: false,
|
|
wordWrap: 'on',
|
|
lineNumbers: 'on',
|
|
padding: { top: 12 },
|
|
});
|
|
});
|
|
},
|
|
|
|
badgeClass(status) {
|
|
if (status === 'success') return 'badge-success';
|
|
if (status === 'error') return 'badge-error';
|
|
return 'badge-skip';
|
|
},
|
|
|
|
basename(path) {
|
|
return path ? path.split('/').pop() : '';
|
|
},
|
|
|
|
ago(ts) {
|
|
if (!ts) return '';
|
|
const diff = Math.floor((Date.now() - new Date(ts)) / 1000);
|
|
if (diff < 60) return diff + 's';
|
|
if (diff < 3600) return Math.floor(diff/60) + 'm';
|
|
if (diff < 86400) return Math.floor(diff/3600) + 'h';
|
|
return Math.floor(diff/86400) + 'd';
|
|
},
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|