Skip to content

Commit 2711a82

Browse files
committed
feat: admin stat endpoints
1 parent bf7e9d8 commit 2711a82

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

src/routes/v1/admin.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Hono } from 'hono';
2+
import type { AppContext } from '@/types/app.js';
3+
import { prisma } from '@/db.js';
4+
5+
const admin = new Hono<AppContext>();
6+
7+
admin.use('*', async (c, next) => {
8+
if (!c.get('authenticated')) {
9+
return c.json({ error: 'Unauthorized' }, 401);
10+
}
11+
await next();
12+
});
13+
14+
admin.get('/telemetry/system-info', async (c) => {
15+
const limit = Math.min(Number(c.req.query('limit') || 100), 500);
16+
const offset = Number(c.req.query('offset') || 0);
17+
18+
const rows = await prisma.telemetrySystemInfo.findMany({
19+
orderBy: { createdAt: 'desc' },
20+
take: limit,
21+
skip: offset,
22+
});
23+
24+
return c.json({
25+
data: rows.map((r) => ({
26+
...r,
27+
desktops: JSON.parse(r.desktops),
28+
screens: JSON.parse(r.screens),
29+
})),
30+
limit,
31+
offset,
32+
});
33+
});
34+
35+
const GRANULARITIES = ['daily', 'weekly', 'monthly', 'yearly'] as const;
36+
type Granularity = typeof GRANULARITIES[number];
37+
38+
function mondayOfWeek(date: Date): string {
39+
const d = new Date(date);
40+
const day = d.getUTCDay();
41+
const diff = day === 0 ? -6 : 1 - day;
42+
d.setUTCDate(d.getUTCDate() + diff);
43+
return d.toISOString().slice(0, 10);
44+
}
45+
46+
function bucketKey(date: Date, granularity: Granularity): string {
47+
const iso = date.toISOString();
48+
switch (granularity) {
49+
case 'daily': return iso.slice(0, 10);
50+
case 'weekly': return mondayOfWeek(date);
51+
case 'monthly': return iso.slice(0, 7) + '-01';
52+
case 'yearly': return iso.slice(0, 4) + '-01-01';
53+
}
54+
}
55+
56+
admin.get('/telemetry/system-info/stats', async (c) => {
57+
const granularity = (c.req.query('granularity') || 'daily') as Granularity;
58+
if (!GRANULARITIES.includes(granularity)) {
59+
return c.json({ error: `Invalid granularity. Must be one of: ${GRANULARITIES.join(', ')}` }, 400);
60+
}
61+
62+
const periods = Math.min(Number(c.req.query('periods') || 30), 365);
63+
const since = new Date();
64+
switch (granularity) {
65+
case 'daily': since.setDate(since.getDate() - periods); break;
66+
case 'weekly': since.setDate(since.getDate() - periods * 7); break;
67+
case 'monthly': since.setMonth(since.getMonth() - periods); break;
68+
case 'yearly': since.setFullYear(since.getFullYear() - periods); break;
69+
}
70+
71+
const rows = await prisma.telemetrySystemInfo.findMany({
72+
where: { date: { gte: since } },
73+
orderBy: { date: 'desc' },
74+
});
75+
76+
const buckets = new Map<string, {
77+
activeUsers: Set<string>;
78+
operatingSystems: Map<string, number>;
79+
architectures: Map<string, number>;
80+
versions: Map<string, number>;
81+
displayProtocols: Map<string, number>;
82+
chassisTypes: Map<string, number>;
83+
}>();
84+
85+
for (const row of rows) {
86+
const key = bucketKey(row.date, granularity);
87+
if (!buckets.has(key)) {
88+
buckets.set(key, {
89+
activeUsers: new Set(),
90+
operatingSystems: new Map(),
91+
architectures: new Map(),
92+
versions: new Map(),
93+
displayProtocols: new Map(),
94+
chassisTypes: new Map(),
95+
});
96+
}
97+
const bucket = buckets.get(key)!;
98+
bucket.activeUsers.add(row.userId);
99+
bucket.operatingSystems.set(row.operatingSystem, (bucket.operatingSystems.get(row.operatingSystem) || 0) + 1);
100+
bucket.architectures.set(row.architecture, (bucket.architectures.get(row.architecture) || 0) + 1);
101+
bucket.versions.set(row.vicinaeVersion, (bucket.versions.get(row.vicinaeVersion) || 0) + 1);
102+
bucket.displayProtocols.set(row.displayProtocol, (bucket.displayProtocols.get(row.displayProtocol) || 0) + 1);
103+
bucket.chassisTypes.set(row.chassisType, (bucket.chassisTypes.get(row.chassisType) || 0) + 1);
104+
}
105+
106+
const data = [...buckets.entries()].map(([period, bucket]) => ({
107+
period,
108+
activeUsers: bucket.activeUsers.size,
109+
operatingSystems: Object.fromEntries(bucket.operatingSystems),
110+
architectures: Object.fromEntries(bucket.architectures),
111+
versions: Object.fromEntries(bucket.versions),
112+
displayProtocols: Object.fromEntries(bucket.displayProtocols),
113+
chassisTypes: Object.fromEntries(bucket.chassisTypes),
114+
}));
115+
116+
return c.json({ data, granularity, periods });
117+
});
118+
119+
export default admin;

src/routes/v1/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AppContext } from '@/types/app.js';
44
import storeRouter from './store.js'
55
import raycast from './raycast.js';
66
import telemetry from './telemetry.js';
7+
import admin from './admin.js';
78
import localStorageRouter from '@/routes/storage.js'
89

910
const storage = createStorageFromEnv();
@@ -20,6 +21,7 @@ v1.use('*', async (c, next) => {
2021
v1.route('/store', storeRouter)
2122
v1.route('/raycast', raycast)
2223
v1.route('/telemetry', telemetry)
24+
v1.route('/admin', admin)
2325

2426
if (storage instanceof LocalStorageAdapter) {
2527
v1.route('/', localStorageRouter);

src/routes/v1/telemetry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ telemetry.post(
1616
}),
1717
zValidator('json', systemInfoSchema),
1818
async (c) => {
19+
const ua = c.req.header('User-Agent') || '';
20+
if (!ua.toLowerCase().startsWith('vicinae')) {
21+
return c.json({ error: 'Forbidden' }, 403);
22+
}
23+
1924
const data = c.req.valid('json');
2025

2126
const today = new Date().toISOString().split('T')[0];

0 commit comments

Comments
 (0)