// ════════════════════════════════════════════════════════════════ // AdminDashboard — /admin // Overview tiles + last activity + token warnings // ════════════════════════════════════════════════════════════════ function AdminDashboard({ currentUser, onRoute }) { const A = window.SDH_ADMIN; const B = window.SDH_BILLING; const isSuperAdmin = currentUser.role === 'super_admin'; const activeUsers = A.users.filter(u => u.status === 'active').length; const invitedUsers = A.invites.filter(i => i.status === 'pending').length; const expiringTokens = A.users.filter(u => u.token_warning); const recentEvents = A.auditLog.slice(0, 8); const cronStatus = A.pipeline.cron_enabled ? 'running' : 'idle'; const billingSummary = isSuperAdmin ? B.adminSummary() : null; return (
{isSuperAdmin && ( )}
} />
{/* Top tiles */}
onRoute('admin.users')} label="пользователи" metric={activeUsers} suffix="активных" footer={`+ ${invitedUsers} в ожидании`} help={{ title: 'Активные пользователи', what: 'Учётные записи со статусом active и непросроченным токеном.', why: 'Если число резко растёт — проверьте журнал invites: не утекли ли ссылки. Если падает — проверьте expired tokens и продлите тем, кто работает.', }} /> onRoute('admin.pipeline')} label="pipeline" metric={cronStatus === 'running' ? 'идёт' : 'ожидает'} suffix={cronStatus === 'running' ? null : 'cron включён'} footer={`последний прогон ${fmtAgo2(A.pipeline.last_run_at)} · ${A.pipeline.last_run_count} новых`} statusDot={cronStatus === 'running' ? 'running' : 'idle'} help={{ title: 'Pipeline статус', what: 'Состояние cron-задачи, которая собирает новые стартапы из источников.', why: 'Если pipeline остановился, корпус устаревает каждые 6 часов. Проверьте источники на этой странице и нажмите «Запустить вручную», если нужен немедленный обновитель.', }} /> {isSuperAdmin && ( onRoute('admin.cost')} label="расходы" metric={`$${billingSummary.monthCostUsd.toFixed(2)}`} suffix={`/ $${A.settings.budget_monthly_usd}`} footer={`${Math.round(billingSummary.monthCostUsd / A.settings.budget_monthly_usd * 100)}% от бюджета`} help={{ title: 'Расходы API за месяц', what: 'Реальная стоимость LLM-вызовов в долларах. Конверсия в рубли по ЦБ курсу.', why: 'Если меньше 50% — бюджет можно урезать или провести больше Phase 4.5. Если выше 80% — настройте Budget guard в settings, чтобы cron не съел остаток.', }} /> )} onRoute('admin.audit')} label="события за 7 дней" metric={A.auditLog.length} suffix="записей" footer="все мутирующие действия логируются" help={{ title: 'Журнал событий', what: 'Запись каждого мутирующего действия пользователей: входы, инвайты, изменения настроек, запуски pipeline.', why: 'Это ваша линия обороны. Если что-то пошло не так — здесь видно, кто и когда сделал. Регулярно просматривайте за день. Никогда не отключайте.', }} />
{/* Bottom grid */}
{/* Recent activity */}

последняя активность

{recentEvents.map((ev, i) => )}
{/* Expiring tokens */}

требуют внимания

{expiringTokens.length === 0 ? (

Все токены валидны. В ближайшие недели регенерация не нужна.

) : (
{expiringTokens.map(u => (
{u.name}
{u.email}
истекает через {u.days_left} дн
))}
)}
); } function Tile({ hue, label, metric, suffix, footer, statusDot, onClick, help }) { return ( ); } function AuditRow({ event, compact }) { const t = new Date(event.timestamp); const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0'); const kindMap = { 'auth.login': { hue: 'info', label: 'вход' }, 'auth.logout': { hue: 'info', label: 'выход' }, 'pipeline.run': { hue: 'amber', label: 'pipeline' }, 'idea.deep_dive': { hue: 'purple', label: 'deep-dive' }, 'invite.create': { hue: 'emerald', label: 'invite' }, 'invite.copied': { hue: 'emerald', label: 'invite' }, 'mcp.api_call': { hue: 'amber', label: 'mcp' }, 'idea.viewed': { hue: 'info', label: 'просмотр' }, 'startup.exported': { hue: 'emerald', label: 'export' }, 'user.role_changed': { hue: 'rose', label: 'роль' }, 'cron.toggle': { hue: 'amber', label: 'cron' }, 'settings.update': { hue: 'bronze', label: 'настройки' }, 'cluster.viewed': { hue: 'info', label: 'кластер' }, 'synthesis.opened': { hue: 'purple', label: 'синтез' }, 'token.regenerated': { hue: 'rose', label: 'токен' }, 'corpus.search': { hue: 'info', label: 'поиск' }, }; const k = kindMap[event.kind] || { hue: 'info', label: 'событие' }; return (
{hh} {event.user} {k.hue === 'bronze' ? {k.label} : {k.label}} {event.details} {!compact && ( {event.ip} )}
); } function fmtAgo2(iso) { const d = new Date(iso); const mins = Math.round((new Date('2026-05-23T10:00:00Z') - d) / 60000); if (mins < 60) return `${mins} мин назад`; if (mins < 24 * 60) return `${Math.round(mins / 60)} ч назад`; return `${Math.round(mins / 60 / 24)} д назад`; } window.AdminDashboard = AdminDashboard; window.AuditRow = AuditRow; window.fmtAgo2 = fmtAgo2;