/* Analytics.jsx — live API. * Без synthetic timeseries: KPI з /overview + /analytics; donut статусу * маршрутів + 7-денна activity-summary з audit-log; останні інциденти. */ const { useState: useStateA, useEffect: useEffectA } = React; function Donut({ segments, size = 100 }) { const r = 36, cx = size / 2, cy = size / 2; const total = segments.reduce((a, s) => a + (s.value || 0), 0); if (!total) { return ( ); } let angle = -90; return ( {segments.map((s, i) => { const v = s.value || 0; if (!v) return null; const sweep = v / total; const x1 = cx + r * Math.cos(angle * Math.PI / 180); const y1 = cy + r * Math.sin(angle * Math.PI / 180); const endAngle = angle + sweep * 360; const x2 = cx + r * Math.cos(endAngle * Math.PI / 180); const y2 = cy + r * Math.sin(endAngle * Math.PI / 180); const large = sweep > 0.5 ? 1 : 0; const path = `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`; angle = endAngle; return ; })} ); } function ActionLabel(action) { const m = { "passenger.stops.create": "Створено зупинку", "passenger.stops.update": "Оновлено зупинку", "passenger.stops.publish": "Опубліковано зупинку", "passenger.stops.delete": "Видалено зупинку", "passenger.routes.create": "Створено маршрут", "passenger.routes.publish": "Опубліковано маршрут", "passenger.routes.pause": "Призупинено маршрут", "passenger.routes.resume": "Відновлено маршрут", "passenger.routes.delete": "Видалено маршрут", "passenger.monitoring.ack": "Підтверджено алерт", "passenger.settings.update": "Зміна налаштувань", "passenger.auth.login": "Логін", "passenger.auth.login_failed": "Невдала спроба логіну", "passenger.auth.logout": "Вихід", }; return m[action] || action; } function Analytics() { const [data, setData] = useStateA(null); const [loading, setLoading] = useStateA(true); const [error, setError] = useStateA(null); const [tab, setTab] = useStateA("overview"); const reload = async () => { try { const res = await window.passengerApi.analytics.get(); setData(res); setError(null); } catch (e) { setError(e); window.showToast && window.showToast("Не вдалося завантажити Аналітику: " + (e.message || ""), "bad"); } finally { setLoading(false); } }; useEffectA(() => { reload(); }, []); window.passengerApi.usePolling(reload, 60000, []); const kpi = (data && data.kpi) || {}; const donut = (data && data.routes_donut) || {}; const activity = (data && data.activity_7d) || {}; const incidents = (data && data.incidents_recent) || []; const tsUnavailable = data && data.timeseries_available === false; const donutSegments = [ { label: "Активні", value: donut.published || 0, color: "var(--ok)" }, { label: "Призупинені", value: donut.paused || 0, color: "var(--warn)" }, { label: "Чернетки", value: donut.draft || 0, color: "var(--text-3)" }, { label: "Архівні", value: donut.archived || 0, color: "var(--border-2)" }, ]; return ( <> { await reload(); window.showToast && window.showToast("Оновлено", "ok"); }}> ⟳ Оновити } />
{error && (
Помилка завантаження.
)} {loading && !data ? (
Завантаження…
) : ( <> {/* KPI row (live) */}
{[ { label: "Доступність мережі", value: kpi.availability_pct != null ? `${kpi.availability_pct}%` : "—", color: kpi.availability_pct >= 95 ? "var(--ok)" : kpi.availability_pct >= 85 ? "var(--warn)" : "var(--bad)" }, { label: "ТЗ онлайн", value: kpi.vehicles_total ? `${kpi.vehicles_online}/${kpi.vehicles_total}` : "—", color: "var(--text-1)" }, { label: "Алертів у мережі", value: kpi.alerts_total ?? "—", color: (kpi.alerts_total || 0) > 0 ? "var(--bad)" : "var(--ok)" }, { label: "Інциденти (відкр.)", value: kpi.incidents_open ?? "—", color: (kpi.incidents_open || 0) > 0 ? "var(--warn)" : "var(--ok)" }, ].map((k, i) => (
{k.label}
{k.value}
))}
{/* Tabs */}
{[["overview","Огляд"],["activity","Активність"],["incidents","Інциденти"]].map(([id, label]) => ( ))}
{tab === "overview" && (
{/* Donut */}
Статус маршрутів
{donutSegments.map(s => (
{s.label}
{s.value}
))}
{/* SLA / breached */}
Інциденти за період
Відкритих
0 ? "var(--bad)" : "var(--ok)" }}>{kpi.incidents_open ?? 0}
Вирішені
{kpi.incidents_resolved ?? 0}
SLA breached
0 ? "var(--bad)" : "var(--text-1)" }}>{kpi.incidents_sla_breached ?? 0}
Алертів total
{kpi.alerts_total ?? 0}
{tsUnavailable && (
📊
Часові ряди — недоступні
Trend-графіки (доступність, рейси по днях) з'являться, коли буде підключений metrics-collector. Дані зараз — поточний snapshot.
)}
)} {tab === "activity" && (
Активність за 7 днів (passenger_audit_log)
{Object.keys(activity).length === 0 ? (
Жодних подій за останні 7 днів
) : ( {Object.entries(activity).sort((a, b) => b[1] - a[1]).map(([action, n]) => ( ))}
ДіяКількість
{ActionLabel(action)}
{action}
{n}
)}
)} {tab === "incidents" && (
Останні інциденти (admin/fleet)
{incidents.length === 0 ? (
Немає інцидентів
) : ( {incidents.map((inc, i) => ( ))}
УзелКодСерйозністьСтатусПерший
{inc.central_id || "—"} {inc.code} {inc.severity === "bad" ? ⚠ Критично : inc.severity === "warn" ? ⚡ Уваги : ✓ OK} {inc.status === "active" ? ● Активний : ✓ {inc.status || "—"}} {inc.first_ts ? new Date(inc.first_ts).toLocaleString("uk-UA") : "—"}
)}
)} )}
); } Object.assign(window, { Analytics });