Files
waggle-os/packages/admin-web/src/pages/Analytics.tsx
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

417 lines
14 KiB
TypeScript

/**
* Analytics page — team usage analytics dashboard.
*
* Shows active users, token usage, top tools, capability gaps,
* and performance trends. Admin-only.
*/
import React, { useEffect, useState } from 'react';
import { api, getErrorMessage, type AnalyticsResponse } from '../api.js';
interface AnalyticsProps {
token: string;
teamSlug: string;
}
/* ─── Shared styles ─── */
const cardStyle: React.CSSProperties = {
padding: 20,
background: '#12141a',
border: '1px solid #2a2d36',
borderRadius: 8,
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
};
const sectionTitle: React.CSSProperties = {
fontSize: 14,
fontWeight: 600,
color: '#9ca3af',
textTransform: 'uppercase',
letterSpacing: '0.05em',
margin: '0 0 12px',
};
const thStyle: React.CSSProperties = {
textAlign: 'left',
padding: '10px 12px',
fontSize: 13,
color: '#9ca3af',
fontWeight: 600,
borderBottom: '1px solid #2a2d36',
};
const tdStyle: React.CSSProperties = {
padding: '10px 12px',
fontSize: 14,
color: '#cbd5e1',
borderBottom: '1px solid #1a1d25',
};
const tableStyle: React.CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
background: '#12141a',
border: '1px solid #2a2d36',
borderRadius: 8,
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
};
/* ─── Sub-components ─── */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isAnalyticsResponse(value: unknown): value is AnalyticsResponse {
if (!isRecord(value)) return false;
const activeUsers = value.activeUsers;
const tokenUsage = value.tokenUsage;
const performanceTrends = value.performanceTrends;
return (
isRecord(activeUsers)
&& typeof activeUsers.daily === 'number'
&& typeof activeUsers.weekly === 'number'
&& typeof activeUsers.monthly === 'number'
&& isRecord(tokenUsage)
&& typeof tokenUsage.total === 'number'
&& Array.isArray(tokenUsage.byUser)
&& Array.isArray(value.topTools)
&& Array.isArray(value.topCommands)
&& Array.isArray(value.capabilityGaps)
&& isRecord(performanceTrends)
&& typeof performanceTrends.correctionRate === 'number'
&& typeof performanceTrends.correctionTrend === 'number'
&& typeof performanceTrends.avgResponseTime === 'number'
);
}
function ActiveUsersCard({ data }: { data: AnalyticsResponse['activeUsers'] }) {
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Active Users</h3>
<div style={{ display: 'flex', gap: 24 }}>
<div>
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.daily}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 24h</div>
</div>
<div>
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.weekly}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 7d</div>
</div>
<div>
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.monthly}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 30d</div>
</div>
</div>
</div>
);
}
function TokenUsageCard({ data }: { data: AnalyticsResponse['tokenUsage'] }) {
const formattedTotal = data.total >= 1000000
? `${(data.total / 1000000).toFixed(1)}M`
: data.total >= 1000
? `${(data.total / 1000).toFixed(1)}K`
: String(data.total);
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Token Usage</h3>
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000', marginBottom: 16 }}>
{formattedTotal} tokens
</div>
{data.byUser.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: 13 }}>No usage data yet.</p>
) : (
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Token usage table" tabIndex={0}>
<table style={{ ...tableStyle, minWidth: 480 }}>
<thead>
<tr>
<th style={thStyle}>User</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Tokens</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Cost</th>
</tr>
</thead>
<tbody>
{data.byUser.map((u) => (
<tr key={u.userId}>
<td style={tdStyle}>{u.name}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{u.tokens.toLocaleString()}
</td>
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
${u.cost.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function TopToolsCard({ data }: { data: AnalyticsResponse['topTools'] }) {
const maxInvocations = data.length > 0 ? Math.max(...data.map((t) => t.invocations)) : 1;
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Top Tools</h3>
{data.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: 13 }}>No tool usage data yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((tool) => {
const widthPercent = Math.max((tool.invocations / maxInvocations) * 100, 2);
return (
<div key={tool.name}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 4 }}>
<span style={{ fontWeight: 500, color: '#f0f2f7' }}>{tool.name}</span>
<span style={{ color: '#9ca3af', fontVariantNumeric: 'tabular-nums' }}>
{tool.invocations}
</span>
</div>
<div style={{
height: 8,
background: '#1a1d25',
borderRadius: 4,
overflow: 'hidden',
}}>
<div style={{
width: `${widthPercent}%`,
height: '100%',
background: '#e5a000',
borderRadius: 4,
transition: 'width 0.3s ease',
}} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function TopCommandsCard({ data }: { data: AnalyticsResponse['topCommands'] }) {
const maxCount = data.length > 0 ? Math.max(...data.map((c) => c.count)) : 1;
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Top Commands</h3>
{data.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: 13 }}>No command usage data yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((cmd) => {
const widthPercent = Math.max((cmd.count / maxCount) * 100, 2);
return (
<div key={cmd.name}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 4 }}>
<span style={{ fontWeight: 500, color: '#f0f2f7', fontFamily: 'monospace' }}>{cmd.name}</span>
<span style={{ color: '#9ca3af', fontVariantNumeric: 'tabular-nums' }}>{cmd.count}</span>
</div>
<div style={{
height: 8,
background: '#1a1d25',
borderRadius: 4,
overflow: 'hidden',
}}>
<div style={{
width: `${widthPercent}%`,
height: '100%',
background: '#a78bfa',
borderRadius: 4,
transition: 'width 0.3s ease',
}} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function CapabilityGapsCard({ data }: { data: AnalyticsResponse['capabilityGaps'] }) {
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Capability Gaps</h3>
{data.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: 13 }}>No capability gaps detected.</p>
) : (
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Capability gaps table" tabIndex={0}>
<table style={{ ...tableStyle, minWidth: 560 }}>
<thead>
<tr>
<th style={thStyle}>Tool</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Requests</th>
<th style={thStyle}>Suggestion</th>
</tr>
</thead>
<tbody>
{data.map((gap) => (
<tr key={gap.tool}>
<td style={tdStyle}>
<span style={{
padding: '2px 8px',
background: 'rgba(229, 160, 0, 0.15)',
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
color: '#e5a000',
}}>
{gap.tool}
</span>
</td>
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{gap.requestCount}
</td>
<td style={{ ...tdStyle, fontSize: 13, color: '#9ca3af' }}>{gap.suggestion}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function PerformanceTrendsCard({ data }: { data: AnalyticsResponse['performanceTrends'] }) {
const trendColor = data.correctionTrend < 0 ? '#10b981' : data.correctionTrend > 0 ? '#ef4444' : '#6b7280';
const trendArrow = data.correctionTrend < 0 ? 'v' : data.correctionTrend > 0 ? '^' : '-';
const trendLabel = data.correctionTrend < 0 ? 'improving' : data.correctionTrend > 0 ? 'worsening' : 'stable';
return (
<div style={cardStyle}>
<h3 style={sectionTitle}>Performance Trends</h3>
<div style={{ display: 'flex', gap: 32 }}>
<div>
<div style={{ fontSize: 12, color: '#9ca3af', marginBottom: 4 }}>Correction Rate</div>
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#e5a000' }}>
{(data.correctionRate * 100).toFixed(1)}%
</div>
<div style={{ fontSize: 12, color: trendColor, fontWeight: 500 }}>
{trendArrow} {Math.abs(data.correctionTrend * 100).toFixed(1)}% ({trendLabel})
</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#9ca3af', marginBottom: 4 }}>Avg Response Time</div>
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#e5a000' }}>
{data.avgResponseTime.toFixed(1)}s
</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>per completed job</div>
</div>
</div>
</div>
);
}
/* ─── Main page ─── */
export function Analytics({ token, teamSlug }: AnalyticsProps) {
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token || !teamSlug) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
setData(null);
const result = await api.getAnalytics(token, teamSlug);
if (!isAnalyticsResponse(result)) {
throw new Error('Analytics data is incomplete. Refresh or check the team server version.');
}
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) {
setData(null);
setError(getErrorMessage(err, 'Failed to load analytics'));
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [token, teamSlug]);
if (loading) {
return (
<div>
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Analytics</h1>
<p style={{ color: '#9ca3af' }}>Loading analytics...</p>
</div>
);
}
return (
<div>
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Usage Analytics</h1>
{error && (
<div role="alert" style={{
padding: '8px 12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: 4,
color: '#f87171',
marginBottom: 16,
fontSize: 13,
}}>
{error}
</div>
)}
{data && (
<>
{/* Row 1: Active Users + Performance Trends */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: 16,
marginTop: 16,
}}>
<ActiveUsersCard data={data.activeUsers} />
<PerformanceTrendsCard data={data.performanceTrends} />
</div>
{/* Row 2: Token Usage */}
<div style={{ marginTop: 24 }}>
<TokenUsageCard data={data.tokenUsage} />
</div>
{/* Row 3: Top Tools + Top Commands */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: 16,
marginTop: 24,
}}>
<TopToolsCard data={data.topTools} />
<TopCommandsCard data={data.topCommands} />
</div>
{/* Row 4: Capability Gaps */}
<div style={{ marginTop: 24 }}>
<CapabilityGapsCard data={data.capabilityGaps} />
</div>
</>
)}
</div>
);
}