// Sidebar + Topbar const cronToHuman = (expr) => { if (!expr || typeof expr !== 'string') return ''; const p = expr.trim().split(/\s+/); if (p.length !== 5) return expr; const [min, hour, , , dow] = p; const t = (h, m) => m === '0' || m === '00' ? `${h}h` : `${h}:${m.padStart(2,'0')}`; if (min.startsWith('*/')) return `a cada ${min.slice(2)} min`; if (hour.startsWith('*/') && min === '0') return `a cada ${hour.slice(2)}h`; const days = ['domingo','segunda','terça','quarta','quinta','sexta','sábado']; if (dow !== '*' && /^\d$/.test(dow)) return `toda ${days[+dow]} às ${t(hour,min)}`; if (dow === '1-5') return `dias úteis às ${t(hour,min)}`; return `todo dia às ${t(hour,min)}`; }; const initials = (name) => name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase(); const modelFamily = (model) => { const m = (model || '').toLowerCase(); if (!m || m === 'none') return 'none'; if (m.includes('haiku')) return 'haiku'; if (m.includes('sonnet')) return 'sonnet'; if (m.includes('opus')) return 'opus'; return 'other'; }; const modelLabel = (model) => { const f = modelFamily(model); if (f === 'none') return 'none'; if (f === 'other') return model || 'other'; return f; }; const ModelBadge = ({ model, compact = false }) => { const family = modelFamily(model); if (family === 'none') return null; return ( {modelLabel(model)} ); }; // Helper: relative time from ISO or already-relative string const relativeTime = (val) => { if (!val) return null; // Already a relative string (e.g., "há 2 min") if (typeof val === 'string' && !/^\d{4}-/.test(val)) return val; try { const diff = Date.now() - new Date(val).getTime(); if (diff < 0) return 'agora'; if (diff < 60000) return 'agora'; if (diff < 3600000) return 'há ' + Math.floor(diff / 60000) + 'm'; if (diff < 86400000) return 'há ' + Math.floor(diff / 3600000) + 'h'; return 'há ' + Math.floor(diff / 86400000) + 'd'; } catch { return val; } }; const computeHealthDots = (agent) => { // Heurística de fallback enquanto backend não expõe agent.recent_runs: // - dot 1: derivado de last_exit_code (0 -> ok, !=0 -> fail, null -> unknown) // - dots 2 e 3: unknown (placeholder cinza) // Se backend já expõe agent.recent_runs (array de 0-3 exit codes), usar. if (Array.isArray(agent.recent_runs) && agent.recent_runs.length > 0) { const slice = agent.recent_runs.slice(0, 3); while (slice.length < 3) slice.push(null); return slice.map(ec => ec == null ? "unknown" : (ec === 0 ? "ok" : "fail")); } const first = agent.last_exit_code == null ? "unknown" : agent.last_exit_code === 0 ? "ok" : "fail"; const second = (agent.last_runs_failed > 0 && agent.last_exit_code === 0) ? "fail" : "unknown"; return [first, second, "unknown"]; }; const AgentRow = ({ agent, agentCrons, active, onClick, density, draggable }) => { const isMac = agent.origin === 'mac' || agent.origin === 'mac-launchd' || agent.origin === 'mac-hook'; const originLabel = isMac ? 'mac' : (agent.origin === 'vps' ? 'vps' : (agent.origin || null)); const originTitle = isMac ? 'Mac pode estar dormindo' : (agent.origin === 'vps' ? 'VPS — sempre online' : (agent.origin || '')); // Cadence: pick the most-frequent enabled cron for this agent const enabledCrons = (agentCrons || []).filter(c => c.enabled); const cadenceText = enabledCrons.length > 0 ? (enabledCrons[0].natural || enabledCrons[0].expr) : 'on-demand'; const hasCron = enabledCrons.length > 0 || (agent.cadence && agent.cadence.length > 0); const lastRunText = relativeTime(agent.lastRun); const healthDots = computeHealthDots(agent); // Derived "scheduled" status — for cron-only agents that don't heartbeat // (launchd jobs, etc.). API returns status=offline between runs, which is // misleading: the job is scheduled and ran recently. Promote to a green // "Agendado" pill when there's a cadence AND last_run_at within the last // 26 hours (covers daily/sub-daily cadences with a small grace window). let pillStatus = agent.status; let pillLabel = agent.statusLabel; if (agent.status === 'offline' && hasCron && agent.last_run_at) { const lastRunMs = Date.parse(agent.last_run_at); if (!isNaN(lastRunMs) && (Date.now() - lastRunMs) < 26 * 60 * 60 * 1000) { pillStatus = 'scheduled'; pillLabel = 'Agendado'; } } return (
{agent.photo ? ( {agent.name} { const node = e.currentTarget; const fallback = document.createElement('div'); fallback.className = 'avatar'; fallback.style.background = agent.avatarColor || ''; fallback.textContent = initials(agent.name); node.replaceWith(fallback); }} /> ) : (
{initials(agent.name)}
)}
{agent.name}
{density !== "compact" && (
{agent.role}
)} {density !== "compact" && (
{' '}{cadenceText} {lastRunText && ( {lastRunText} )} {healthDots.map((d, i) => ( ))}
)}
{originLabel && ( {originLabel} )} {pillLabel} {agent.pendingApprovals > 0 && (
{agent.pendingApprovals}
)}
); }; const STATUS_FILTERS = [ { key: "all", label: "Todos" }, { key: "idle", label: "Disponível" }, { key: "running", label: "Executando" }, { key: "offline", label: "Offline" }, { key: "error", label: "Erro" }, ]; const MODEL_FILTERS = [ { key: "all", label: "Modelos" }, { key: "haiku", label: "Haiku" }, { key: "sonnet", label: "Sonnet" }, { key: "opus", label: "Opus" }, { key: "none", label: "None" }, ]; const Sidebar = ({ agents, crons, activeId, onSelect, density, onReorder }) => { const [q, setQ] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); const [modelFilter, setModelFilter] = React.useState("all"); const inputRef = React.useRef(null); const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); const shortcutHint = isMac ? "⌘K" : "Ctrl+K"; React.useEffect(() => { const handler = (e) => { const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; if (cmdOrCtrl && (e.key === "k" || e.key === "K")) { e.preventDefault(); if (inputRef.current) inputRef.current.focus(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [isMac]); // Build crons-by-agent map const cronsByAgent = React.useMemo(() => { const map = {}; if (!Array.isArray(crons)) return map; crons.forEach(c => { const aid = c.agentId || c.agent_id; if (!aid) return; if (!map[aid]) map[aid] = []; map[aid].push(c); }); return map; }, [crons]); const filtersActive = q !== "" || statusFilter !== "all" || modelFilter !== "all"; const filtered = React.useMemo(() => { const needle = q.trim().toLowerCase(); let list = agents; if (needle) { list = list.filter(a => { const haystack = [ a.name || "", a.role || "", a.model || "", a.description || "", ...(Array.isArray(a.traits) ? a.traits : []), ...(Array.isArray(a.integrations) ? a.integrations : []), ...(Array.isArray(a.capabilities) ? a.capabilities : []), ].join(" ").toLowerCase(); return haystack.includes(needle); }); } if (statusFilter !== "all") list = list.filter(a => a.status === statusFilter); if (modelFilter !== "all") list = list.filter(a => modelFamily(a.model) === modelFilter); // Order respects server-side display_order (set by drag-and-drop reorder). // Errors/pending no longer "float to top" — explicit user ordering wins; // use the status filter chip to surface errors when needed. return list; }, [agents, crons, q, statusFilter, modelFilter]); // Drag-and-drop reorder via SortableJS. Disabled while any filter is active // (reordering a filtered subset would silently shuffle the global order). const listRef = React.useRef(null); React.useEffect(() => { if (!listRef.current || typeof window === 'undefined' || !window.Sortable) return; if (!onReorder) return; const sortable = window.Sortable.create(listRef.current, { animation: 150, disabled: filtersActive, handle: '.agent-row', // whole card is the handle ghostClass: 'agent-row-ghost', // styled in dashboard.css chosenClass: 'agent-row-chosen', dragClass: 'agent-row-drag', onEnd: () => { const ids = Array.from(listRef.current.children) .map(el => el.getAttribute && el.getAttribute('data-agent-id')) .filter(Boolean); if (ids.length > 0) onReorder(ids); }, }); return () => { try { sortable.destroy(); } catch {} }; }, [filtersActive, onReorder, filtered.length]); return ( ); }; const BudgetGauge = () => { const [data, setData] = React.useState(null); React.useEffect(() => { const fetchBudget = () => { const tok = localStorage.getItem('hub_token'); if (!tok) return; fetch('/api/v1/budget', { headers: { Authorization: 'Bearer ' + tok } }) .then(r => r.ok ? r.json() : null) .then(d => { if (d) setData(d); }) .catch(() => {}); }; fetchBudget(); const id = setInterval(fetchBudget, 60000); return () => clearInterval(id); }, []); if (!data || !data.cap_cents || data.cap_cents <= 0) return null; const cost = data.cost_30d_cents || 0; const cap = data.cap_cents; const pct = Math.min(100, Math.round((cost / cap) * 100)); const tone = pct >= 90 ? 'red' : pct >= 60 ? 'yellow' : 'green'; const fmt = (c) => '$' + (c / 100).toFixed(2); return (
{fmt(cost)} / {fmt(cap)} ({pct}%)
); }; window.BudgetGauge = BudgetGauge; const NotificationsPanel = ({ onClose }) => (
e.stopPropagation()}>
Notificações

Nenhuma notificação

Falhas, alertas de custo e aprovações pendentes aparecerão aqui.
); const Topbar = ({ pendingCount, view, onSetView }) => { const [showNotif, setShowNotif] = React.useState(false); const openSettings = () => { window.postMessage({ type: '__activate_edit_mode' }, '*'); }; const handleLogout = () => { localStorage.removeItem('hub_token'); window.location = '/login'; }; return (
setShowNotif(false)}>
v
vini-agents
Command Center · v0.3 hub online · 0 falhas {/* D-07: view toggle */} {onSetView && (
)}
{pendingCount > 0 ? ( {pendingCount} aprovações pendentes ) : ( 0 aprovações pendentes )}
{showNotif && setShowNotif(false)} />}
); }; window.Sidebar = Sidebar; window.Topbar = Topbar; window.cronToHuman = cronToHuman; window.initials = initials; window.modelFamily = modelFamily; window.modelLabel = modelLabel; window.ModelBadge = ModelBadge;