feat(stats): voice-time and message leaderboards per space
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s

Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.

Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at
the seven call sites that reach them, so no path can be missed, and
destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise
leak open rows.

A restart leaves sessions open with an unknowable end time. They are closed at
startedAt, discarding that time rather than inventing it: crediting the gap
would hand someone hours they never spent, and the numbers are the point.
Mirrors the existing users.status sweep on boot.

Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
This commit is contained in:
2026-08-31 13:29:59 -03:00
parent bbb190cbda
commit 1830051732
13 changed files with 4642 additions and 1 deletions
@@ -0,0 +1,14 @@
CREATE TABLE `voice_sessions` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text,
`channel_id` text NOT NULL,
`user_id` text NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
File diff suppressed because it is too large Load Diff
@@ -99,6 +99,13 @@
"when": 1788193162813,
"tag": "0013_fancy_betty_brant",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788193659704,
"tag": "0014_mean_killer_shrike",
"breakpoints": true
}
]
}
+23
View File
@@ -610,3 +610,26 @@ export const auditEvents = sqliteTable('audit_events', {
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
actorIdx: index('idx_audit_events_actor').on(table.actorId),
}));
/**
* One row per stay in a voice room, closed when the user leaves.
*
* Separate from `auditEvents` on purpose: that table records points in time,
* while a call is an interval. Storing joins and leaves as separate point
* events would make every statistics query pair rows by hand and guess at
* joins whose leave never arrived (a crash, a restart).
*
* `endedAt` null means still connected. `spaceId` is null for DM calls.
*/
export const voiceSessions = sqliteTable('voice_sessions', {
id: text('id').primaryKey(),
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
channelId: text('channel_id').notNull(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
startedAt: integer('started_at').notNull(),
endedAt: integer('ended_at'),
}, (table) => ({
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
userIdx: index('idx_voice_sessions_user').on(table.userId),
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
}));
+7
View File
@@ -17,6 +17,8 @@ import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js';
import { spotifyRoutes } from './routes/spotify.js';
import { auditRoutes } from './routes/audit.js';
import { statsRoutes } from './routes/stats.js';
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js';
@@ -111,6 +113,10 @@ async function main(): Promise<void> {
// Initialize database
getDb();
// A restart leaves voice sessions open with no way to know when they really
// ended. Sweep them before anything can read the statistics.
closeOrphanedVoiceSessions();
// Reset orphaned `users.status` rows for locally-homed users. The previous
// process's in-memory disconnect timers are gone, so any non-offline row
// is stale by construction. Replicated (federated) rows are skipped — their
@@ -130,6 +136,7 @@ async function main(): Promise<void> {
await app.register(livekitRoutes);
await app.register(spotifyRoutes);
await app.register(auditRoutes);
await app.register(statsRoutes);
await app.register(socialRoutes);
await app.register(settingsRoutes);
await app.register(utilRoutes);
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance } from 'fastify';
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { isMember } from '../utils/permissions.js';
/** Windows the UI offers. Anything else is clamped into this range. */
const DEFAULT_DAYS = 30;
const MAX_DAYS = 365;
interface Leader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export async function statsRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
'/api/spaces/:id/stats',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Any member may look: these are the group's own numbers, not moderation
// data. The audit log, which names who did what, stays admin-only.
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
// Voice time. Only closed sessions count: an open one has no duration
// yet, and counting "now - startedAt" would make the numbers move every
// time the page is refreshed.
const voiceRows = db.select({
userId: schema.voiceSessions.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
})
.from(schema.voiceSessions)
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
.where(and(
eq(schema.voiceSessions.spaceId, id),
gte(schema.voiceSessions.startedAt, since),
isNotNull(schema.voiceSessions.endedAt),
))
.groupBy(schema.voiceSessions.userId)
.all() as Leader[];
// Messages. Scoped through the space's channels — the messages table has
// no space column.
const channelIds = db.select({ id: schema.channels.id })
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all()
.map((c) => c.id);
const messageRows = channelIds.length === 0 ? [] : db.select({
userId: schema.messages.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`count(*)`,
})
.from(schema.messages)
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
.where(and(
inArray(schema.messages.channelId, channelIds),
gte(schema.messages.createdAt, since),
))
.groupBy(schema.messages.userId)
.all() as Leader[];
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
return reply.code(200).send({
days,
since,
voice: voiceRows.sort(byValueDesc),
messages: messageRows.sort(byValueDesc),
totals: {
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
},
});
},
);
}
@@ -0,0 +1,65 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
/**
* Opens a session when someone joins a voice room.
*
* Never throws: statistics must not be able to break a call. Closes any
* dangling session for the same user first — the one-room-per-user invariant
* means a second open row would be a bookkeeping error, not two real calls.
*/
export function openVoiceSession(input: {
spaceId: string | null;
channelId: string;
userId: string;
}): void {
try {
const db = getDb();
closeVoiceSession(input.userId);
db.insert(schema.voiceSessions).values({
id: generateSnowflake(),
spaceId: input.spaceId,
channelId: input.channelId,
userId: input.userId,
startedAt: Date.now(),
endedAt: null,
}).run();
} catch (err) {
console.warn('[voice-sessions] failed to open session', err);
}
}
/** Closes the user's open session, if any. Never throws. */
export function closeVoiceSession(userId: string): void {
try {
getDb().update(schema.voiceSessions)
.set({ endedAt: Date.now() })
.where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt)))
.run();
} catch (err) {
console.warn('[voice-sessions] failed to close session', err);
}
}
/**
* Closes sessions left open by a crash or restart.
*
* Their real end time is unknowable. Ending them at `startedAt` — a zero-length
* session — discards that time rather than inventing it: crediting the gap
* would silently hand someone hours they never spent, and the numbers are the
* entire point of keeping this table.
*/
export function closeOrphanedVoiceSessions(): void {
try {
const result = getDb().update(schema.voiceSessions)
.set({ endedAt: sql`${schema.voiceSessions.startedAt}` })
.where(isNull(schema.voiceSessions.endedAt))
.run();
if (result.changes > 0) {
console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`);
}
} catch (err) {
console.warn('[voice-sessions] failed to close orphans', err);
}
}
+16
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
import { getDb, schema } from '../db/index.js';
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
@@ -714,6 +715,16 @@ class ConnectionManager {
room.participants.add(userId);
this.userToRoom.set(userId, roomId);
// Recorded here rather than at the seven call sites that lead into voice:
// every path — join, move, DM call, reconnect — funnels through this
// method, so hooking it cannot miss one.
openVoiceSession({
spaceId: room.roomType === 'space' ? (room.metadata as SpaceRoomMeta).spaceId : null,
channelId: roomId,
userId,
});
return room;
}
@@ -746,6 +757,8 @@ class ConnectionManager {
const room = this.leaveRoom(roomId, userId);
if (!room) return null;
closeVoiceSession(userId);
return { roomId, room };
}
@@ -757,6 +770,9 @@ class ConnectionManager {
const displaced: string[] = [];
for (const userId of room.participants) {
this.userToRoom.delete(userId);
// Destroying a room bypasses leaveCurrentRoom, so these sessions would
// otherwise stay open until the next restart swept them away.
closeVoiceSession(userId);
displaced.push(userId);
}
+25
View File
@@ -74,6 +74,22 @@ import type {
Activity,
} from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
export interface StatsLeader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export interface SpaceStats {
days: number;
since: number;
voice: StatsLeader[];
messages: StatsLeader[];
totals: { voiceMs: number; messages: number };
}
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
@@ -286,6 +302,10 @@ export class BackspaceApiClient {
removeFavorite: (id: string) => Promise<void>;
};
readonly stats: {
space: (spaceId: string, days: number) => Promise<SpaceStats>;
};
readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
};
@@ -704,6 +724,11 @@ export class BackspaceApiClient {
},
};
this.stats = {
space: (spaceId: string, days: number) =>
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
};
this.audit = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
@@ -8,6 +8,7 @@ import { api } from '../../api/client';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
import { useT } from '../../i18n';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
@@ -269,7 +270,7 @@ export function SpaceSettingsModal() {
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const t = useT();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
const isOpen = activeModal === 'spaceSettings';
@@ -337,6 +338,7 @@ export function SpaceSettingsModal() {
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
</div>
</div>
@@ -375,6 +377,7 @@ export function SpaceSettingsModal() {
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
</div>
</div>
)}
@@ -402,6 +405,7 @@ export function SpaceSettingsModal() {
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
</div>
</div>
)}
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { api, type SpaceStats, type StatsLeader } from '../../../api/client';
import { Avatar } from '../../ui/Avatar';
import { useT, type TranslationKey } from '../../../i18n';
interface StatsPanelProps {
spaceId: string;
}
const RANGES: { days: number; key: TranslationKey }[] = [
{ days: 7, key: 'stats.range.7' },
{ days: 30, key: 'stats.range.30' },
{ days: 365, key: 'stats.range.365' },
];
function Leaderboard({
title,
rows,
format,
}: {
title: string;
rows: StatsLeader[];
format: (value: number) => string;
}) {
// The bar is relative to the leader, not to the total: with five people the
// share of a total is tiny and every bar looks the same.
const max = rows.length > 0 ? Math.max(...rows.map((r) => r.value)) : 0;
return (
<div>
<h3 className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary mb-2">{title}</h3>
<ul className="space-y-1.5">
{rows.map((row) => (
<li key={row.userId} className="flex items-center gap-3">
<Avatar src={row.avatar} name={row.displayName ?? row.username} size={26} userId={row.userId} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[13px] text-txt-secondary truncate">
{row.displayName ?? row.username}
</span>
<span className="text-[12px] text-txt-tertiary tabular-nums flex-shrink-0">
{format(row.value)}
</span>
</div>
<div className="h-[3px] rounded-full bg-interactive-muted mt-1 overflow-hidden">
<div
className="h-full bg-accent-primary rounded-full"
style={{ width: max > 0 ? `${(row.value / max) * 100}%` : '0%' }}
/>
</div>
</div>
</li>
))}
</ul>
</div>
);
}
export function StatsPanel({ spaceId }: StatsPanelProps) {
const t = useT();
const [days, setDays] = useState(30);
const [stats, setStats] = useState<SpaceStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.stats.space(spaceId, days)
.then((data) => { if (!cancelled) setStats(data); })
.catch(() => { if (!cancelled) setStats(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [spaceId, days]);
const formatDuration = (ms: number) => {
const minutes = Math.round(ms / 60000);
const hours = Math.floor(minutes / 60);
return hours > 0
? t('stats.hours', { hours, minutes: minutes % 60 })
: t('stats.minutes', { minutes });
};
const isEmpty = !stats || (stats.voice.length === 0 && stats.messages.length === 0);
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('stats.title')}</h2>
<div className="flex gap-1.5 mb-5">
{RANGES.map((range) => (
<button
key={range.days}
type="button"
onClick={() => setDays(range.days)}
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
days === range.days
? 'bg-accent-primary text-white'
: 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
}`}
>
{t(range.key)}
</button>
))}
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
) : isEmpty ? (
<p className="text-[13px] text-txt-tertiary">{t('stats.empty')}</p>
) : (
<div className="space-y-6">
{stats.voice.length > 0 && (
<div>
<Leaderboard title={t('stats.voice.title')} rows={stats.voice} format={formatDuration} />
<p className="text-[11px] text-txt-tertiary mt-2">
{t('stats.total.voice', { value: formatDuration(stats.totals.voiceMs) })}
</p>
</div>
)}
{stats.messages.length > 0 && (
<div>
<Leaderboard
title={t('stats.messages.title')}
rows={stats.messages}
format={(value) => String(value)}
/>
<p className="text-[11px] text-txt-tertiary mt-2">
{t('stats.total.messages', { value: stats.totals.messages })}
</p>
</div>
)}
</div>
)}
<p className="text-[11px] text-txt-tertiary mt-6">{t('stats.note')}</p>
</div>
);
}
+14
View File
@@ -33,6 +33,20 @@ export const en = {
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
// Statistics
'stats.title': 'Statistics',
'stats.range.7': 'Last 7 days',
'stats.range.30': 'Last 30 days',
'stats.range.365': 'Last year',
'stats.voice.title': 'Time in voice',
'stats.messages.title': 'Messages sent',
'stats.empty': 'Nothing recorded in this period yet.',
'stats.total.voice': '{value} total',
'stats.total.messages': '{value} messages in total',
'stats.hours': '{hours}h {minutes}m',
'stats.minutes': '{minutes}m',
'stats.note': 'Counting started when this feature was installed — earlier activity is not included.',
// Audit log
'audit.title': 'Audit Log',
'audit.empty': 'Nothing recorded yet. Changes to the server show up here.',
+14
View File
@@ -32,6 +32,20 @@ export const ptBR: Partial<Dictionary> = {
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
// Estatísticas
'stats.title': 'Estatísticas',
'stats.range.7': 'Últimos 7 dias',
'stats.range.30': 'Últimos 30 dias',
'stats.range.365': 'Último ano',
'stats.voice.title': 'Tempo em call',
'stats.messages.title': 'Mensagens enviadas',
'stats.empty': 'Nada registrado neste período ainda.',
'stats.total.voice': '{value} no total',
'stats.total.messages': '{value} mensagens no total',
'stats.hours': '{hours}h {minutes}min',
'stats.minutes': '{minutes}min',
'stats.note': 'A contagem começou quando esta funcionalidade foi instalada — atividade anterior não entra.',
// Registro de auditoria
'audit.title': 'Registro de auditoria',
'audit.empty': 'Nada registrado ainda. Mudanças no servidor aparecem aqui.',