openvibers/openvibe.community Commits
Pastes: daily stat series for the over-time chart (#50)
Changes
@@@ -118,6 +118,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = nrouter.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));router.get('/admin/stats', staffOnly, run(async () => await service.stats()));++ router.get('/admin/stats/series', staffOnly, run(async (req) => await service.statSeries(req.query)));router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));@@@ -645,6 +645,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim// ── staff (api.js has already checked the viewer is staff) ──async stats() { return { stats: await store.stats(db) }; },++ /** Daily counts behind the paste total (Live's "over time" chart; Media's frozen copy stopped at the move). */++ async statSeries(q = {}) { return await store.statSeries(db, intIn(q.days, 30, 1, 365)); },++async forks(q = {}) {const limit = intIn(q.limit, 100, 1, 500);const offset = Math.max(parseInt(q.offset, 10) || 0, 0);@@@ -337,6 +337,27 @@ async function stats(db) {};}++/**++ * Pastes made per UTC day over the last `days` days (deleted ones left out, as in stats()), for "over time" charts:++ * { metric, days, points: [{ day, value }], total, before (made before the window), prev_total (the window before) }.++ */++async function statSeries(db, days) {++ const now = Date.now();++ const dayOf = (i) => new Date(now - i * 86400000).toISOString().slice(0, 10);++ const since = dayOf(days - 1);++ const rows = await db.prepare(`SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS n FROM pastes++ WHERE deleted_at IS NULL AND created_at >= ? GROUP BY day`).all(since);++ const byDay = new Map(rows.map((r) => [r.day, Number(r.n) || 0]));++ const points = [];++ for (let i = days - 1; i >= 0; i--) points.push({ day: dayOf(i), value: byDay.get(dayOf(i)) || 0 });++ const count = async (sql, ...params) => Number((await db.prepare(sql).get(...params)).n) || 0;++ return {++ metric: 'pastes', days, points, total: points.reduce((n, p) => n + p.value, 0),++ before: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at < ?', since),++ prev_total: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?', dayOf(2 * days - 1), since),++ };++}++async function listForks(db, limit, offset) {const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_atFROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL@@@ -403,7 +424,7 @@ module.exports = {generateSlug, sanitizeTitle, detectLanguage,getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,-- setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,++ setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,getProjections, upsertProjection, subjectsByUsername,mapGet, mapSet,SLUG_ADJECTIVES, SLUG_NOUNS,@@@ -155,6 +155,14 @@ const { boot, check, done } = require('./helpers/app');assert.ok(bulkAdmin.json().stats.total > 0);const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });assert.strictEqual(bulkSvc.status, 403);++ const series = await call('/api/pastes/admin/stats/series?days=7', { cookie: adminJwt });++ assert.strictEqual(series.status, 200, series.text);++ const sj = series.json();++ assert.deepStrictEqual([sj.metric, sj.days, sj.points.length], ['pastes', 7, 7]);++ assert.strictEqual(sj.points[6].day, new Date().toISOString().slice(0, 10), 'the last point is today (UTC)');++ assert.ok(sj.points[6].value > 0 && sj.total >= sj.points[6].value, 'pastes made today are counted');++ assert.strictEqual(sj.total + sj.before, bulkAdmin.json().stats.total, 'the window and what came before add up to the total');++ assert.strictEqual((await call('/api/pastes/admin/stats/series', { token: svc([WRITE]) })).status, 403);});await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {
@@ -118,6 +118,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -118,6 +118,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
router.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));
router.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));
router.get('/admin/stats', staffOnly, run(async () => await service.stats()));
router.get('/admin/stats', staffOnly, run(async () => await service.stats()));
+ router.get('/admin/stats/series', staffOnly, run(async (req) => await service.statSeries(req.query)));
router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));
router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));
router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));
router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));
router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));
router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));
@@ -645,6 +645,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -645,6 +645,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// ── staff (api.js has already checked the viewer is staff) ──
// ── staff (api.js has already checked the viewer is staff) ──
async stats() { return { stats: await store.stats(db) }; },
async stats() { return { stats: await store.stats(db) }; },
+ /** Daily counts behind the paste total (Live's "over time" chart; Media's frozen copy stopped at the move). */
+ async statSeries(q = {}) { return await store.statSeries(db, intIn(q.days, 30, 1, 365)); },
+
async forks(q = {}) {
async forks(q = {}) {
const limit = intIn(q.limit, 100, 1, 500);
const limit = intIn(q.limit, 100, 1, 500);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
@@ -337,6 +337,27 @@ async function stats(db) {
@@ -337,6 +337,27 @@ async function stats(db) {
};
};
}
}
+/**
+ * Pastes made per UTC day over the last `days` days (deleted ones left out, as in stats()), for "over time" charts:
+ * { metric, days, points: [{ day, value }], total, before (made before the window), prev_total (the window before) }.
+ */
+async function statSeries(db, days) {
+ const now = Date.now();
+ const dayOf = (i) => new Date(now - i * 86400000).toISOString().slice(0, 10);
+ const since = dayOf(days - 1);
+ const rows = await db.prepare(`SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS n FROM pastes
+ WHERE deleted_at IS NULL AND created_at >= ? GROUP BY day`).all(since);
+ const byDay = new Map(rows.map((r) => [r.day, Number(r.n) || 0]));
+ const points = [];
+ for (let i = days - 1; i >= 0; i--) points.push({ day: dayOf(i), value: byDay.get(dayOf(i)) || 0 });
+ const count = async (sql, ...params) => Number((await db.prepare(sql).get(...params)).n) || 0;
+ return {
+ metric: 'pastes', days, points, total: points.reduce((n, p) => n + p.value, 0),
+ before: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at < ?', since),
+ prev_total: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?', dayOf(2 * days - 1), since),
+ };
+}
+
async function listForks(db, limit, offset) {
async function listForks(db, limit, offset) {
const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
@@ -403,7 +424,7 @@ module.exports = {
@@ -403,7 +424,7 @@ module.exports = {
generateSlug, sanitizeTitle, detectLanguage,
generateSlug, sanitizeTitle, detectLanguage,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
- setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
+ setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
getProjections, upsertProjection, subjectsByUsername,
mapGet, mapSet,
mapGet, mapSet,
SLUG_ADJECTIVES, SLUG_NOUNS,
SLUG_ADJECTIVES, SLUG_NOUNS,
@@ -155,6 +155,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -155,6 +155,14 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(bulkAdmin.json().stats.total > 0);
assert.ok(bulkAdmin.json().stats.total > 0);
const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });
const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });
assert.strictEqual(bulkSvc.status, 403);
assert.strictEqual(bulkSvc.status, 403);
+ const series = await call('/api/pastes/admin/stats/series?days=7', { cookie: adminJwt });
+ assert.strictEqual(series.status, 200, series.text);
+ const sj = series.json();
+ assert.deepStrictEqual([sj.metric, sj.days, sj.points.length], ['pastes', 7, 7]);
+ assert.strictEqual(sj.points[6].day, new Date().toISOString().slice(0, 10), 'the last point is today (UTC)');
+ assert.ok(sj.points[6].value > 0 && sj.total >= sj.points[6].value, 'pastes made today are counted');
+ assert.strictEqual(sj.total + sj.before, bulkAdmin.json().stats.total, 'the window and what came before add up to the total');
+ assert.strictEqual((await call('/api/pastes/admin/stats/series', { token: svc([WRITE]) })).status, 403);
});
});
await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {
await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {