mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] add PostHog analytics integration
This commit is contained in:
@@ -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}
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
@@ -9,4 +9,5 @@ pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
typeguard==4.4.2
|
||||
python-dotenv==1.1.1
|
||||
Pillow
|
||||
Pillow
|
||||
posthog
|
||||
@@ -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 = () => {
|
||||
<Route path="/modes" element={<Modes />} />
|
||||
<Route path="/apps" element={<Views />} />
|
||||
<Route path="/apps/:id" element={<Views />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AnalyticsOptIn />
|
||||
</UpdateListener>
|
||||
</SettingsLoader>
|
||||
</ShortcutsProvider>
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1400,
|
||||
maxWidth: 480,
|
||||
width: '90%',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 3,
|
||||
boxShadow: c.shadow.lg,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.9rem', fontWeight: 600, mb: 0.5 }}>
|
||||
Help improve OpenSwarm
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }}>
|
||||
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.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => handleChoice(false)}
|
||||
sx={{
|
||||
color: c.text.muted,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.82rem',
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
}}
|
||||
>
|
||||
No thanks
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => handleChoice(true)}
|
||||
sx={{
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
textTransform: 'none',
|
||||
fontSize: '0.82rem',
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
Share anonymous data
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsOptIn;
|
||||
@@ -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 (
|
||||
<Box sx={{ height: '100%', overflow: 'auto', p: 3 }}>
|
||||
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 600, mb: 3 }}>
|
||||
Analytics
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{
|
||||
p: 4,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke={c.accent.primary} strokeWidth="1.5">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M7 16l4-4 4 4 5-5" />
|
||||
<circle cx="20" cy="7" r="1.5" fill={c.accent.primary} />
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '1.1rem', fontWeight: 600, mb: 1 }}>
|
||||
Analytics powered by PostHog
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6, mb: 3, maxWidth: 500, mx: 'auto' }}>
|
||||
Usage data is automatically collected — sessions, costs, tool usage, model distribution, and task categories.
|
||||
All data is anonymous and can be disabled in Settings.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 3, textAlign: 'left' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<Box key={item.label} sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.82rem', fontWeight: 600, mb: 0.5 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', lineHeight: 1.4 }}>
|
||||
{item.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
@@ -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<PixelChartProps> = ({
|
||||
data,
|
||||
palette = 'salmon',
|
||||
height = 140,
|
||||
pixelSize = 6,
|
||||
formatValue,
|
||||
glow = true,
|
||||
showXLabels = true,
|
||||
showYScale = true,
|
||||
mode = 'bar',
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const animRef = useRef(0);
|
||||
const progressRef = useRef(0);
|
||||
const hoverIdxRef = useRef(-1);
|
||||
const tooltipRef = useRef<HTMLDivElement>(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 (
|
||||
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
|
||||
/>
|
||||
{/* X-axis labels */}
|
||||
{showXLabels && data.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
|
||||
{xLabels.map((xl) => (
|
||||
<Typography
|
||||
key={xl.idx}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: '0.58rem',
|
||||
fontFamily: c.font.mono,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 60,
|
||||
}}
|
||||
>
|
||||
{xl.label}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<Box
|
||||
ref={tooltipRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.12s',
|
||||
bgcolor: c.bg.inverse,
|
||||
color: c.text.inverse,
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: c.font.mono,
|
||||
fontWeight: 500,
|
||||
px: 1,
|
||||
py: 0.35,
|
||||
borderRadius: 0.75,
|
||||
whiteSpace: 'nowrap',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PixelChart;
|
||||
@@ -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<string, number>;
|
||||
modes_used: Record<string, number>;
|
||||
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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user