Inicio
{/* ── HERO BANNER ── */}
{greeting}
{userName || 'Tu espacio de trabajo'}
Tu plataforma de agentes IA está operativa
{planLabel && Plan {planLabel}}
{subscription?.status === 'active' && (
Activo
)}
{subscription?.is_paused && Pausado}
{subscription && !subscription.has_subscription && Sin plan}
{agents.length}
agentes disponibles
{(tokensUsed / 1000).toFixed(1)}k
tokens este mes
90 ? 'var(--bad)' : usagePct > 70 ? 'var(--warn)' : 'var(--ok)' }}>
{usagePct}%
cuota usada
{(tokensUsed / 1000).toFixed(1)}k / {(tokensLimit / 1000).toFixed(0)}k
90 ? 'var(--bad)' : usagePct > 70 ? 'var(--warn)' : 'var(--brand)', transition: 'width 0.5s' }} />
setRoute('runs')}>
{recentRuns === null ? '—' : recentRuns.length}
ejecuciones recientes
{/* ── MAIN GRID: timeline + panel lateral ── */}
{/* Actividad reciente — timeline */}
Actividad reciente
{recentRuns && recentRuns.length > 0 && (
)}
{recentRuns === null ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : recentRuns.length === 0 ? (
Sin ejecuciones todavía
Prueba un agente para ver la actividad aquí
) : (
{recentRuns.slice(0, 5).map((run, i) => {
const borderCol = { ok: 'var(--ok)', error: 'var(--bad)', warning: 'var(--warn)' }[run.status] || 'var(--line-3)';
const ts = run.created_at ? new Date(run.created_at).toLocaleString('es-ES', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
const dur = run.duration_ms ? `${(run.duration_ms / 1000).toFixed(1)} s` : null;
return (
{run.agent_name || '—'}
{ts}{dur ? ` · ${dur}` : ''} · {(run.tokens_used || 0).toLocaleString('es-ES')} tok
{run.cost_eur > 0 &&
{run.cost_eur.toFixed(2)} €}
);
})}
)}
{/* Panel lateral: acciones rápidas + feed en vivo */}
Acciones rápidas
{[
{ route: 'marketplace', icon: 'store', label: 'Explorar catálogo', sub: `${agents.length} agentes disponibles`, bg: 'var(--brand-soft)', color: 'var(--brand)' },
{ route: 'playground', icon: 'play', label: 'Área de pruebas', sub: 'Valida antes de desplegar', bg: 'color-mix(in oklab, var(--violet) 15%, var(--bg-2))', color: 'var(--violet)' },
{ route: 'builder', icon: 'flow', label: 'Editor de flujos', sub: 'Conecta nodos y herramientas', bg: 'color-mix(in oklab, var(--teal) 15%, var(--bg-2))', color: 'var(--teal)' },
{ route: 'deployment', icon: 'cloud', label: 'Implantación', sub: 'Activa y monitoriza agentes', bg: 'color-mix(in oklab, var(--info) 15%, var(--bg-2))', color: 'var(--info)' },
].map(({ route, icon, label, sub, bg, color }) => (
))}
{liveFeed.length > 0 && (
{liveFeed.slice(0, 5).map((entry, idx) => {
const colonIdx = entry.indexOf(': ');
const agentPart = colonIdx > -1 ? entry.slice(0, colonIdx) : entry;
const rest = colonIdx > -1 ? entry.slice(colonIdx + 2) : '';
return (
{agentPart}
{rest && {rest}}
);
})}
)}
{/* ── AGENTES SUGERIDOS ── */}
Sugeridos para tu equipo
{[...agents].sort((a, b) => (Number(b.runs || 0) - Number(a.runs || 0))).slice(0, 4).map((a) => {
const cat = cats.find(c => c.id === a.cat);
return
openAgent(a.id)} />;
})}
{/* ── CÓMO EMPEZAR ── */}
Cómo empezar
{quickStart.map((step) => (
{step.id}
{step.eta}
{step.title}
{step.desc}
))}
);
};
// ---------------------------------------------------------------------------
// RunDetail — panel lateral de detalle editable
// ---------------------------------------------------------------------------
const RunDetail = ({ run, onClose, onSaved }) => {
const [notes, setNotes] = React.useState(run.notes || '');
const [status, setStatus] = React.useState(run.status);
const [saving, setSaving] = React.useState(false);
const [saved, setSaved] = React.useState(false);
const token = () => sessionStorage.getItem('af_token') || '';
const fmtFull = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleString('es-ES', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/v1/runs/${run.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token() },
body: JSON.stringify({ notes: notes.trim() || null, status }),
});
if (res.ok) {
const updated = await res.json();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
onSaved && onSaved(updated);
}
} catch (_) {}
setSaving(false);
};
const statusColor = { ok: 'var(--ok)', error: 'var(--bad)', warning: 'var(--warn)', skipped: 'var(--ink-3)' };
return (
Detalle de ejecución
{run.id}
{/* Meta */}
{[
['Agente', run.agent_name],
['Disparador', run.trigger_type],
['Duración', run.duration_ms != null ? (run.duration_ms / 1000).toFixed(3) + ' s' : '—'],
['Tokens', (run.tokens_used || 0).toLocaleString('es-ES')],
['Coste', Number(run.cost_eur || 0).toFixed(4) + ' €'],
['Fecha', fmtFull(run.created_at)],
].map(([k, v]) => (
))}
{/* Estado editable */}
Estado
{['ok', 'error', 'warning', 'skipped'].map(s => (
))}
{/* Error message */}
{run.error_message && (
Mensaje de error
{run.error_message}
)}
{/* Notas editables */}
);
};
// ---------------------------------------------------------------------------
// RunsLogs — lista completa con filtros y panel de detalle
// ---------------------------------------------------------------------------
const RunsLogs = ({ setRoute }) => {
const [runs, setRuns] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [statusFilter, setStatusFilter] = React.useState('all');
const [triggerFilter, setTriggerFilter] = React.useState('all');
const [agentSearch, setAgentSearch] = React.useState('');
const [daysFilter, setDaysFilter] = React.useState('all');
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [selectedRun, setSelectedRun] = React.useState(null);
const [sortCol, setSortCol] = React.useState('created_at');
const [sortAsc, setSortAsc] = React.useState(false);
const getToken = () => sessionStorage.getItem('af_token') || '';
const loadRuns = React.useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ limit: '500' });
if (statusFilter !== 'all') params.set('status', statusFilter);
if (triggerFilter !== 'all') params.set('trigger', triggerFilter);
if (agentSearch.trim()) params.set('agent', agentSearch.trim());
if (daysFilter !== 'all') params.set('days', daysFilter);
const res = await fetch('/api/v1/runs?' + params, {
headers: { Authorization: 'Bearer ' + getToken() },
});
if (res.ok) setRuns(await res.json());
} catch (_) {}
setLoading(false);
}, [statusFilter, triggerFilter, agentSearch, daysFilter]);
React.useEffect(() => { loadRuns(); }, [loadRuns]);
React.useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(loadRuns, 15000);
return () => clearInterval(timer);
}, [autoRefresh, loadRuns]);
const fmtDur = (ms) => ms == null ? '—' : (ms / 1000).toFixed(2).replace('.', ',') + ' s';
const fmtCost = (eur) => eur == null ? '—' : Number(eur).toFixed(4).replace('.', ',') + ' €';
const fmtWhen = (iso) => {
if (!iso) return '—';
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 10) return 'ahora mismo';
if (diff < 60) return 'hace ' + diff + ' s';
if (diff < 3600) return 'hace ' + Math.round(diff / 60) + ' m';
if (diff < 86400) return 'hace ' + Math.round(diff / 3600) + ' h';
return new Date(iso).toLocaleString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
};
const toggleSort = (col) => {
if (sortCol === col) setSortAsc(a => !a);
else { setSortCol(col); setSortAsc(false); }
};
const sorted = React.useMemo(() => {
const arr = [...runs];
arr.sort((a, b) => {
let va = a[sortCol], vb = b[sortCol];
if (va == null) va = sortCol === 'duration_ms' ? -1 : '';
if (vb == null) vb = sortCol === 'duration_ms' ? -1 : '';
if (typeof va === 'string') return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
return sortAsc ? va - vb : vb - va;
});
return arr;
}, [runs, sortCol, sortAsc]);
const summary = React.useMemo(() => ({
total: runs.length,
errors: runs.filter(r => r.status === 'error').length,
avgMs: runs.length ? runs.reduce((s, r) => s + (r.duration_ms || 0), 0) / runs.filter(r => r.duration_ms != null).length || 0 : 0,
totalCost: runs.reduce((s, r) => s + Number(r.cost_eur || 0), 0),
}), [runs]);
const exportCsv = () => {
const header = ['id', 'agente', 'estado', 'disparador', 'duracion_ms', 'tokens', 'coste_eur', 'notas', 'cuando'];
const rows = sorted.map((r) => [
r.id, r.agent_name, r.status, r.trigger_type,
String(r.duration_ms ?? ''), String(r.tokens_used), String(r.cost_eur),
String(r.notes || ''), r.created_at,
]);
const csv = [header, ...rows]
.map((line) => line.map((v) => `"${String(v).replaceAll('"', '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'agentforge-ejecuciones.csv';
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
window.afNotify && window.afNotify('Exportación CSV completada');
};
const onSaved = (updated) => {
setRuns(prev => prev.map(r => r.id === updated.id ? updated : r));
if (selectedRun && selectedRun.id === updated.id) setSelectedRun(updated);
};
const thStyle = (col) => ({
cursor: 'pointer', userSelect: 'none',
color: sortCol === col ? 'var(--brand)' : 'var(--ink-3)',
whiteSpace: 'nowrap',
});
const sortIcon = (col) => sortCol === col ? (sortAsc ? ' ↑' : ' ↓') : '';
const statusTag = (s) => {
const cls = s === 'ok' ? 'ok' : s === 'error' ? 'bad' : 'warn';
return
{s};
};
return (
Ejecuciones y logs
{!loading && (
{summary.total} runs · {summary.errors} errores · coste {summary.totalCost.toFixed(4)} €
)}
setAgentSearch(e.target.value)}
style={{ width: 160 }}
/>
| toggleSort('id')}>ID{sortIcon('id')} |
toggleSort('agent_name')}>Agente{sortIcon('agent_name')} |
toggleSort('status')}>Estado{sortIcon('status')} |
toggleSort('trigger_type')}>Disparador{sortIcon('trigger_type')} |
toggleSort('duration_ms')}>Duración{sortIcon('duration_ms')} |
toggleSort('tokens_used')}>Tokens{sortIcon('tokens_used')} |
toggleSort('cost_eur')}>Coste{sortIcon('cost_eur')} |
toggleSort('created_at')}>Cuándo{sortIcon('created_at')} |
{loading && (
| Cargando ejecuciones… |
)}
{!loading && sorted.length === 0 && (
|
Sin ejecuciones con los filtros actuales.
|
)}
{!loading && sorted.map(r => (
setSelectedRun(r)}
style={{
cursor: 'pointer',
background: selectedRun?.id === r.id ? 'var(--brand)11' : undefined,
}}
>
|
{r.id.slice(0, 8)}
{r.notes && ●}
|
{r.agent_name} |
{statusTag(r.status)} |
{r.trigger_type} |
5000 ? 'var(--warn)' : 'var(--ink)' }}>{fmtDur(r.duration_ms)} |
{(r.tokens_used || 0).toLocaleString('es-ES')} |
{fmtCost(r.cost_eur)} |
{fmtWhen(r.created_at)} |
))}
Haz clic en una fila para ver detalles y editar notas · El punto naranja indica que tiene nota
{selectedRun && (
setSelectedRun(null)}
onSaved={onSaved}
/>
)}
);
};
// ---------------------------------------------------------------------------
// Gráfico de barras SVG con tooltip y ejes
// ---------------------------------------------------------------------------
const BarChart = ({ data, height = 200 }) => {
const [tooltip, setTooltip] = React.useState(null);
if (!data || data.length === 0) {
return (
Sin datos de ejecuciones todavía
);
}
const maxVal = Math.max(1, ...data.map(d => d.count));
const svgW = 800; const svgH = height;
const padL = 36; const padR = 8; const padT = 12; const padB = 28;
const chartW = svgW - padL - padR;
const chartH = svgH - padT - padB;
const barW = Math.max(2, chartW / data.length - 2);
const fmtDay = (day) => {
const d = new Date(day + 'T00:00:00');
return d.toLocaleDateString('es-ES', { day: '2-digit', month: 'short' });
};
// Y axis labels
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(f => ({
val: Math.round(maxVal * f),
y: padT + chartH - Math.round(chartH * f),
}));
// X labels: show first, middle, last
const xLabels = [];
if (data.length > 0) {
xLabels.push(0);
if (data.length > 2) xLabels.push(Math.floor(data.length / 2));
if (data.length > 1) xLabels.push(data.length - 1);
}
return (
{tooltip && (
{tooltip.day} · {tooltip.count} ejecuciones
)}
);
};
// ---------------------------------------------------------------------------
// Analytics — dashboard completo con tendencias
// ---------------------------------------------------------------------------
const Analytics = ({ data, setRoute }) => {
const [stats, setStats] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [days, setDays] = React.useState(28);
const getToken = () => sessionStorage.getItem('af_token') || '';
React.useEffect(() => {
setLoading(true);
fetch('/api/v1/analytics/summary?days=' + days, {
headers: { Authorization: 'Bearer ' + getToken() },
})
.then(r => r.ok ? r.json() : null)
.then(d => { setStats(d); setLoading(false); })
.catch(() => setLoading(false));
}, [days]);
const trend = (curr, prev) => {
if (!prev) return null;
const pct = ((curr - prev) / prev * 100).toFixed(1);
const up = curr >= prev;
return { pct, up, label: (up ? '▲ +' : '▼ ') + Math.abs(pct) + ' %' };
};
const topMax = stats && stats.top_agents.length > 0 ? Math.max(1, stats.top_agents[0].count) : 1;
const triggerColors = { playground: '#60a5fa', api: '#34d399', webhook: '#a78bfa', scheduled: '#fbbf24' };
const exportCsv = () => {
if (!stats) return;
const rows = [
['Período', days + ' días'],
['Total ejecuciones', stats.total_runs],
['Tasa éxito', stats.success_rate + '%'],
['Latencia media ms', stats.avg_duration_ms],
['Tokens totales', stats.total_tokens],
['Coste total EUR', stats.total_cost_eur],
[],
['Fecha', 'Ejecuciones'],
...stats.daily_counts.map(d => [d.day, d.count]),
];
const csv = rows.map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'agentforge-analytics.csv';
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
window.afNotify && window.afNotify('Analytics exportado');
};
return (
Analítica
últimos {days} días
{loading &&
Cargando analítica…
}
{!loading && !stats &&
No se pudo cargar la analítica.
}
{!loading && stats && (() => {
const runsTrend = trend(stats.total_runs, stats.prev_total_runs);
const rateTrend = trend(stats.success_rate, stats.prev_success_rate);
const costTrend = trend(stats.total_cost_eur, stats.prev_cost_eur);
return (<>
{/* KPIs */}
{[
{
label: 'Ejecuciones totales',
val: stats.total_runs.toLocaleString('es-ES'),
trend: runsTrend,
trendGood: runsTrend?.up,
color: 'var(--ink)',
},
{
label: 'Tasa de éxito',
val: stats.success_rate.toFixed(1) + ' %',
trend: rateTrend,
trendGood: rateTrend?.up,
color: stats.success_rate >= 95 ? 'var(--ok)' : stats.success_rate >= 80 ? 'var(--warn)' : 'var(--bad)',
},
{
label: 'Latencia media',
val: stats.avg_duration_ms > 0 ? (stats.avg_duration_ms / 1000).toFixed(2).replace('.', ',') + ' s' : '—',
color: 'var(--ink)',
},
{
label: 'Tokens consumidos',
val: (stats.total_tokens || 0).toLocaleString('es-ES'),
color: 'var(--ink)',
},
{
label: 'Gasto total',
val: stats.total_cost_eur.toFixed(4).replace('.', ',') + ' €',
trend: costTrend,
trendGood: !costTrend?.up,
color: 'var(--ink)',
},
].map((k, i) => (
{k.label}
{k.val}
{k.trend && (
{k.trend.label} vs período ant.
)}
))}
{/* Gráfico diario */}
Ejecuciones en el tiempo
últimos {days} días · agregado diario · {stats.daily_counts.length} puntos
{/* Fila inferior */}
{/* Top agentes */}
Top agentes
{stats.top_agents.length === 0 ? (
Sin datos todavía.
) : stats.top_agents.map((a, i) => (
{i + 1}
{a.count.toLocaleString('es-ES')}
))}
{/* Por disparador */}
Por disparador
{(!stats.trigger_breakdown || stats.trigger_breakdown.length === 0) ? (
Sin datos todavía.
) : (() => {
const total = stats.trigger_breakdown.reduce((s, t) => s + t.count, 0) || 1;
return stats.trigger_breakdown.map((t) => (
{t.trigger}
{Math.round(t.count / total * 100)} %
{t.count.toLocaleString('es-ES')}
));
})()}
{/* Errores */}
Errores frecuentes
{stats.error_breakdown.length === 0 ? (
Sin errores en el período ✓
) : stats.error_breakdown.map((e, i) => (
))}
>);
})()}
);
};
window.Integrations = Integrations;
window.Billing = Billing;
window.SettingsInner = SettingsInner;
window.Settings = Settings;
window.Home = Home;
window.RunsLogs = RunsLogs;
window.Analytics = Analytics;