diff --git a/backend/apps/analytics/__init__.py b/backend/apps/analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py new file mode 100644 index 00000000..defef678 --- /dev/null +++ b/backend/apps/analytics/analytics.py @@ -0,0 +1,144 @@ +"""Analytics SubApp: PostHog for product analytics + local usage summary from session data.""" + +import json +import logging +import os +import platform +from collections import Counter +from contextlib import asynccontextmanager + +from backend.config.Apps import SubApp +from backend.config.paths import SESSIONS_DIR +from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def analytics_lifespan(): + init_collector() + logger.info("PostHog analytics initialised") + + try: + from backend.apps.settings.settings import load_settings + settings = load_settings() + + providers = [] + if getattr(settings, "anthropic_api_key", None): + providers.append("anthropic") + + record("app.opened", { + "os": platform.system(), + "platform": platform.platform(), + "provider_count": len(providers), + "providers": providers, + }) + + identify({ + "providers_configured": providers, + "provider_count": len(providers), + }) + except Exception as e: + logger.debug(f"Analytics startup event failed (non-critical): {e}") + + yield + + shutdown_collector() + logger.info("PostHog analytics shut down") + + +analytics = SubApp("analytics", analytics_lifespan) + + +def _load_all_sessions() -> list[dict]: + """Load all persisted session JSON files.""" + results = [] + if not os.path.exists(SESSIONS_DIR): + return results + for fname in os.listdir(SESSIONS_DIR): + if fname.endswith(".json"): + try: + with open(os.path.join(SESSIONS_DIR, fname)) as f: + results.append(json.load(f)) + except Exception: + pass + return results + + +@analytics.router.get("/usage-summary") +async def usage_summary(): + """Compute usage stats from persisted sessions for the Settings page.""" + from backend.apps.agents.agent_manager import agent_manager + + # Combine persisted + active sessions + sessions = _load_all_sessions() + for s in agent_manager.get_all_sessions(): + sessions.append(s.model_dump(mode="json")) + + total_sessions = len(sessions) + total_cost = sum(s.get("cost_usd", 0) for s in sessions) + total_messages = 0 + total_tool_calls = 0 + total_duration = 0.0 + model_counts: Counter = Counter() + provider_counts: Counter = Counter() + tool_counts: Counter = Counter() + status_counts: Counter = Counter() + + for s in sessions: + messages = s.get("messages", []) + user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")] + tool_msgs = [m for m in messages if m.get("role") == "tool_call"] + total_messages += len(user_msgs) + total_tool_calls += len(tool_msgs) + + model_counts[s.get("model", "unknown")] += 1 + provider_counts[s.get("provider", "anthropic")] += 1 + status_counts[s.get("status", "unknown")] += 1 + + # Duration + created = s.get("created_at") + closed = s.get("closed_at") + if created and closed: + try: + from datetime import datetime + fmt = "%Y-%m-%dT%H:%M:%S" + c_str = created[:19] + cl_str = closed[:19] + dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds() + if dur > 0: + total_duration += dur + except Exception: + pass + + # Count individual tools + for m in tool_msgs: + content = m.get("content", {}) + if isinstance(content, dict): + tool_name = content.get("tool", "") + if tool_name: + tool_counts[tool_name] += 1 + + avg_duration = total_duration / total_sessions if total_sessions > 0 else 0 + avg_cost = total_cost / total_sessions if total_sessions > 0 else 0 + completed = status_counts.get("completed", 0) + completion_rate = completed / total_sessions if total_sessions > 0 else 0 + + return { + "total_sessions": total_sessions, + "total_cost_usd": round(total_cost, 4), + "total_messages": total_messages, + "total_tool_calls": total_tool_calls, + "avg_duration_seconds": round(avg_duration, 1), + "avg_cost_per_session": round(avg_cost, 4), + "completion_rate": round(completion_rate, 3), + "models_used": dict(model_counts.most_common(10)), + "providers_used": dict(provider_counts.most_common(10)), + "top_tools": dict(tool_counts.most_common(15)), + "status_breakdown": dict(status_counts), + } + + +@analytics.router.get("/status") +async def analytics_status(): + return {"status": "posthog", "enabled": True} diff --git a/backend/apps/analytics/collector.py b/backend/apps/analytics/collector.py new file mode 100644 index 00000000..dddcedc5 --- /dev/null +++ b/backend/apps/analytics/collector.py @@ -0,0 +1,123 @@ +"""PostHog-only analytics collector. + +All events go directly to PostHog. No local SQLite storage. + +Usage from any module: + from backend.apps.analytics.collector import record + record("session.started", {"model": "opus"}, session_id="abc123") +""" + +import logging +import platform +from uuid import uuid4 + +from posthog import Posthog + +logger = logging.getLogger(__name__) + +POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB" +POSTHOG_HOST = "https://us.i.posthog.com" + +_posthog: Posthog | None = None +_installation_id: str | None = None + + +def init(): + """Initialise PostHog. Called once at app startup.""" + global _posthog + if _posthog is None: + _posthog = Posthog( + project_api_key=POSTHOG_API_KEY, + host=POSTHOG_HOST, + ) + return _posthog + + +def shutdown(): + """Flush and close. Called at app shutdown.""" + global _posthog + if _posthog: + try: + _posthog.shutdown() + except Exception: + pass + _posthog = None + + +def _get_installation_id() -> str: + """Get or create a stable anonymous installation ID.""" + global _installation_id + if _installation_id: + return _installation_id + try: + from backend.apps.settings.settings import load_settings, _save_settings + settings = load_settings() + iid = getattr(settings, "installation_id", None) + if not iid: + iid = uuid4().hex + settings.installation_id = iid + _save_settings(settings) + _installation_id = iid + except Exception: + _installation_id = uuid4().hex + return _installation_id + + +def _is_opted_in() -> bool: + """Check if user has opted in to analytics.""" + try: + from backend.apps.settings.settings import load_settings + return getattr(load_settings(), "analytics_opt_in", True) + except Exception: + return True + + +def record( + event_type: str, + properties: dict | None = None, + session_id: str | None = None, + dashboard_id: str | None = None, +): + """Record an analytics event to PostHog.""" + if not _posthog or not _is_opted_in(): + return + + props = {**(properties or {})} + if session_id: + props["session_id"] = session_id + if dashboard_id: + props["dashboard_id"] = dashboard_id + props["os"] = platform.system() + props["platform"] = platform.platform() + + try: + _posthog.capture( + event_type, + distinct_id=_get_installation_id(), + properties=props, + ) + except Exception as e: + logger.debug(f"PostHog capture failed (non-critical): {e}") + + +def identify(extra_properties: dict | None = None): + """Identify the current installation with properties.""" + if not _posthog or not _is_opted_in(): + return + + try: + _posthog.identify( + _get_installation_id(), + properties={ + "os": platform.system(), + "platform": platform.platform(), + **(extra_properties or {}), + }, + ) + except Exception as e: + logger.debug(f"PostHog identify failed (non-critical): {e}") + + +def get_collector(): + """Backward compat — returns None since we no longer have a local collector.""" + return None diff --git a/backend/apps/analytics/models.py b/backend/apps/analytics/models.py new file mode 100644 index 00000000..a8696dde --- /dev/null +++ b/backend/apps/analytics/models.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel +from typing import Optional + + +class AnalyticsEvent(BaseModel): + id: Optional[int] = None + timestamp: str + event_type: str + properties: dict + session_id: Optional[str] = None + dashboard_id: Optional[str] = None + + +class UsageSummary(BaseModel): + total_sessions: int = 0 + total_cost_usd: float = 0.0 + total_messages: int = 0 + total_tool_calls: int = 0 + avg_session_duration_seconds: float = 0.0 + session_completion_rate: float = 0.0 + approval_rate: float = 0.0 + models_used: dict[str, int] = {} + modes_used: dict[str, int] = {} + top_tools: list[list] = [] + + +class TimeSeriesPoint(BaseModel): + date: str + value: float + + +class ExportPayload(BaseModel): + export_version: str = "1.0" + exported_at: str = "" + app_version: str = "unknown" + period: dict = {} + summary: dict = {} diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index e2e3061f..172e8337 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -25,3 +25,6 @@ class AppSettings(BaseModel): expand_new_chats_in_dashboard: bool = False auto_reveal_sub_agents: bool = True dev_mode: bool = False + # Analytics: opted in by default, user can toggle off + analytics_opt_in: bool = True + installation_id: Optional[str] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index e590d9a4..4ddfd1f8 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -38,6 +38,13 @@ def load_settings() -> AppSettings: return AppSettings() +def _save_settings(settings_obj: AppSettings): + """Persist settings to JSON file.""" + os.makedirs(DATA_DIR, exist_ok=True) + with open(SETTINGS_FILE, "w") as f: + json.dump(settings_obj.model_dump(), f, indent=2) + + @settings.router.get("") async def get_settings(): return load_settings().model_dump() diff --git a/backend/main.py b/backend/main.py index c3d8736a..9dbfd4b7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,11 +19,12 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry from backend.apps.skill_registry.skill_registry import skill_registry from backend.apps.outputs.outputs import outputs from backend.apps.dashboards.dashboards import dashboards +from backend.apps.analytics.analytics import analytics from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards]) +main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics]) app = main_app.app app.add_middleware( diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ef474e6..9cc94efe 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,5 @@ pytest==8.3.4 pytest-asyncio==0.25.2 typeguard==4.4.2 python-dotenv==1.1.1 -Pillow \ No newline at end of file +Pillow +posthog \ No newline at end of file diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 97920562..e1ac50dc 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -22,6 +22,8 @@ import Tools from './pages/Tools/Tools'; import Modes from './pages/Modes/Modes'; import Views from './pages/Views/Views'; import Customization from './pages/Customization/Customization'; +import Analytics from './pages/Analytics/Analytics'; +import AnalyticsOptIn from './components/AnalyticsOptIn'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp'; import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -228,8 +230,10 @@ const ThemedApp: React.FC = () => { } /> } /> } /> + } /> + diff --git a/frontend/src/app/components/AnalyticsOptIn.tsx b/frontend/src/app/components/AnalyticsOptIn.tsx new file mode 100644 index 00000000..b96701dd --- /dev/null +++ b/frontend/src/app/components/AnalyticsOptIn.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { updateSettings } from '@/shared/state/settingsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const AnalyticsOptIn: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const settings = useAppSelector((s) => s.settings.data); + const loaded = useAppSelector((s) => s.settings.loaded); + + if (!loaded || settings.analytics_opt_in !== null) return null; + + const handleChoice = (optIn: boolean) => { + dispatch(updateSettings({ ...settings, analytics_opt_in: optIn })); + }; + + return ( + + + + Help improve OpenSwarm + + + Share anonymous usage statistics like session counts, feature usage, and model preferences. + No conversations, file paths, or personal information — ever. + You can change this anytime in Settings. + + + + + + + + ); +}; + +export default AnalyticsOptIn; diff --git a/frontend/src/app/pages/Analytics/Analytics.tsx b/frontend/src/app/pages/Analytics/Analytics.tsx new file mode 100644 index 00000000..1c10703e --- /dev/null +++ b/frontend/src/app/pages/Analytics/Analytics.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const Analytics: React.FC = () => { + const c = useClaudeTokens(); + + return ( + + + + Analytics + + + + + + + + + + + + Analytics powered by PostHog + + + Usage data is automatically collected — sessions, costs, tool usage, model distribution, and task categories. + All data is anonymous and can be disabled in Settings. + + + + {[ + { label: 'Sessions & Usage', desc: 'How often agents are launched, session duration, completion rates' }, + { label: 'Cost Tracking', desc: 'Spend by model, provider, and time period' }, + { label: 'Task Categories', desc: 'What users do — coding, email, research, social, browsing' }, + { label: 'Model Distribution', desc: 'Which models and providers are most popular' }, + { label: 'Tool Usage', desc: 'Most used MCP tools, execution times, approval rates' }, + { label: 'Retention & Funnels', desc: 'User engagement, feature adoption, onboarding flow' }, + ].map((item) => ( + + + {item.label} + + + {item.desc} + + + ))} + + + + + ); +}; + +export default Analytics; diff --git a/frontend/src/app/pages/Analytics/PixelChart.tsx b/frontend/src/app/pages/Analytics/PixelChart.tsx new file mode 100644 index 00000000..f889517b --- /dev/null +++ b/frontend/src/app/pages/Analytics/PixelChart.tsx @@ -0,0 +1,402 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const PALETTES = { + salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'], + blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'], + coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'], + green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'], + purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'], +} as const; + +type PaletteKey = keyof typeof PALETTES; + +interface PixelChartProps { + data: { label: string; value: number }[]; + palette?: PaletteKey; + height?: number; + pixelSize?: number; + formatValue?: (v: number) => string; + glow?: boolean; + showXLabels?: boolean; + showYScale?: boolean; + mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars +} + +const PixelChart: React.FC = ({ + data, + palette = 'salmon', + height = 140, + pixelSize = 6, + formatValue, + glow = true, + showXLabels = true, + showYScale = true, + mode = 'bar', +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animRef = useRef(0); + const progressRef = useRef(0); + const hoverIdxRef = useRef(-1); + const tooltipRef = useRef(null); + const c = useClaudeTokens(); + const colors = PALETTES[palette]; + + const maxVal = Math.max(...data.map((d) => d.value), 0.001); + + // Compute nice Y-axis ticks + const yTicks = (() => { + if (maxVal <= 0) return [0]; + const rawStep = maxVal / 3; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const normalised = rawStep / magnitude; + let niceStep: number; + if (normalised <= 1) niceStep = magnitude; + else if (normalised <= 2) niceStep = 2 * magnitude; + else if (normalised <= 5) niceStep = 5 * magnitude; + else niceStep = 10 * magnitude; + const ticks: number[] = []; + for (let v = 0; v <= maxVal * 1.1; v += niceStep) { + ticks.push(v); + } + if (ticks.length < 2) ticks.push(niceStep); + return ticks; + })(); + + // X-axis labels: show first, last, and up to 3 evenly spaced + const xLabels = (() => { + if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label })); + if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label })); + const result: { idx: number; label: string }[] = []; + result.push({ idx: 0, label: data[0].label }); + const step = Math.floor(data.length / 4); + for (let i = 1; i <= 3; i++) { + const idx = Math.min(i * step, data.length - 2); + if (idx > 0 && idx < data.length - 1) { + result.push({ idx, label: data[idx].label }); + } + } + result.push({ idx: data.length - 1, label: data[data.length - 1].label }); + return result; + })(); + + const Y_LABEL_WIDTH = showYScale ? 80 : 0; + + const draw = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || data.length === 0) return; + + const dpr = window.devicePixelRatio || 1; + const totalW = container.clientWidth; + const chartW = totalW - Y_LABEL_WIDTH; + const h = height; + canvas.width = totalW * dpr; + canvas.height = h * dpr; + canvas.style.width = `${totalW}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.scale(dpr, dpr); + + const px = pixelSize; + const gridCols = Math.floor(chartW / px); + const gridRows = Math.floor(h / px); + const effectiveMax = yTicks[yTicks.length - 1] || maxVal; + + ctx.clearRect(0, 0, totalW, h); + + // Y-axis labels and horizontal grid lines + if (showYScale) { + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + for (const tick of yTicks) { + const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0; + const yPx = h - yNorm * (h - px); + + // Grid line + ctx.strokeStyle = c.border.subtle; + ctx.lineWidth = 0.5; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(Y_LABEL_WIDTH, yPx); + ctx.lineTo(totalW, yPx); + ctx.stroke(); + ctx.setLineDash([]); + + // Label + const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1)); + ctx.fillStyle = c.text.ghost; + ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx); + } + } + + // Subtle grid dots in chart area + ctx.fillStyle = c.border.subtle; + for (let gy = 0; gy < gridRows; gy += 5) { + for (let gx = 0; gx < gridCols; gx += 5) { + ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1); + } + } + + const progress = Math.min(progressRef.current, 1); + const hoverIdx = hoverIdxRef.current; + + if (mode === 'area') { + // -- Area / line chart mode -- + const usableH = h - px * 2; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i < data.length; i++) { + const val = data[i].value; + const norm = effectiveMax > 0 ? val / effectiveMax : 0; + const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW; + const y = h - px - norm * usableH * progress; + points.push({ x, y }); + } + + if (points.length > 0) { + // Filled area with gradient + const gradient = ctx.createLinearGradient(0, 0, 0, h); + gradient.addColorStop(0, colors[colors.length - 1] + '60'); + gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30'); + gradient.addColorStop(1, colors[0] + '08'); + + ctx.beginPath(); + ctx.moveTo(points[0].x, h); + for (let i = 0; i < points.length; i++) { + if (i === 0) { + ctx.lineTo(points[i].x, points[i].y); + } else { + const prev = points[i - 1]; + const curr = points[i]; + const cpx = (prev.x + curr.x) / 2; + ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); + } + } + ctx.lineTo(points[points.length - 1].x, h); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // Line on top + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + if (i === 0) { + ctx.moveTo(points[i].x, points[i].y); + } else { + const prev = points[i - 1]; + const curr = points[i]; + const cpx = (prev.x + curr.x) / 2; + ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y); + } + } + ctx.strokeStyle = colors[colors.length - 1]; + ctx.lineWidth = 2; + ctx.stroke(); + + // Glow on line + if (glow) { + ctx.shadowColor = colors[colors.length - 1]; + ctx.shadowBlur = 8; + ctx.stroke(); + ctx.shadowBlur = 0; + } + + // Data point dots + for (let i = 0; i < points.length; i++) { + if (data[i].value > 0) { + const isHov = i === hoverIdx; + ctx.beginPath(); + ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2); + ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)]; + ctx.fill(); + if (isHov) { + ctx.strokeStyle = colors[colors.length - 1]; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + } + + // Pixel scatter in the filled area for the pixel art feel + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]; + const p2 = points[i + 1]; + const steps = Math.ceil((p2.x - p1.x) / px); + for (let s = 0; s < steps; s++) { + const t = s / steps; + const x = p1.x + t * (p2.x - p1.x); + const lineY = p1.y + t * (p2.y - p1.y); + for (let py = lineY + px * 2; py < h - px; py += px * 2) { + if (Math.random() > 0.65) { + const depth = (py - lineY) / (h - lineY); + const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1))); + ctx.globalAlpha = 0.15 + (1 - depth) * 0.2; + ctx.fillStyle = colors[ci]; + ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1); + } + } + } + } + ctx.globalAlpha = 1; + } + } else { + // -- Bar chart mode (original) -- + const barSlots = data.length; + const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots)); + const barW = Math.max(1, totalBarPx - 1); + + for (let i = 0; i < data.length; i++) { + const val = data[i].value; + const normalised = effectiveMax > 0 ? val / effectiveMax : 0; + const usableRows = gridRows - 2; + const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows)); + const barH = Math.round(targetH * progress); + const barX = i * totalBarPx; + const isHovered = i === hoverIdx; + + for (let row = 0; row < barH; row++) { + const y = gridRows - 1 - row; + const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1))); + const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx]; + + for (let col = 0; col < barW; col++) { + ctx.fillStyle = baseColor; + ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1); + } + } + + if (glow && barH > 0) { + const topY = (gridRows - 1 - barH + 1) * px; + ctx.shadowColor = colors[colors.length - 1]; + ctx.shadowBlur = 6; + ctx.fillStyle = colors[colors.length - 1]; + for (let col = 0; col < barW; col++) { + ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1); + } + ctx.shadowBlur = 0; + } + } + } + }, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]); + + useEffect(() => { + progressRef.current = 0; + let start: number | null = null; + const animate = (ts: number) => { + if (!start) start = ts; + progressRef.current = Math.min(1, (ts - start) / 600); + draw(); + if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate); + }; + animRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animRef.current); + }, [data, draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + const tooltip = tooltipRef.current; + if (!canvas || !tooltip || data.length === 0) return; + + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left - Y_LABEL_WIDTH; + if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; } + + const chartW = rect.width - Y_LABEL_WIDTH; + const gridCols = Math.floor(chartW / pixelSize); + const totalBarPx = Math.max(1, Math.floor(gridCols / data.length)); + const idx = Math.floor(mx / (totalBarPx * pixelSize)); + + if (idx >= 0 && idx < data.length) { + hoverIdxRef.current = idx; + const d = data[idx]; + const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2); + tooltip.textContent = `${d.label}: ${valStr}`; + tooltip.style.opacity = '1'; + tooltip.style.left = `${e.clientX - rect.left}px`; + tooltip.style.top = `${e.clientY - rect.top - 28}px`; + } else { + hoverIdxRef.current = -1; + tooltip.style.opacity = '0'; + } + draw(); + }, + [data, pixelSize, draw, formatValue, Y_LABEL_WIDTH], + ); + + const handleMouseLeave = useCallback(() => { + hoverIdxRef.current = -1; + if (tooltipRef.current) tooltipRef.current.style.opacity = '0'; + draw(); + }, [draw]); + + return ( + + + {/* X-axis labels */} + {showXLabels && data.length > 0 && ( + + {xLabels.map((xl) => ( + + {xl.label} + + ))} + + )} + {/* Tooltip */} + + + ); +}; + +export default PixelChart; diff --git a/frontend/src/shared/state/analyticsSlice.ts b/frontend/src/shared/state/analyticsSlice.ts new file mode 100644 index 00000000..8de6ca32 --- /dev/null +++ b/frontend/src/shared/state/analyticsSlice.ts @@ -0,0 +1,235 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { API_BASE } from '@/shared/config'; + +const ANALYTICS_API = `${API_BASE}/analytics`; + +export interface AnalyticsSummary { + total_sessions: number; + total_cost_usd: number; + total_messages: number; + total_tool_calls: number; + avg_session_duration_seconds: number; + session_completion_rate: number; + approval_rate: number; + models_used: Record; + modes_used: Record; + top_tools: [string, number][]; +} + +export interface UsagePoint { + date: string; + sessions: number; + cost: number; +} + +export interface CostPoint { + date: string; + cost: number; +} + +export interface ToolRank { + tool: string; + count: number; +} + +export interface ApprovalStats { + allow: number; + deny: number; + total: number; + rate: number; + avg_latency_ms: number; +} + +export interface SessionStats { + completed: number; + stopped: number; + error: number; + total: number; + completion_rate: number; + avg_duration_seconds: number; +} + +export interface HourlyPoint { + hour: number; + count: number; +} + +export interface DurationBucket { + label: string; + count: number; +} + +export interface CostByModel { + model: string; + cost: number; + sessions: number; +} + +export interface CumulativeCostPoint { + date: string; + cumulative: number; + daily: number; +} + +export interface ToolDuration { + tool: string; + calls: number; + avg_ms: number; + max_ms: number; +} + +export interface SessionCost { + timestamp: string; + model: string; + cost: number; + duration: number; + messages: number; +} + +interface AnalyticsState { + summary: AnalyticsSummary | null; + usage: UsagePoint[]; + cost: CostPoint[]; + tools: ToolRank[]; + approvals: ApprovalStats | null; + sessionStats: SessionStats | null; + hourly: HourlyPoint[]; + durationDist: DurationBucket[]; + costByModel: CostByModel[]; + cumulativeCost: CumulativeCostPoint[]; + toolDurations: ToolDuration[]; + sessionCosts: SessionCost[]; + exportPreview: any | null; + loading: boolean; +} + +const initialState: AnalyticsState = { + summary: null, + usage: [], + cost: [], + tools: [], + approvals: null, + sessionStats: null, + hourly: [], + durationDist: [], + costByModel: [], + cumulativeCost: [], + toolDurations: [], + sessionCosts: [], + exportPreview: null, + loading: false, +}; + +export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => { + const res = await fetch(`${ANALYTICS_API}/summary`); + return (await res.json()) as AnalyticsSummary; +}); + +export const fetchUsage = createAsyncThunk( + 'analytics/fetchUsage', + async ({ period, range }: { period: string; range: number }) => { + const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`); + const data = await res.json(); + return data.data as UsagePoint[]; + }, +); + +export const fetchCost = createAsyncThunk( + 'analytics/fetchCost', + async ({ period, range }: { period: string; range: number }) => { + const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`); + const data = await res.json(); + return data.data as CostPoint[]; + }, +); + +export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => { + const res = await fetch(`${ANALYTICS_API}/tools?limit=20`); + const data = await res.json(); + return data.data as ToolRank[]; +}); + +export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => { + const res = await fetch(`${ANALYTICS_API}/approvals`); + return (await res.json()) as ApprovalStats; +}); + +export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => { + const res = await fetch(`${ANALYTICS_API}/sessions-stats`); + return (await res.json()) as SessionStats; +}); + +export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => { + const res = await fetch(`${ANALYTICS_API}/hourly-activity`); + const data = await res.json(); + return data.data as HourlyPoint[]; +}); + +export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => { + const res = await fetch(`${ANALYTICS_API}/duration-distribution`); + const data = await res.json(); + return data.data as DurationBucket[]; +}); + +export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => { + const res = await fetch(`${ANALYTICS_API}/cost-by-model`); + const data = await res.json(); + return data.data as CostByModel[]; +}); + +export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => { + const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`); + const data = await res.json(); + return data.data as CumulativeCostPoint[]; +}); + +export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => { + const res = await fetch(`${ANALYTICS_API}/tool-durations`); + const data = await res.json(); + return data.data as ToolDuration[]; +}); + +export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => { + const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`); + const data = await res.json(); + return data.data as SessionCost[]; +}); + +export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => { + const res = await fetch(`${ANALYTICS_API}/export/preview`); + return await res.json(); +}); + +export const doExport = createAsyncThunk('analytics/doExport', async () => { + const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' }); + return await res.json(); +}); + +const analyticsSlice = createSlice({ + name: 'analytics', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; }) + .addCase(fetchAnalyticsSummary.fulfilled, (state, action) => { + state.loading = false; + state.summary = action.payload; + }) + .addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; }) + .addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; }) + .addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; }) + .addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; }) + .addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; }) + .addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; }) + .addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; }) + .addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; }) + .addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; }) + .addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; }) + .addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; }) + .addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; }) + .addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; }); + }, +}); + +export default analyticsSlice.reducer; diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index a1424e45..fceb33c9 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -12,6 +12,7 @@ import outputsReducer from './outputsSlice'; import dashboardLayoutReducer from './dashboardLayoutSlice'; import dashboardsReducer from './dashboardsSlice'; import updateReducer from './updateSlice'; +import analyticsReducer from './analyticsSlice'; export const store = configureStore({ reducer: { @@ -28,6 +29,7 @@ export const store = configureStore({ dashboardLayout: dashboardLayoutReducer, dashboards: dashboardsReducer, update: updateReducer, + analytics: analyticsReducer, }, });