Compare commits

..

2 Commits

Author SHA1 Message Date
Pulse Agent 13421c4bf0 docs(TOOLS.md): Obsidian Vault Pulse + skill obsidian-vault-linker + estrutura documentada 2026-05-20 19:41:03 -03:00
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
13 changed files with 757 additions and 0 deletions
+27
View File
@@ -333,3 +333,30 @@ services:
3. Labels Traefik podem estar no `deploy.labels` do compose 3. Labels Traefik podem estar no `deploy.labels` do compose
4. Rede `public` é a única overlay attachable — não criar rede por stack 4. Rede `public` é a única overlay attachable — não criar rede por stack
5. Nunca usar `restart_policy` no nível do compose v3.9 deploy — usar `restart_policy` direto no serviço 5. Nunca usar `restart_policy` no nível do compose v3.9 deploy — usar `restart_policy` direto no serviço
## 📝 Obsidian — Vault Pulse
- **Caminho**: `/root/Obsidian-Pulse/`
- **Skill**: `obsidian-vault-linker` instalada (SKILL.md + 24 relationship types)
- **CLI**: `obs` não instalado (não necessário — acessar diretamente os arquivos `.md`)
- **Estrutura**:
```
Inbox/ — notas temporárias (triar diariamente)
Projetos/ — [nome-do-projeto].md por projeto
Docker/ — stacks, compose, troubleshoot
Dev/ — dev environment, workflows, taskboard
Codex/ — código-fonte comentado
Logs/ — logs agrupados por data YYYY-MM-DD
Memorias/ — MEMORY.md + memórias diárias indexadas
Templates/ — templates para novas notas
```
- **Nota central**: `Home.md` — dashboard do vault com links para todas as notas principais
- **Config**: `.obsidian/app.json` (location de arquivos, live preview, properties)
- **Uso**: agentes escrevem blocos de aprendizado e docs diretamente nos `.md` — Obsidian renderiza grafo de conhecimento
### Quick Commands
```bash
find /root/Obsidian-Pulse -name '*.md' | wc -l # count de notas
grep -r '#' /root/Obsidian-Pulse/ --include='*.md' | grep '^#' # buscar headers
```
+56
View File
@@ -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) })
+52
View File
@@ -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) })
+55
View File
@@ -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) })
+15
View File
@@ -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"
}
}
+22
View File
@@ -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"
}
}
+98
View File
@@ -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}`)
})()
})
+129
View File
@@ -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>
+25
View File
@@ -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"
}
}
+239
View File
@@ -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>
+8
View File
@@ -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";
}
+20
View File
@@ -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"]
}
+11
View File
@@ -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,
},
})