Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/analytics/detail/page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { statReasonTypes } from '@/reports/helpers/getType'
import { HistoricalGraph } from '@/reports/stats/HistoricalGraph'
import { LiveStatsPanel } from '@/reports/stats/LiveStats'
import { LiveStatsCards } from '@/reports/stats/LiveStats'
import {
StatsFilters,
useParamStatsFilters,
Expand Down Expand Up @@ -66,7 +66,7 @@ export function StatsDetailPageContent() {

<StatsFilters value={filters} onChange={handleFilterChange} />

<LiveStatsPanel params={live} />
<LiveStatsCards params={live} />

<div className="rounded-lg shadow bg-white dark:bg-slate-800 p-4 dark:shadow-slate-700">
<HistoricalGraph
Expand Down
7 changes: 7 additions & 0 deletions app/analytics/page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LiveStatsCards } from '@/reports/stats/LiveStats'
import { StatsCard } from '@/reports/stats/Stats'
import { useMemo } from 'react'
import { useTitle } from 'react-use'
import Link from 'next/link'

export function AnalyticsPageContent() {
useTitle('Analytics')
Expand All @@ -24,6 +25,12 @@ export function AnalyticsPageContent() {

<div className="mb-6">
<LiveStatsCards />
<Link
href="/analytics/detail?grouping=aggregate"
className="mt-3 inline-block text-sm text-blue-600 hover:underline dark:text-blue-400"
>
View aggregate details
</Link>
</div>

{queues.length > 0 && (
Expand Down
143 changes: 103 additions & 40 deletions components/reports/stats/HistoricalGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ import {
ResponsiveContainer,
} from 'recharts'
import { format } from 'date-fns'
import { ToolsOzoneReportDefs } from '@atproto/api'
import { isDarkModeEnabled } from '@/common/useColorScheme'
import { formatDuration } from '@/lib/util'
import { StatCard } from './Stats'
import type { HistoricalReportStats } from './useReportStats'

const SERIES = [
{ key: 'inboundCount', name: 'Inbound', color: '#3b82f6' },
{ key: 'pendingCount', name: 'Pending', color: '#eab308' },
{ key: 'pendingCount', name: 'Pending (snapshot)', color: '#eab308' },
{ key: 'escalatedCount', name: 'Escalated', color: '#ef4444' },
{ key: 'closedCount', name: 'Closed', color: '#64748b' },
{ key: 'actionedCount', name: 'Actioned', color: '#22c55e' },
{ key: 'acknowledgedCount', name: 'Acknowledged', color: '#06b6d4' },
] as const

export function HistoricalGraph({
Expand All @@ -26,7 +30,7 @@ export function HistoricalGraph({
isError,
onRetry,
}: {
stats?: ToolsOzoneReportDefs.HistoricalStats[]
stats?: HistoricalReportStats[]
isLoading: boolean
isError?: boolean
onRetry?: () => void
Expand Down Expand Up @@ -70,53 +74,112 @@ export function HistoricalGraph({
date: format(new Date(s.date), 'MMM d'),
inboundCount: s.inboundCount,
actionedCount: s.actionedCount,
closedCount: s.closedCount,
acknowledgedCount: s.acknowledgedCount,
pendingCount: s.pendingCount,
escalatedCount: s.escalatedCount,
}))

const sum = (key: keyof HistoricalReportStats) =>
stats.reduce((total, stat) => total + Number(stat[key] ?? 0), 0)
const closedCount = sum('closedCount')
const actionedCount = sum('actionedCount')
const ahtSampleCount = sum('ahtSampleCount')
const ahtDurationSec = sum('ahtDurationSec')
const resolutionSampleCount = sum('resolutionSampleCount')
const resolutionDurationSec = sum('resolutionDurationSec')
const latestPending = [...stats]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.find((stat) => stat.pendingCount != null)?.pendingCount

const dark = isDarkModeEnabled()
const axisColor = dark ? '#9ca3af' : '#6b7280'
const gridColor = dark ? '#374151' : '#e5e7eb'

return (
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis
dataKey="date"
tick={{ fontSize: 12, fill: axisColor }}
tickLine={{ stroke: axisColor }}
/>
<YAxis
tick={{ fontSize: 12, fill: axisColor }}
tickLine={{ stroke: axisColor }}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? '#1e293b' : '#ffffff',
borderColor: dark ? '#475569' : '#e5e7eb',
color: dark ? '#e2e8f0' : '#1f2937',
}}
/>
<Legend />
{SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
<div className="w-full space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard
label="Inbound"
value={sum('inboundCount')}
classNamePreset="inbound"
/>
<StatCard
label="Latest Pending"
value={latestPending}
classNamePreset="pending"
/>
<StatCard label="Closed" value={closedCount} classNamePreset="closed" />
<StatCard
label="Actioned"
value={actionedCount}
suffix={
closedCount > 0
? `${Math.round((actionedCount / closedCount) * 100)}%`
: undefined
}
classNamePreset="actioned"
/>
<StatCard
label="AHT"
value={
ahtSampleCount > 0
? formatDuration(Math.round(ahtDurationSec / ahtSampleCount))
: undefined
}
classNamePreset="avgHandlingTime"
/>
<StatCard
label="Resolution Time"
value={
resolutionSampleCount > 0
? formatDuration(
Math.round(resolutionDurationSec / resolutionSampleCount),
)
: undefined
}
classNamePreset="avgHandlingTime"
/>
</div>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis
dataKey="date"
tick={{ fontSize: 12, fill: axisColor }}
tickLine={{ stroke: axisColor }}
/>
))}
</LineChart>
</ResponsiveContainer>
<YAxis
tick={{ fontSize: 12, fill: axisColor }}
tickLine={{ stroke: axisColor }}
/>
<Tooltip
contentStyle={{
backgroundColor: dark ? '#1e293b' : '#ffffff',
borderColor: dark ? '#475569' : '#e5e7eb',
color: dark ? '#e2e8f0' : '#1f2937',
}}
/>
<Legend />
{SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
29 changes: 28 additions & 1 deletion components/reports/stats/LiveStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function LiveStatsCards({ params }: { params?: LiveStatsParams }) {

return (
<div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-3">
<StatCard
label="Inbound"
value={stats.inboundCount}
Expand All @@ -89,20 +89,47 @@ export function LiveStatsCards({ params }: { params?: LiveStatsParams }) {
value={stats.escalatedCount}
classNamePreset="escalated"
/>
<StatCard
label="Closed"
value={stats.closedCount}
classNamePreset="closed"
/>
<StatCard
label="Actioned"
value={stats.actionedCount}
suffix={stats.actionRate != null ? `${stats.actionRate}%` : undefined}
classNamePreset="actioned"
/>
<StatCard
label="Acknowledged"
value={stats.acknowledgedCount}
classNamePreset="acknowledged"
/>
{stats.avgHandlingTimeSec != null && (
<StatCard
label="Avg Handling Time"
value={formatDuration(stats.avgHandlingTimeSec)}
classNamePreset="avgHandlingTime"
/>
)}
{stats.avgResolutionTimeSec != null && (
<StatCard
label="Avg Resolution Time"
value={formatDuration(stats.avgResolutionTimeSec)}
classNamePreset="avgHandlingTime"
/>
)}
</div>
{(stats.labelActionCount != null ||
stats.tagActionCount != null ||
stats.takedownActionCount != null) && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span>Actions:</span>
<span>Labels {stats.labelActionCount ?? 0}</span>
<span>Tags {stats.tagActionCount ?? 0}</span>
<span>Takedowns {stats.takedownActionCount ?? 0}</span>
</div>
)}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Updated {new Date(stats.lastUpdated).toLocaleTimeString()}
</p>
Expand Down
39 changes: 33 additions & 6 deletions components/reports/stats/Stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const STATS_PRESETS = {
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
actioned:
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
closed:
'bg-slate-100 text-slate-800 dark:bg-slate-700/50 dark:text-slate-300',
acknowledged:
'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300',
avgHandlingTime:
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
}
Expand Down Expand Up @@ -57,18 +61,31 @@ export function StatValue({
}

export interface ReportStats {
/** Number of reports in 'open' status */
/** Current number of reports that are not closed. */
pendingCount?: number
/** Number of reports in 'closed' status */
/** Close transitions linked to a label, tag, or takedown event. */
actionedCount?: number
/** Number of reports in 'escalated' status */
/** Number of close transitions */
closedCount?: number
/** Number of closures without a linked enforcement action */
acknowledgedCount?: number
/** Escalation transitions in the current UTC day. */
escalatedCount?: number
/** Reports received in this queue in the last 24 hours. */
/** Reports created in the current UTC day. */
inboundCount?: number
/** Percentage of reports actioned (actionedCount / inboundCount * 100), rounded to nearest integer. Absent when inboundCount is 0. */
/** Percentage of closures actioned, rounded to the nearest integer. */
actionRate?: number
/** Average time in seconds from report creation to close, for reports closed in this period. */
/** Average time in seconds from report assignment to close. */
avgHandlingTimeSec?: number
/** Average time in seconds from report creation to close. */
avgResolutionTimeSec?: number
labelActionCount?: number
tagActionCount?: number
takedownActionCount?: number
ahtDurationSec?: number
ahtSampleCount?: number
resolutionDurationSec?: number
resolutionSampleCount?: number
/** When these statistics were last computed */
lastUpdated?: string
}
Expand Down Expand Up @@ -105,6 +122,11 @@ export function StatValues({
value={stats.escalatedCount}
classNamePreset="escalated"
/>
<StatValue
label="Closed"
value={stats.closedCount}
classNamePreset="closed"
/>
<StatValue
label="Actioned"
value={stats.actionedCount}
Expand All @@ -113,6 +135,11 @@ export function StatValues({
stats.actionRate != null ? ` (${stats.actionRate}%)` : undefined
}
/>
<StatValue
label="Acknowledged"
value={stats.acknowledgedCount}
classNamePreset="acknowledged"
/>
</div>
{windowHours && (
<div className="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
Expand Down
11 changes: 8 additions & 3 deletions components/reports/stats/useReportStats.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useLabelerAgent } from '@/shell/ConfigurationContext'
import { ToolsOzoneReportDefs } from '@atproto/api'
import { useQuery } from '@tanstack/react-query'
import type { ReportStats } from './Stats'

export type HistoricalReportStats = ReportStats & {
date: string
computedAt?: string
}

export type LiveStatsParams = {
queueId?: number
Expand All @@ -27,7 +32,7 @@ export const useLiveStats = (params?: LiveStatsParams) => {
const { data } = await labelerAgent.tools.ozone.report.getLiveStats(
params ?? {},
)
return data.stats
return data.stats as ReportStats
},
refetchInterval: 5 * 60 * 1000,
})
Expand All @@ -43,7 +48,7 @@ export const useHistoricalStats = (params?: HistoricalStatsParams) => {
params ?? {},
)
return {
stats: data.stats as ToolsOzoneReportDefs.HistoricalStats[],
stats: data.stats as unknown as HistoricalReportStats[],
cursor: data.cursor,
}
},
Expand Down
Loading