Files
homeflow/internal/api/ui/index.html
T
alexandrev 938b895d37 feat: v0.1.0 — UI, control flow, new actions
- 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>
2026-05-21 23:07:39 +02:00

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>