Skip to content

Commit 988af23

Browse files
Feed and media analytics (#66)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 2a1f65a commit 988af23

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+5951
-15
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@
3636

3737
![Media Details](docs/screenshots/media-details.png)
3838

39+
## Analytics
40+
41+
MediaRSS includes built-in feed and media analytics in the admin dashboard:
42+
43+
- **Feed analytics** (per feed + per token):
44+
- RSS fetches
45+
- media requests
46+
- download starts
47+
- bytes served
48+
- unique clients
49+
- top media items
50+
- top clients
51+
- daily activity trends
52+
- **Media analytics** (aggregated across all feed tokens):
53+
- requests/download starts
54+
- bytes served
55+
- unique clients
56+
- per-feed/per-token breakdowns
57+
- top clients
58+
- daily activity trends
59+
- **Time windows**: switch between 7d / 30d / 90d directly in the UI
60+
3961
## Self-Hosting
4062

4163
### Prerequisites
@@ -225,6 +247,9 @@ The following environment variables can be configured:
225247
- Each media root has a name (used in URLs) and a path (filesystem location)
226248
- **Names must be unique** - duplicate names are not allowed
227249
- Names should be URL-safe (alphanumeric, hyphens, underscores)
250+
- `ANALYTICS_RETENTION_DAYS`: Number of days to retain feed analytics events
251+
(default: `180`)
252+
- Older analytics events are pruned automatically on startup
228253

229254
### Securing the Admin Dashboard
230255

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { colors, radius, spacing, typography } from '#app/styles/tokens.ts'
2+
3+
type DailyActivityPoint = {
4+
day: string
5+
mediaRequests: number
6+
}
7+
8+
export function AnalyticsDailyActivityChart() {
9+
return ({ daily }: { daily: Array<DailyActivityPoint> }) => {
10+
const visibleDaily = daily.slice(-14)
11+
const maxDailyRequests = Math.max(
12+
1,
13+
...visibleDaily.map((point) => point.mediaRequests),
14+
)
15+
16+
return (
17+
<div>
18+
<h4
19+
css={{
20+
fontSize: typography.fontSize.sm,
21+
fontWeight: typography.fontWeight.semibold,
22+
margin: `0 0 ${spacing.sm} 0`,
23+
color: colors.text,
24+
}}
25+
>
26+
Daily Activity
27+
</h4>
28+
{daily.length === 0 ? (
29+
<p
30+
css={{
31+
margin: 0,
32+
fontSize: typography.fontSize.sm,
33+
color: colors.textMuted,
34+
}}
35+
>
36+
No daily activity yet.
37+
</p>
38+
) : (
39+
<div
40+
css={{
41+
display: 'flex',
42+
flexDirection: 'column',
43+
gap: spacing.xs,
44+
}}
45+
>
46+
{visibleDaily.map((point) => (
47+
<div
48+
key={point.day}
49+
css={{
50+
display: 'grid',
51+
gridTemplateColumns: '68px 1fr 52px',
52+
alignItems: 'center',
53+
gap: spacing.sm,
54+
}}
55+
>
56+
<span
57+
css={{
58+
fontSize: typography.fontSize.xs,
59+
color: colors.textMuted,
60+
fontFamily: 'monospace',
61+
}}
62+
>
63+
{point.day.slice(5)}
64+
</span>
65+
<div
66+
css={{
67+
height: '8px',
68+
borderRadius: radius.sm,
69+
backgroundColor: colors.background,
70+
overflow: 'hidden',
71+
}}
72+
>
73+
<div
74+
css={{
75+
height: '100%',
76+
width: `${Math.max(2, (point.mediaRequests / maxDailyRequests) * 100)}%`,
77+
backgroundColor: colors.primary,
78+
}}
79+
/>
80+
</div>
81+
<span
82+
css={{
83+
fontSize: typography.fontSize.xs,
84+
color: colors.textMuted,
85+
textAlign: 'right',
86+
}}
87+
>
88+
{point.mediaRequests}
89+
</span>
90+
</div>
91+
))}
92+
</div>
93+
)}
94+
</div>
95+
)
96+
}
97+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { colors, radius, spacing, typography } from '#app/styles/tokens.ts'
2+
3+
export function AnalyticsMetricCard() {
4+
return ({ label, value }: { label: string; value: string }) => (
5+
<div
6+
css={{
7+
padding: spacing.sm,
8+
borderRadius: radius.md,
9+
border: `1px solid ${colors.border}`,
10+
backgroundColor: colors.background,
11+
}}
12+
>
13+
<div
14+
css={{
15+
fontSize: typography.fontSize.xs,
16+
color: colors.textMuted,
17+
textTransform: 'uppercase',
18+
letterSpacing: '0.05em',
19+
marginBottom: spacing.xs,
20+
}}
21+
>
22+
{label}
23+
</div>
24+
<div
25+
css={{
26+
fontSize: typography.fontSize.base,
27+
fontWeight: typography.fontWeight.semibold,
28+
color: colors.text,
29+
}}
30+
>
31+
{value}
32+
</div>
33+
</div>
34+
)
35+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { formatFileSize } from '#app/helpers/format.ts'
2+
import { colors, radius, spacing, typography } from '#app/styles/tokens.ts'
3+
4+
type TopClientSummary = {
5+
clientName: string
6+
downloadStarts: number
7+
mediaRequests: number
8+
uniqueClients: number
9+
bytesServed: number
10+
lastSeenAt: number | null
11+
}
12+
13+
export function AnalyticsTopClientsList() {
14+
return ({ clients }: { clients: Array<TopClientSummary> }) => (
15+
<div>
16+
<h4
17+
css={{
18+
fontSize: typography.fontSize.sm,
19+
fontWeight: typography.fontWeight.semibold,
20+
margin: `0 0 ${spacing.sm} 0`,
21+
color: colors.text,
22+
}}
23+
>
24+
Top Clients
25+
</h4>
26+
{clients.length === 0 ? (
27+
<p
28+
css={{
29+
margin: 0,
30+
fontSize: typography.fontSize.sm,
31+
color: colors.textMuted,
32+
}}
33+
>
34+
No client analytics yet.
35+
</p>
36+
) : (
37+
<ul
38+
css={{
39+
listStyle: 'none',
40+
padding: 0,
41+
margin: 0,
42+
display: 'flex',
43+
flexDirection: 'column',
44+
gap: spacing.sm,
45+
}}
46+
>
47+
{clients.slice(0, 8).map((client, index) => (
48+
<li
49+
key={`${client.clientName}-${client.lastSeenAt ?? 0}-${index}`}
50+
css={{
51+
padding: spacing.sm,
52+
borderRadius: radius.md,
53+
border: `1px solid ${colors.border}`,
54+
backgroundColor: colors.background,
55+
}}
56+
>
57+
<div
58+
css={{
59+
fontSize: typography.fontSize.sm,
60+
fontWeight: typography.fontWeight.medium,
61+
color: colors.text,
62+
}}
63+
>
64+
{client.clientName}
65+
</div>
66+
<div
67+
css={{
68+
marginTop: spacing.xs,
69+
fontSize: typography.fontSize.xs,
70+
color: colors.textMuted,
71+
display: 'flex',
72+
gap: spacing.sm,
73+
flexWrap: 'wrap',
74+
}}
75+
>
76+
<span>{client.downloadStarts} starts</span>
77+
<span>{client.mediaRequests} requests</span>
78+
<span>{client.uniqueClients} clients</span>
79+
<span>{formatFileSize(client.bytesServed)}</span>
80+
</div>
81+
</li>
82+
))}
83+
</ul>
84+
)}
85+
</div>
86+
)
87+
}

0 commit comments

Comments
 (0)