fix(server): online and storage statistics (#14792)

#### PR Dependency Tree


* **PR #14792** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Admin dashboard returns more accurate sync and storage timelines with
carry‑forwarded minute buckets and corrected current totals.

* **Bug Fixes**
* Active-user flushes are debounced/scheduled to prevent overlapping
writes and reduce stale counts.
* Snapshot writes now retry and will skip gracefully when lock
contention prevents completion, avoiding partial snapshots.

* **Tests**
* New e2e tests cover carry‑forward behavior, no backfill outside
requested windows, and storage history accuracy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-04-07 02:08:13 +08:00
committed by GitHub
parent 193ec14ad3
commit 5806ad8a3a
4 changed files with 332 additions and 38 deletions
@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client';
import Sinon from 'sinon';
import { app, e2e, Mockers } from '../test';
@@ -389,6 +390,201 @@ e2e(
}
);
e2e(
'adminDashboard should carry forward missing sync and storage samples',
async t => {
const now = new Date('2026-04-05T08:55:00.000Z');
const clock = Sinon.useFakeTimers({ now, toFake: ['Date'] });
try {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const minute = new Date();
minute.setSeconds(0, 0);
const sampleStartMinute = new Date(minute.getTime() - 30 * 60 * 1000);
const sampleEndMinute = new Date(
sampleStartMinute.getTime() + 2 * 60 * 1000
);
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES
(${sampleStartMinute}, 5, NOW()),
(${sampleEndMinute}, 7, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
)
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, ARRAY[]::text[], NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
snapshot_size = EXCLUDED.snapshot_size,
blob_count = EXCLUDED.blob_count,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
const today = new Date();
const currentDay = new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate()
)
);
const twoDaysAgo = new Date(
currentDay.getTime() - 2 * 24 * 60 * 60 * 1000
);
await db.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
)
VALUES (${workspace.id}, ${twoDaysAgo}, 100, 50, 1, NOW())
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
const result = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
minute
activeUsers
}
workspaceStorageBytes
blobStorageBytes
workspaceStorageHistory {
date
value
}
blobStorageHistory {
date
value
}
}
}
`,
{
input: {
storageHistoryDays: 3,
syncHistoryHours: 2,
},
}
);
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncActiveUsers, 7);
const missingMinute = new Date(sampleStartMinute.getTime() + 60 * 1000);
t.is(
dashboard.syncActiveUsersTimeline.find(
(point: { minute: string }) =>
point.minute === missingMinute.toISOString()
)?.activeUsers,
5
);
const workspaceHistory = dashboard.workspaceStorageHistory.map(
(point: { value: number }) => point.value
);
const blobHistory = dashboard.blobStorageHistory.map(
(point: { value: number }) => point.value
);
t.is(workspaceHistory.length, 3);
t.is(blobHistory.length, 3);
t.is(workspaceHistory[0], workspaceHistory[1]);
t.is(blobHistory[0], blobHistory[1]);
t.is(
workspaceHistory[workspaceHistory.length - 1],
dashboard.workspaceStorageBytes
);
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes);
} finally {
clock.restore();
}
}
);
e2e(
'adminDashboard should not backfill sync samples older than the requested window',
async t => {
const now = new Date('2026-04-05T08:55:00.000Z');
const clock = Sinon.useFakeTimers({ now, toFake: ['Date'] });
try {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const staleMinute = new Date('2026-04-05T05:55:00.000Z');
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES (${staleMinute}, 9, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
const result = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
activeUsers
}
}
}
`,
{
input: {
syncHistoryHours: 1,
},
}
);
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncActiveUsers, 0);
t.true(
dashboard.syncActiveUsersTimeline.every(
(point: { activeUsers: number }) => point.activeUsers === 0
)
);
} finally {
clock.restore();
}
}
);
e2e(
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
async t => {