Files
pulse-memory/pulse-dev/taskboard/public/index.html
T
Pulse Agent adeb4dad33 feat: pulse-dev — dev environment full-stack com hot reload + agentes + taskboard + vault Obsidian
- Stack Swarm 'dev': redis + taskboard + frontend HMR (Vite) + backend HMR (tsx) + 5 agents
- TaskBoard standalone React com Redis pub/sub e BLPOP queue
- Backend API Express: /tasks /agents /health com hot reload
- Agentes: 2 FE (frontend), 2 BE (backend), 1 DevOps (parallel workers)
- Vault Obsidian: /root/Obsidian-Pulse/ com estrutura Inbox/Projetos/Docker/Dev/Codex/Logs/Memorias/Templates
- Skill obsidian-vault-linker instalada e documentada
- Caddy labels: board.octal.tec.br + api.octal.tec.br + frontend.octal.tec.br
- Protocolo task queue Redis documentado em MEMORY.md
2026-05-20 19:40:54 -03:00

240 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Pulse TaskBoard — Dev Orchestrator</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e4e4e7; min-height: 100vh; }
header { background: #1e293b; padding: 12px 24px; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 16px; color: #60a5fa; display: flex; align-items: center; gap: 8px; }
header h1 span { color: #94a3b8; font-weight: 400; font-size: 13px; }
.stats { display: flex; gap: 16px; font-size: 13px; }
.stat { background: #1e293b; border: 1px solid #334155; border-radius: 6px; padding: 4px 12px; }
.stat b { color: #60a5fa; }
main { padding: 20px; display: flex; gap: 16px; height: calc(100vh - 56px); }
.col { flex: 1; display: flex; flex-direction: column; border-radius: 8px; background: #1e293b; border: 1px solid #334155; overflow: hidden; }
.col-header { padding: 12px 16px; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
.col-header .count { background: #334155; padding: 2px 8px; border-radius: 99px; font-size: 11px; }
.todo .col-header { color: #f0b429; } .todo .count { background: #78350f; color: #fde68a; }
.doing .col-header { color: #3b82f6; } .doing .count { background: #1e3a5f; color: #93c5fd; }
.done .col-header { color: #22c55e; } .done .count { background: #14532d; color: #86efac; }
.tasks { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 8px; }
.task { background: #0f1117; border: 1px solid #334155; border-radius: 8px; padding: 12px; cursor: pointer; transition: border-color .15s; }
.task:hover { border-color: #60a5fa; }
.task-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.task-meta { display: flex; gap: 8px; align-items: center; font-size: 11px; color: #94a3b8; }
.badge { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
.badge-feature { background: #1e3a5f; color: #60a5fa; }
.badge-bug { background: #7f1d1d; color: #fca5a5; }
.badge-refactor{ background: #4a1d7f; color: #d8b4fe; }
.badge-test { background: #1a4f2e; color: #86efac; }
.badge-devops { background: #3f3713; color: #fde68a; }
.priority-critical { color: #ef4444; }
.priority-high { color: #f97316; }
.priority-medium { color: #fbbf24; }
.priority-low { color: #22c55e; }
.add-form { border-top: 1px solid #334155; padding: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.add-form input, .add-form select { background: #0f1117; border: 1px solid #334155; border-radius: 6px; padding: 8px 12px; color: #e4e4e7; font-size: 13px; outline: none; }
.add-form input:focus, .add-form select:focus { border-color: #60a5fa; }
.add-form input { flex: 1; min-width: 120px; }
.add-form button { background: #2563eb; border: none; border-radius: 6px; padding: 8px 16px; color: white; font-size: 13px; cursor: pointer; font-weight: 600; }
.add-form button:hover { background: #3b82f6; }
.logs-panel { width: 320px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.logs-header { padding: 10px 14px; font-size: 12px; font-weight: 600; background: #0f1117; border-bottom: 1px solid #334155; color: #94a3b8; display: flex; justify-content: space-between; }
.logs { flex: 1; overflow-y: auto; padding: 8px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 11px; line-height: 1.7; }
.log-line { display: flex; gap: 8px; }
.log-time { color: #475569; flex-shrink: 0; }
.log-level-INFO { color: #60a5fa; }
.log-level-WARN { color: #f0b429; }
.log-level-ERROR { color: #ef4444; }
.log-level-TASK { color: #a78bfa; }
.log-level-AGENT { color: #22c55e; }
::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
</style>
</head>
<body>
<header>
<h1>⚡ Pulse TaskBoard <span>dev-orchestrator</span></h1>
<div class="stats">
<div class="stat">Tasks: <b id="stat-total">0</b></div>
<div class="stat">Ativas: <b id="stat-active">0</b></div>
<div class="stat">Agentes: <b id="stat-agents">3</b></div>
<div class="stat" id="redis-status">Redis: <b style="color:#ef4444">offline</b></div>
</div>
</header>
<main>
<div class="col todo">
<div class="col-header">📋 A Fazer <span class="count" id="todo-count">0</span></div>
<div class="tasks" id="todo-tasks"></div>
<div class="add-form" id="todo-form">
<input id="todo-title" placeholder="Nova tarefa..." />
<select id="todo-priority"><option value="low">Baixa</option><option value="medium" selected>Média</option><option value="high">Alta</option><option value="critical">Crítica</option></select>
<select id="todo-type"><option value="feature">Feature</option><option value="bug">Bug</option><option value="refactor">Refactor</option><option value="test">Test</option><option value="devops">DevOps</option></select>
<button onclick="addTask()">+ Tarefa</button>
</div>
</div>
<div class="col doing">
<div class="col-header">🔨 Em Progresso <span class="count" id="doing-count">0</span></div>
<div class="tasks" id="doing-tasks"></div>
</div>
<div class="col done">
<div class="col-header">✅ Concluídas <span class="count" id="done-count">0</span></div>
<div class="tasks" id="done-tasks"></div>
</div>
<div class="logs-panel">
<div class="logs-header">📡 Logs em Tempo Real <span id="log-count">0</span></div>
<div class="logs" id="logs"></div>
</div>
</main>
<script type="module">
// ─── Redis (opcional: se estiver disponível) ──────────────────────────
const REDIS_URL = (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
? 'ws://localhost:6379' : null;
let ws = null, useRedis = false;
function connectRedis() {
if (!REDIS_URL) return;
try {
ws = new WebSocket(REDIS_URL.replace('ws://', 'ws://').replace('http://', 'ws://'));
} catch(e) { useRedis = false; }
}
// ─── Task Store (localStorage + polling增量) ──────────────────────────
const LS_KEY = 'pulse-tasks';
function load() { try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch { return []; } }
function save(tasks) { localStorage.setItem(LS_KEY, JSON.stringify(tasks)); emitChange(); }
function emitChange() {
const tasks = load();
const counts = { todo: 0, doing: 0, done: 0 };
tasks.forEach(t => counts[t.status]++);
document.getElementById('todo-count').textContent = counts.todo;
document.getElementById('doing-count').textContent = counts.doing;
document.getElementById('done-count').textContent = counts.done;
document.getElementById('stat-total').textContent = tasks.length;
document.getElementById('stat-active').textContent = counts.todo + counts.doing;
renderColumns(tasks);
}
function genId() { return crypto.randomUUID(); }
window.addTask = function() {
const title = document.getElementById('todo-title').value.trim();
if (!title) return;
const tasks = load();
task = {
id: genId(),
title,
type: document.getElementById('todo-type').value,
priority: document.getElementById('todo-priority').value,
status: 'todo',
assignee: null,
domain: document.getElementById('todo-type').value === 'devops' ? 'devops' : 'fullstack',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
tasks.unshift(task);
save(tasks);
document.getElementById('todo-title').value = '';
addLog('TASK', `Tarefa criada: "${title}" [${task.type}]`);
};
function moveTask(id, newStatus) {
const tasks = load();
const t = tasks.find(x => x.id === id);
if (!t) return;
t.status = newStatus;
t.updated_at = new Date().toISOString();
save(tasks);
addLog('INFO', `${t.title}${newStatus}`);
// Publicar no Redis se conectado
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'task_update', task: t }));
}
}
function renderColumns(tasks) {
['todo', 'doing', 'done'].forEach(status => {
const col = document.getElementById(`${status}-tasks`);
const list = tasks.filter(t => t.status === status);
col.innerHTML = list.map(t => `
<div class="task" onclick="moveTask('${t.id}','${
t.status === 'todo' ? 'doing' : t.status === 'doing' ? 'done' : 'todo'
}')">
<div class="task-title">${t.title}</div>
<div class="task-meta">
<span class="badge badge-${t.type}">${t.type}</span>
<span class="priority-${t.priority}">${t.priority}</span>
<span>${new Date(t.created_at).toLocaleTimeString('pt-BR')}</span>
</div>
</div>
`).join('');
});
}
// ─── Log Stream ─────────────────────────────────────────────────────
let logCount = 0;
const LOG_COLORS = { INFO:'#60a5fa', WARN:'#f0b429', ERROR:'#ef4444', TASK:'#a78bfa', AGENT:'#22c55e' };
function addLog(level, msg) {
const logs = document.getElementById('logs');
const ts = new Date().toLocaleTimeString('pt-BR', { hour12: false });
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="log-time">${ts}</span><span class="log-level-${level}" style="color:${LOG_COLORS[level]||'#aaa'}">[${level}]</span> <span>${msg}</span>`;
logs.appendChild(line);
logs.scrollTop = logs.scrollHeight;
logCount++;
document.getElementById('log-count').textContent = logCount;
}
// ─── Seed inicial ───────────────────────────────────────────────────
function seed() {
if (load().length > 0) return;
const seeds = [
{ title:'Hot reload Vite + React', type:'feature', priority:'high', status:'todo', domain:'frontend' },
{ title:'Backend HMR com tsx watch', type:'feature', priority:'high', status:'doing', domain:'backend' },
{ title:'Log stream agregado (Loki)', type:'devops', priority:'medium', status:'todo', domain:'devops' },
{ title:'Worker Python para ETL async', type:'feature', priority:'medium', status:'todo', domain:'backend' },
{ title:'Test E2E pipeline completo', type:'test', priority:'medium', status:'done', domain:'fullstack' },
{ title:'TaskBoard UI (React + Redis)', type:'feature', priority:'high', status:'doing', domain:'frontend' },
{ title:'Fix Caddy labels Duplicadas', type:'bug', priority:'low', status:'done', domain:'devops' },
{ title:'Migrar monitoring Prometheus', type:'devops', priority:'low', status:'todo', domain:'devops' },
];
const tasks = seeds.map((s, i) => ({
id: genId(), title: s.title, type: s.type, priority: s.priority,
status: s.status, domain: s.domain, assignee: null,
created_at: new Date(Date.now() - i * 3600000).toISOString(),
updated_at: new Date().toISOString(),
}));
save(tasks);
}
seed();
emitChange();
addLog('AGENT', 'TaskBoard iniciado — 3 agentes ativos');
addLog('INFO', 'Redis: tentando conexão...');
try { connectRedis(); } catch(e) { useRedis = false; }
if (ws) {
ws.onopen = () => { useRedis = true; addLog('AGENT','Redis conectado'); document.querySelector('#redis-status b').style.color='#22c55e'; document.querySelector('#redis-status b').textContent='online'; };
ws.onclose = () => { useRedis = false; addLog('WARN','Redis desconectado'); document.querySelector('#redis-status b').style.color='#ef4444'; document.querySelector('#redis-status b').textContent='offline'; };
ws.onerror = () => { useRedis = false; document.querySelector('#redis-status b').style.color='#ef4444'; };
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'log') addLog(msg.level || 'INFO', msg.msg);
} catch {}
};
}
</script>
</body>
</html>