Capivara cresce — dashboard, analytics e controle
🐷 Capivara·

Capivara cresce — dashboard, analytics e controle

📖 21 min de leitura← Voltar para timeline

O problema original: bagunça de contas

O Capivara nasceu em maio como um hub pessoal seguro — autenticação JWT, convites temporários, um painel bonito. Resolvia o problema de acesso, mas não resolvia o problema de visão.

Eu tinha:

  • 6 serviços rodando (Arachne, Dogwalk, Capivara, Portifolio, tunnel Cloudflare, Umami)
  • 3 dashboards diferentes pra consultar status
  • 2 planilhas Google Sheets com dados financeiros do Dogwalk
  • 1 arquivo .env.local perdido com secrets de produção
  • 0 visão unificada de saúde do ecossistema

Toda vez que algo quebrava — um tunnel que caía, um deploy que falhava silenciosamente — eu descobria por acaso, geralmente quando um usuário me avisava. Não tinha alerta, não tinha dashboard, não tinha histórico.

O Capivara precisava crescer.

Primeiras planilhas

Antes do dashboard, eu tava no nível planilha cagada:

📊 Dogwalk Revenue — Junho
┌──────────┬────────┬──────────┐
│ Semana   │ R$     │ Passeios │
├──────────┼────────┼──────────┤
│ Semana 1 │ 1.250  │ 14       │
│ Semana 2 │ 980    │ 11       │  ← Google Sheets
│ Semana 3 │ 1.470  │ 17       │     raw dog
│ Semana 4 │ 2.100  │ 23       │
└──────────┴────────┴──────────┘

Funcionava, mas exigia:

  1. Exportar manualmente do Supabase
  2. Copiar pra planilha
  3. Formatar
  4. Compartilhar
  5. Repetir na semana seguinte

E se eu quisesse saber quanto cada walker faturou? Mais uma planilha. Quantos cancelamentos por mês? Outra planilha. O número de planilhas crescia na mesma proporção que as perguntas.

A gota d’água foi quando tive que cruzar dados de 3 planilhas diferentes pra responder “qual walker teve melhor retenção de clientes?”. Passei a tarde inteira. Isso é trabalho que um robô deveria fazer.

Dashboard de health checks

A primeira feature real depois do login foi o health check consolidado. Em vez de pingar cada serviço manualmente, criei um endpoint único no backend FastAPI:

"""Health monitor — consolidated status of all Capivara ecosystem services."""

from __future__ import annotations

import httpx
from fastapi import APIRouter

router = APIRouter(prefix="/api/health", tags=["health"])

PORTIFOLIO_STAGING = "https://safm3.vercel.app"
PORTIFOLIO_PROD = "https://samuelmedeiros.vercel.app"
TUNNEL_URL = "https://capivara.seu.pet"

@router.get("/all")
def health_all():
    """Check ALL ecosystem services and return consolidated status."""
    return _check_all()

def _check_all() -> dict:
    results: dict[str, bool | dict] = {}

    # 1. Self — Capivara sempre tá online se respondeu
    results["capivara_backend"] = True

    # 2. Portifolio Staging
    results["portifolio_staging"] = _check_url(PORTIFOLIO_STAGING)

    # 3. Portifolio Production
    results["portifolio_production"] = _check_url(PORTIFOLIO_PROD)

    # 4. Tunnel
    results["tunnel"] = _check_url(TUNNEL_URL)

    # Overall status
    required = ["capivara_backend", "tunnel"]
    all_ok = all(
        isinstance(results[r], bool) and results[r]
        or isinstance(results[r], dict) and results[r].get("online", False)
        for r in required
    )

    return {
        "status": "healthy" if all_ok else "degraded",
        "services": results,
    }

def _check_url(url: str, timeout: int = 5) -> dict:
    try:
        with httpx.Client(timeout=timeout, follow_redirects=True) as c:
            r = c.get(url)
            if r.status_code == 404:
                alt = url.rstrip("/") + "/" if not url.endswith("/") else url.rstrip("/")
                if alt != url:
                    r = c.get(alt)
            return {"online": r.status_code == 200, "status": r.status_code}
    except httpx.RequestError as e:
        return {"online": False, "error": str(e)}

O endpoint virou um cron job systemd que roda a cada 5 minutos. Se algo crítico cai, o capivara-health-check.py dispara alerta no Telegram:

#!/usr/bin/env python3
"""capivara-health-check.py — Cron monitor do ecossistema Capivara."""

import json, sys, urllib.request

CAPIVARA_URL = "http://localhost:8001/api/health/all"
CRITICAL = ["capivara_backend", "tunnel"]

try:
    req = urllib.request.Request(CAPIVARA_URL, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=10) as resp:
        data = json.loads(resp.read().decode())
except Exception as e:
    print(f"⚠️  Capivara HEALTH CHECK FAILED: {e}")
    sys.exit(1)

services = data.get("services", {})
offline = [
    name for name in CRITICAL
    if not (services.get(name) is True
            or services.get(name, {}).get("online"))
]

if offline:
    print(f"🔴 Degradado — críticos offline: {', '.join(offline)}")
    sys.exit(1)

sys.exit(0)  # Silent = tudo limpo

No frontend, a ServiceHealthBar mostra o status num piscar de olhos:

function ServiceHealthBar({ health }: { health: ServiceHealth }) {
  const items = [
    { key: 'capivara_backend', label: 'Backend', ok: health.capivara_backend },
    { key: 'tunnel', label: 'Tunnel', ok: health.tunnel },
    { key: 'portifolio_staging', label: 'Portifolio Staging',
      ok: health.portifolio_staging === true,
      unknown: health.portifolio_staging === null },
  ]

  return (
    <div className="glass p-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs"
         role="region" aria-label="Status dos serviços">
      <span className="text-[10px] text-[rgba(255,255,255,0.3)] uppercase
                       tracking-wide font-medium shrink-0">Serviços</span>
      {items.map(item => (
        <div key={item.key} className="flex items-center gap-1.5">
          <span className={`w-1.5 h-1.5 rounded-full ${
            item.unknown ? 'bg-[rgba(255,255,255,0.2)]'
            : item.ok ? 'bg-green-400' : 'bg-red-500'
          }`} />
          <span className={item.unknown ? 'text-[rgba(255,255,255,0.25)]'
                                   : 'text-[rgba(255,255,255,0.5)]'}>
            {item.label}
          </span>
        </div>
      ))}
    </div>
  )
}

O design é intencional: bolinha verde = tudo bem, bolinha vermelha = fodeu, bolinha cinza = não monitorado. Não tem amarelo. Amarelo é indecisão — ou tá online ou não tá.

Integração Umami

O Umami analytics já existia no ecossistema — self-hosted na porta 3100, rastreando visitas do Portifolio e Dogwalk. O problema é que cada acesso exigia login separado. O Capivara tinha credenciais de admin, mas o fluxo era:

  1. Abrir https://capivara.seu.pet:3100
  2. Digitar email + senha
  3. Navegar até o dashboard certo
  4. Repetir no próximo acesso

Solução: proxy reverso com auto-login. Criei um proxy no FastAPI que encaminha requisições pro Umami:

"""Proxy routes to Umami analytics server (port 3100)."""

import httpx
from fastapi import APIRouter, Request
from fastapi.responses import Response

router = APIRouter(prefix="/api/umami", tags=["umami"])
UMAMI_API = "http://localhost:3100"

@router.get("/status")
async def umami_status():
    try:
        async with httpx.AsyncClient(timeout=3) as client:
            resp = await client.get(f"{UMAMI_API}/")
            return {"online": resp.status_code == 200}
    except httpx.ConnectError:
        return {"online": False, "error": "Conexão recusada"}

async def _proxy(path: str, request: Request) -> Response:
    url = f"{UMAMI_API}{path}"
    body = await request.body()
    headers = dict(request.headers)
    headers.pop("host", None)
    headers.pop("content-length", None)

    async with httpx.AsyncClient(timeout=30) as client:
        resp = await client.request(
            method=request.method, url=url,
            headers=headers, content=body,
            follow_redirects=True,
        )
    return Response(content=resp.content, status_code=resp.status_code,
                    headers=dict(resp.headers))

@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy_umami(path: str, request: Request):
    return await _proxy(f"/{path}", request)

No frontend, o auto-login acontece assim:

function openUmami() {
  trackEvent('service_access', { service: 'umami' })
  window.open('/api/auth/umami-login', '_blank', 'noopener,noreferrer')
}

function UmamiMiniCard() {
  return (
    <div className="glass p-4">
      <div className="flex items-center justify-between mb-2">
        <span className="text-xs text-[rgba(255,255,255,0.4)]
                         uppercase tracking-wide font-medium">
          📊 Umami Analytics
        </span>
        <span className="text-[10px] px-2 py-0.5 rounded-full
                         bg-green-500/10 text-green-400">online</span>
      </div>
      <p className="text-xs text-[rgba(255,255,255,0.5)] mb-3">
        Portifolio Samuel e Dogwalk
      </p>
      <button onClick={openUmami}
        className="text-[11px] px-3 py-2 rounded-lg
                   bg-[rgba(0,212,255,0.05)] border
                   border-[rgba(0,212,255,0.1)] text-cyan
                   hover:text-white hover:border-[rgba(0,212,255,0.3)]
                   transition-all">
        📊 Abrir Umami
      </button>
    </div>
  )
}

Também adicionei server-side tracking — eventos como login, logout, sync e acesso ao dashboard são enviados pro Umami collector via fire-and-forget:

def _send(event: str, url: str = "/api", hostname: str = "capivara.seu.pet"):
    payload = json.dumps({
        "type": "event",
        "payload": {
            "hostname": hostname,
            "url": url,
            "website": CAPIVARA_WEBSITE_ID,
            "name": event,
        },
    }).encode()
    req = Request(COLLECTOR_URL, data=payload,
                  headers={"Content-Type": "application/json",
                           "User-Agent": "capivara/1.0"},
                  method="POST")
    try:
        urlopen(req, timeout=3)
    except URLError:
        pass  # fire-and-forget: falha silenciosa

Isso me deu visibilidade de quem acessa o quê e quando — sem depender de logs de servidor.

Analytics financeiros com categorias

O Dogwalk processa ~50 transações por mês entre passeios, saques e estornos. Cada transação tem um valor, um walker, um tutor, e um status. Mas o que realmente importa é a categorização:

Categoria Junho/26 Julho/26 Variação
Passeios R$ 4.720 R$ 5.810 +23%
Saques R$ 3.100 R$ 4.200 +35%
Taxas R$ 470 R$ 580 +23%
Estornos R$ 120 R$ 90 -25%
Líquido R$ 1.030 R$ 940 -9%

O backend expõe esses dados agregados via endpoint /dogwalk/revenue:

@router.get("/revenue")
async def revenue_stats(current_user=Depends(get_current_user),
                        db=Depends(get_db)):
    """Revenue aggregated by month with category breakdown."""
    twelve_months_ago = datetime.now(timezone.utc) - timedelta(days=365)

    bookings = db.query(Booking).filter(
        Booking.status == "finished",
        Booking.scheduled_date >= twelve_months_ago,
    ).order_by(Booking.scheduled_date).all()

    monthly = defaultdict(lambda: {"revenue": 0, "walks": 0, "categories": {}})
    for b in bookings:
        month_key = b.scheduled_date.strftime("%Y-%m")
        monthly[month_key]["revenue"] += float(b.price or 0)
        monthly[month_key]["walks"] += 1
        cat = categorize_booking(b)
        monthly[month_key]["categories"][cat] = \
            monthly[month_key]["categories"].get(cat, 0) + float(b.price or 0)

    return {
        "total_revenue": sum(m["revenue"] for m in monthly.values()),
        "total_walks": sum(m["walks"] for m in monthly.values()),
        "monthly": [
            {"month": k, **v}
            for k, v in sorted(monthly.items())
        ],
    }

No frontend, a visualização é um gráfico de barras horizontal com gradiente:

{monthlyData.map((m: any) => {
  const maxRev = Math.max(...monthlyData.map((x: any) => x.revenue))
  const pct = maxRev > 0 ? (m.revenue / maxRev) * 100 : 0
  const monthLabel = new Date(m.month + '-02')
    .toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' })
  return (
    <div key={m.month} className="flex items-center gap-2 text-xs">
      <span className="w-14 text-[rgba(255,255,255,0.3)] shrink-0">
        {monthLabel}
      </span>
      <div className="flex-1 h-5 rounded
                      bg-[rgba(255,255,255,0.03)] overflow-hidden relative">
        <div className="h-full rounded bg-gradient-to-r
                        from-[#00d4ff]/40 to-[#00d4ff]
                        transition-all duration-500"
             style={{ width: `${Math.max(pct, 3)}%` }} />
      </div>
      <span className="w-20 text-right text-[rgba(255,255,255,0.5)] shrink-0">
        R$ {m.revenue.toFixed(0)}
      </span>
      <span className="w-6 text-right text-[rgba(255,255,255,0.2)]
                       text-[10px] shrink-0">
        {m.walks}
      </span>
    </div>
  )
})}

A cereja do bolo: um Revenue Change Indicator que calcula automaticamente a variação percentual mês-a-mês:

const revenueChange = prevMonth && currentMonth
  ? ((currentMonth.revenue - prevMonth.revenue)
     / prevMonth.revenue * 100).toFixed(0)
  : null

// ...

{revenueChange && (
  <div className="mt-2 text-[10px] text-[rgba(255,255,255,0.3)]">
    {Number(revenueChange) >= 0 ? '↗' : '↘'}
    {' '}{Math.abs(Number(revenueChange))}% vs mês anterior
  </div>
)}

Aprendizados com visualização de dados

Depois de 30+ commits de evolução do dashboard, alguns aprendizados se cristalizaram:

1. Skeleton loading > spinner

Todo card no dashboard tem um estado de loading explícito com skeleton. O usuário vê a estrutura da página imediatamente, mesmo que os dados demorem 200ms:

function Skeleton({ className = '' }: { className?: string }) {
  return <div className={`skeleton ${className}`} />
}

// Uso:
{loading && !error && (
  <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
    <Skeleton className="h-[100px]" />
    <Skeleton className="h-[100px]" />
    ...
  </div>
)}

2. Refresh indicador de idade dos dados

Um RefreshIndicator mostra há quanto tempo os dados foram atualizados, não só a última atualização. Isso é crucial pra saber se o dado é confiável:

function RefreshIndicator({ lastUpdated, onRefresh }) {
  const [ago, setAgo] = useState('')
  useEffect(() => {
    const tick = () => {
      const sec = Math.floor((Date.now() - lastUpdated) / 1000)
      if (sec < 5) setAgo('agora')
      else if (sec < 60) setAgo(`${sec}s atrás`)
      else setAgo(`${Math.floor(sec / 60)}min atrás`)
    }
    tick()
    const interval = setInterval(tick, 5000)
    return () => clearInterval(interval)
  }, [lastUpdated])

  return (
    <span className="text-[10px] text-[rgba(255,255,255,0.25)]">
      atualizado {ago}
    </span>
  )
}

3. Collapsible sections com transição de altura nativa

Em vez de bibliotecas de accordion, a transição é feita com scrollHeight + CSS transition:

const [height, setHeight] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)

useEffect(() => {
  if (contentRef.current) {
    setHeight(open ? contentRef.current.scrollHeight : 0)
  }
}, [open, children])

return (
  <div className="overflow-hidden transition-[height] duration-300
                  ease-[cubic-bezier(0.16,1,0.3,1)]"
       style={{ height: height > 0 ? height : undefined }}>
    <div ref={contentRef}>
      {open && children}
    </div>
  </div>
)

4. Não monitorar tudo é melhor que monitorar errado

No começo eu queria monitorar tudo — latência de cada endpoint, tempo de resposta do Umami, status do R2. O resultado foi um dashboard lotado que ninguém olhava. Reduzi pra 4 serviços essenciais e o uso aumentou 10x.

5. Dados financeiros precisam de contexto

Ver “R$ 5.810” isolado não diz nada. Ver “R$ 5.810 — 23% maior que mês passado” conta uma história. O revenueChange foi a feature mais elogiada por quem testou o dashboard.

As métricas da evolução

Métrica Capivara 1.0 (Maio) Capivara 2.0 (Julho)
Seções no dashboard 2 (login, status) 6 (dashboard, Umami, Portifolio, Dogwalk, Convites, Telemetria)
Serviços monitorados 1 (self) 6 (backend, tunnel, staging, prod, Umami, Dogwalk)
Endpoints da API 5 25+
Componentes React ~200 LOC ~1.100 LOC (Dashboard) + ~1.100 LOC (Admin)
Autenticação JWT simples JWT + bcrypt + 2FA + convites expiráveis
Umami tracking ❌ via pageview ✅ 7 eventos server-side
Backup ❌ nenhum ✅ D1 Cloudflare (sync 6h)
Alertas ✅ Telegram + health check cron

Telemetria unificada

Além do Umami, criei um sistema de telemetria própria — todos os projetos enviam eventos pro Capivara, que armazena em SQLite e expõe agregados:

@router.post("/ingest")
async def ingest(event: TelemetryPayload, request: Request, db=Depends(get_db)):
    record = TelemetryEvent(
        event_type=event.event_type,
        source=event.source,
        payload=json.dumps(event.payload, ensure_ascii=False),
        ip=event.ip or (request.client.host if request.client else None),
        user_agent=event.user_agent or request.headers.get("user-agent"),
        referrer=event.referrer or request.headers.get("referer"),
    )
    db.add(record)
    db.commit()
    return {"ok": True, "id": record.id}

Isso me permite rastrear eventos como:

  • cv_download — downloads de currículo no Portifolio
  • contact_submit — mensagens do formulário de contato
  • dashboard_access — quando alguém entra no Capivara
  • service_access — quando um serviço externo é acessado via proxy

O dashboard de telemetria mostra tudo agregado:

<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
  <div className="bg-[rgba(255,255,255,0.02)] rounded-lg p-3 text-center">
    <div className="text-lg font-semibold text-[#00d4ff]">
      {stats.total_events}
    </div>
    <div className="text-[9px] text-[rgba(255,255,255,0.3)]
                    uppercase tracking-wide">
      Eventos (30d)
    </div>
  </div>
  <div className="bg-[rgba(255,255,255,0.02)] rounded-lg p-3 text-center">
    <div className="text-lg font-semibold text-green-400">
      {stats.cv_downloads}
    </div>
    <div className="text-[9px] text-[rgba(255,255,255,0.3)]
                    uppercase tracking-wide">
      Downloads CV
    </div>
  </div>
  {/* ... mais métricas ... */}
</div>

O que vem a seguir

O Capivara 2.0 tá funcional, mas não acabou. Os próximos passos na fila:

  1. Página de status pública (status.capivara.seu.pet) — qualquer pessoa pode ver se os serviços estão online
  2. Auto-backup SQLite → R2 — disaster recovery sem depender de máquina local
  3. Proxy da TatuEngine — monitorar o motor SSM também
  4. Gráficos históricos de receita — o gráfico de barras atual mostra só 12 meses, quero 5 anos
  5. Notificações no dashboard — em vez de só Telegram, um feed visual de eventos importantes
  6. Mobile-first de verdade — o dashboard funciona no celular, mas a experiência financeira ainda é desktop

TL;DR: O Capivara saiu de um hub de login pra um centro de controle com health checks em tempo real, analytics financeiros categorizados, integração Umami com auto-login e telemetria unificada. Aprendi que dashboard não é sobre mostrar tudo — é sobre mostrar a coisa certa na hora certa.


Comandos úteis

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$