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
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import Redis from 'ioredis'
|
||||
const redis = new Redis({ host: 'redis', port: 6379, lazyConnect: true })
|
||||
const QUEUE = 'dev-tasks'
|
||||
const LOG = 'dev-logs'
|
||||
|
||||
function log(level, msg) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg })
|
||||
redis.publish(LOG, line).catch(() => {})
|
||||
console.log(`[${level}] ${msg}`)
|
||||
}
|
||||
|
||||
async function claimTask(task) {
|
||||
await redis.hset(`agent:agent-backend:task`, task.id, JSON.stringify(task))
|
||||
await redis.hset(`agent:agent-backend`, 'status', 'busy')
|
||||
await redis.hset(`agent:agent-backend`, 'current_task', task.title)
|
||||
await redis.publish(LOG, JSON.stringify({ ts: new Date().toISOString(), level: 'AGENT', msg: `Backend pegou: "${task.title}"` }))
|
||||
task.status = 'in_progress'
|
||||
task.assignee = 'agent-backend'
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
log('AGENT', `▶️ Processando: ${task.title}`)
|
||||
await new Promise(r => setTimeout(r, Math.random() * 6000 + 3000))
|
||||
task.status = 'done'
|
||||
task.done_at = new Date().toISOString()
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
await redis.hset(`agent:agent-backend`, 'status', 'idle')
|
||||
await redis.hdel(`agent:agent-backend:task`, task.id)
|
||||
await redis.hincrby('agent:agent-backend', 'tasks_done', 1)
|
||||
await awaitingTask(task)
|
||||
}
|
||||
|
||||
async function awaitingTask(task) {
|
||||
await redis.hset(`agent:agent-backend`, 'status', 'idle')
|
||||
await redis.hdel(`agent:agent-backend:task`, task.id)
|
||||
log('AGENT', `✔️ Concluído: ${task.title}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await redis.connect()
|
||||
await redis.set('agent:agent-backend', JSON.stringify({
|
||||
id: 'agent-backend', role: 'backend', status: 'online',
|
||||
started_at: new Date().toISOString(), tasks_done: 0,
|
||||
}))
|
||||
log('AGENT', 'Backend Agent online — aguardando tarefas')
|
||||
while (true) {
|
||||
const [_, taskId] = await redis.blpop(QUEUE, 60)
|
||||
if (!taskId) { await new Promise(r => setTimeout(r, 1000)); continue }
|
||||
const raw = await redis.get(`task:${taskId}`)
|
||||
if (!raw) continue
|
||||
const task = JSON.parse(raw)
|
||||
if (task.domain !== 'backend' && task.domain !== 'fullstack') {
|
||||
await redis.rpush(QUEUE, taskId); continue
|
||||
}
|
||||
await claimTask(task)
|
||||
}
|
||||
}
|
||||
run().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,52 @@
|
||||
import Redis from 'ioredis'
|
||||
import { execSync } from 'child_process'
|
||||
const redis = new Redis({ host: 'redis', port: 6379, lazyConnect: true })
|
||||
const QUEUE = 'dev-tasks'
|
||||
const LOG = 'dev-logs'
|
||||
|
||||
function log(level, msg) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg })
|
||||
redis.publish(LOG, line).catch(() => {})
|
||||
console.log(`[${level}] ${msg}`)
|
||||
}
|
||||
|
||||
function exec(cmd) {
|
||||
try { return execSync(cmd, { encoding: 'utf8', timeout: 30000 }).trim() }
|
||||
catch(e) { return `ERROR: ${e.message}` }
|
||||
}
|
||||
|
||||
async function processTask(task) {
|
||||
task.status = 'in_progress'
|
||||
task.assignee = 'agent-devops'
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
log('AGENT', `▶️ Processando: ${task.title}`)
|
||||
const result = exec(task.command || `echo "${task.title}"`)
|
||||
task.result = result
|
||||
task.status = 'done'
|
||||
task.done_at = new Date().toISOString()
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
log('AGENT', `✔️ Concluído: ${task.title}`)
|
||||
return result
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await redis.connect()
|
||||
await redis.set('agent:agent-devops', JSON.stringify({
|
||||
id: 'agent-devops', role: 'devops', status: 'online',
|
||||
started_at: new Date().toISOString(), tasks_done: 0,
|
||||
}))
|
||||
log('AGENT', 'DevOps Agent online — aguardando tarefas')
|
||||
while (true) {
|
||||
const [_, tid] = await redis.blpop(QUEUE, 60)
|
||||
if (!tid) { await new Promise(r => setTimeout(r, 1000)); continue }
|
||||
const raw = await redis.get(`task:${tid}`)
|
||||
if (!raw) continue
|
||||
const task = JSON.parse(raw)
|
||||
if (task.domain !== 'devops') {
|
||||
await redis.rpush(QUEUE, tid); continue
|
||||
}
|
||||
await processTask(task)
|
||||
await redis.hincrby('agent:agent-devops', 'tasks_done', 1)
|
||||
}
|
||||
}
|
||||
run().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,55 @@
|
||||
import Redis from 'ioredis'
|
||||
const redis = new Redis({ host: 'redis', port: 6379, lazyConnect: true })
|
||||
const QUEUE = 'dev-tasks'
|
||||
const LOG = 'dev-logs'
|
||||
|
||||
function log(level, msg) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg })
|
||||
redis.publish(LOG, line).catch(() => {})
|
||||
console.log(`[${level}] ${msg}`)
|
||||
}
|
||||
|
||||
async function claimTask(task) {
|
||||
await redis.hset(`agent:agent-frontend:task`, task.id, JSON.stringify(task))
|
||||
await redis.hset(`agent:agent-frontend`, 'status', 'busy')
|
||||
await redis.hset(`agent:agent-frontend`, 'current_task', task.title)
|
||||
await redis.publish(LOG, JSON.stringify({ ts: new Date().toISOString(), level: 'AGENT', msg: `Frontend pegou: "${task.title}"` }))
|
||||
task.status = 'in_progress'
|
||||
task.assignee = 'agent-frontend'
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
log('AGENT', `▶️ Processando: ${task.title}`)
|
||||
await new Promise(r => setTimeout(r, Math.random() * 5000 + 2000))
|
||||
task.status = 'done'
|
||||
task.done_at = new Date().toISOString()
|
||||
await redis.set(`task:${task.id}`, JSON.stringify(task))
|
||||
await redis.hset(`agent:agent-frontend`, 'status', 'idle')
|
||||
await redis.hdel(`agent:agent-frontend:task`, task.id)
|
||||
awaitingTask(task)
|
||||
}
|
||||
|
||||
async function awaitingTask(task) {
|
||||
await redis.hset(`agent:agent-frontend`, 'status', 'idle')
|
||||
await redis.hdel(`agent:agent-frontend:task`, task.id)
|
||||
log('AGENT', `✔️ Concluído: ${task.title}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await redis.connect()
|
||||
await redis.set('agent:agent-frontend', JSON.stringify({
|
||||
id: 'agent-frontend', role: 'frontend', status: 'online',
|
||||
started_at: new Date().toISOString(), tasks_done: 0,
|
||||
}))
|
||||
log('AGENT', 'Frontend Agent online — aguardando tarefas')
|
||||
while (true) {
|
||||
const [_, taskId] = await redis.blpop(QUEUE, 60)
|
||||
if (!taskId) { await new Promise(r => setTimeout(r, 1000)); continue }
|
||||
const raw = await redis.get(`task:${taskId}`)
|
||||
if (!raw) continue
|
||||
const task = JSON.parse(raw)
|
||||
if (task.domain !== 'frontend' && task.domain !== 'fullstack') {
|
||||
await redis.rpush(QUEUE, taskId); continue
|
||||
}
|
||||
await claimTask(task)
|
||||
}
|
||||
}
|
||||
run().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "pulse-agents",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"agent-frontend": "node /app/agents/frontend/agent.js",
|
||||
"agent-backend": "node /app/agents/backend/agent.js",
|
||||
"agent-devops": "node /app/agents/devops/agent.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^5.4.1",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "pulse-dev-backend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --dir ./src server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^8.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import Redis from 'ioredis'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(helmet())
|
||||
app.use(express.json())
|
||||
|
||||
// ─── Redis ──────────────────────────────────────────────────────────
|
||||
const redis = new Redis({ host: 'redis', port: 6379, lazyConnect: true })
|
||||
redis.on('error', (err) => console.error('[Redis] error:', err.message))
|
||||
redis.on('ready', () => console.log('[Redis] conectado'))
|
||||
|
||||
// ─── Task Queue ─────────────────────────────────────────────────────
|
||||
const TASK_QUEUE = 'dev-tasks'
|
||||
const LOG_CHANNEL = 'dev-logs'
|
||||
|
||||
function log(level, msg) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg })
|
||||
redis.publish(LOG_CHANNEL, line).catch(() => {})
|
||||
console.log(`[${level}] ${msg}`)
|
||||
}
|
||||
|
||||
// GET /tasks — lista todas
|
||||
// POST /tasks — cria tarefa
|
||||
// PUT /tasks/:id — atualiza status
|
||||
// GET /agents — lista agentes conectados
|
||||
// GET /logs/:limit — logs recentes
|
||||
|
||||
app.get('/tasks', async (_req, res) => {
|
||||
const keys = await redis.keys('task:*')
|
||||
const tasks = []
|
||||
for (const k of keys) {
|
||||
const t = await redis.get(k)
|
||||
if (t) tasks.push(JSON.parse(t))
|
||||
}
|
||||
tasks.sort((a, b) => b.updated_at?.localeCompare(a.updated_at) || 0)
|
||||
res.json(tasks)
|
||||
})
|
||||
|
||||
app.post('/tasks', async (req, res) => {
|
||||
const id = uuidv4()
|
||||
const task = {
|
||||
id, title: req.body.title, type: req.body.type || 'feature',
|
||||
priority: req.body.priority || 'medium', status: 'todo',
|
||||
domain: req.body.domain || 'fullstack', assignee: null,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
}
|
||||
await redis.set(`task:${id}`, JSON.stringify(task))
|
||||
await redis.rpush('task-queue', id)
|
||||
log('TASK', `Nova tarefa: "${task.title}" [${task.type}]`)
|
||||
res.status(201).json(task)
|
||||
})
|
||||
|
||||
app.put('/tasks/:id', async (req, res) => {
|
||||
const key = `task:${req.params.id}`
|
||||
const raw = await redis.get(key)
|
||||
if (!raw) return res.status(404).json({ error: 'Não encontrada' })
|
||||
const task = JSON.parse(raw)
|
||||
Object.assign(task, req.body, { updated_at: new Date().toISOString() })
|
||||
await redis.set(key, JSON.stringify(task))
|
||||
log('INFO', `Tarefa atualizada: "${task.title}" status=${task.status}`)
|
||||
res.json(task)
|
||||
})
|
||||
|
||||
app.get('/agents', async (_req, res) => {
|
||||
const keys = await redis.keys('agent:*')
|
||||
const agents = []
|
||||
for (const k of keys) {
|
||||
const a = await redis.get(k)
|
||||
if (a) agents.push(JSON.parse(a))
|
||||
}
|
||||
res.json(agents)
|
||||
})
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', ts: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// Promover tarefa automaticamente ao criar/modificar
|
||||
app.use('*', (req, res) => { res.status(404).json({ error: 'Not found' }) })
|
||||
|
||||
const PORT = 3001
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
// Registrar agente backend
|
||||
;(async () => {
|
||||
try { await redis.connect() } catch (e) { console.error('Redis connect:', e.message) }
|
||||
await redis.set('agent:dev-backend', JSON.stringify({
|
||||
id: 'dev-backend', role: 'backend', status: 'online',
|
||||
started_at: new Date().toISOString(),
|
||||
}))
|
||||
log('AGENT', `Backend API online :${PORT}`)
|
||||
log('AGENT', `Redis conectado — queue: ${TASK_QUEUE}`)
|
||||
})()
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pulse Dev — Frontend</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e4e4e7; }
|
||||
nav { background: #1e293b; padding: 12px 24px; display: flex; gap: 20px; border-bottom: 1px solid #334155; }
|
||||
nav a { color: #94a3b8; text-decoration: none; font-size: 14px; }
|
||||
nav a.active { color: #60a5fa; font-weight: 600; }
|
||||
section { padding: 32px 24px; max-width: 960px; margin: 0 auto; }
|
||||
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
h2 { font-size: 18px; margin: 24px 0 12px; color: #60a5fa; }
|
||||
p { color: #94a3b8; line-height: 1.7; font-size: 14px; }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 16px; }
|
||||
.card b { color: #60a5fa; font-size: 20px; }
|
||||
.card small { color: #64748b; font-size: 12px; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 11px; margin: 2px; }
|
||||
.ok { background: #14532d; color: #86efac; }
|
||||
.warn{ background: #78350f; color: #fde68a; }
|
||||
.err { background: #7f1d1d; color: #fca5a5; }
|
||||
pre { background: #0f1117; border: 1px solid #334155; border-radius: 6px; padding: 16px; font-size: 13px; overflow-x: auto; color: #a5f3fc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="#overview">Visão Geral</a>
|
||||
<a href="#agents">Agentes</a>
|
||||
<a href="#stacks">Stacks</a>
|
||||
<a href="#flows">Fluxos</a>
|
||||
</nav>
|
||||
<section id="overview">
|
||||
<h1>⚡ Pulse Frontend</h1>
|
||||
<p>Ambiente de desenvolvimento full-stack com hot reload, agents paralelos e taskboard em tempo real.</p>
|
||||
<div class="status-grid">
|
||||
<div class="card"><small>Redis</small><br><b id="redis-badge"><span class="warn badge">conectando…</span></b></div>
|
||||
<div class="card"><small>TaskBoard</small><br><b>board.octal.tec.br</b></div>
|
||||
<div class="card"><small>Backend API</small><br><b id="api-status">verificando…</b></div>
|
||||
<div class="card"><small>Hot Reload</small><br><b id="hmr-status">aguardando</b></div>
|
||||
<div class="card"><small>Backend Agent</small><br><b id="be-status">—</b></div>
|
||||
<div class="card"><small>Frontend Agent</small><br><b id="fe-status">—</b></div>
|
||||
<div class="card"><small>DevOps Agent</small><br><b id="ops-status">—</b></div>
|
||||
<div class="card"><small>Tasks na Queue</small><br><b id="queue-count">—</b></div>
|
||||
</div>
|
||||
<h2>Stack Swarm / Clusters</h2>
|
||||
<pre id="stack-info">Carregando…</pre>
|
||||
<h2>Últimos Logs</h2>
|
||||
<pre id="logs">Aguardando eventos…</pre>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const API = 'http://redis:6379'
|
||||
const BACKEND_URL = 'http://localhost:3001'
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const [agentsRes, stacksRes, logsRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/agents`),
|
||||
fetch(`${BACKEND_URL}/tasks?redis=1`),
|
||||
fetch(`${BACKEND_URL}/health`),
|
||||
])
|
||||
if (agentsRes.status === 'fulfilled') {
|
||||
const agents = await agentsRes.value.json()
|
||||
const map = {};
|
||||
agents.forEach(a => { map[a.id] = a; })
|
||||
const setStatus = (id, badgeId, labelId) => {
|
||||
const el = document.getElementById(badgeId)
|
||||
if (!el) return
|
||||
const a = map[id]
|
||||
if (!a) return
|
||||
const s = a.status
|
||||
document.getElementById(labelId).textContent = a.role || s
|
||||
el.innerHTML = `<span class="${s==='online'?'ok':s==='busy'?'warn':'err'} badge">${s}</span>`
|
||||
}
|
||||
setStatus('dev-backend', 'be-status', 'api-status')
|
||||
setStatus('agent-frontend','fe-status','hmr-status')
|
||||
setStatus('agent-devops', 'ops-status','ops-status')
|
||||
}
|
||||
if (stacksRes.status === 'fulfilled') {
|
||||
const tasks = await stacksRes.value.json()
|
||||
const counts = { todo: 0, doing: 0, done: 0 }
|
||||
tasks.forEach(t => { if (counts[t.status] !== undefined) counts[t.status]++ })
|
||||
document.getElementById('queue-count').textContent = counts.todo + counts.doing
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function getStackInfo() {
|
||||
const r = await fetch(`${BACKEND_URL}/health`)
|
||||
if (!r.ok) return 'Backend: timeout — verificar stack dev no Swarm'
|
||||
return`Stack Swarm — Ambiente Dev Full-Stack
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ORQUESTRADOR │ TaskBoard │ Redis │ Pipeline │
|
||||
│ dock.pulse-ops│ board.oc. │ 6379 │ CI/CD → Gitea │
|
||||
│ (auto) │ (nginx) │ (falback)│ GitHub Actions │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ FRONTEND │ BACKEND │ WORKERS │ Deploy │
|
||||
│ Vite 5173 │ tsx:3001 │ 2 FE/2 BE│ docker stack deploy -c │
|
||||
│ (hot reload) │ (hot reload│ 1 DevOps │ runbooks/dev-stack.yml │
|
||||
│ │ + HMR) │ │ dev │
|
||||
└─────────────────────────────────────────────────────────────────┘`
|
||||
}
|
||||
|
||||
async function getLogs() {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND_URL}/logs?limit=20`)
|
||||
const lines = await r.json()
|
||||
return lines.map(l => {
|
||||
const ts = ts(l.ts || Date.now())
|
||||
const col = { INFO:'#60a5fa', AGENT:'#22c55e', TASK:'#a78bfa', WARN:'#f0b429', ERROR:'#ef4444' }[l.level] || '#94a3b8'
|
||||
return `\x1b[${col}m[${ts}]\x1b[0m [\x1b[${col}m${l.level}\x1b[0m] ${l.msg || JSON.stringify(l)}`
|
||||
}).join('\n') || 'Sem logs ainda...'
|
||||
} catch(e) { return 'Logs indisponíveis' }
|
||||
}
|
||||
|
||||
function ts(ms) {
|
||||
const d = new Date(ms)
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('stack-info').textContent = await getStackInfo()
|
||||
await update()
|
||||
setInterval(update, 3000)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pulse-taskboard",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name board.octal.tec.br;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user