417 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|