Add live network totals for nodes, users, published media, and posts, including local-node counts and media-count federation support.

Hop-State: A_06FPDCRHF0BSN8BDYYXW3ER
Hop-Proposal: R_06FPDCQJTBM7EZK7BXFA8D0
Hop-Task: T_06FPD59337GBJTP7D756FEG
Hop-Attempt: AT_06FPD59337493XRD47TM0H0
This commit is contained in:
2026-07-15 09:53:59 -07:00
committed by Hop
parent 9e01bff9e8
commit 6990568baf
12 changed files with 7358 additions and 17 deletions
+4 -1
View File
@@ -32,10 +32,11 @@ const announcementSchema = z.object({
softwareVersion: z.string().optional(),
userCount: z.number().optional(),
postCount: z.number().optional(),
mediaCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
timestamp: z.string().optional(),
});
}).passthrough();
// Schema including signature for verification
const signedAnnouncementSchema = announcementSchema.extend({
@@ -101,6 +102,7 @@ export async function POST(request: Request) {
softwareVersion: data.softwareVersion,
userCount: data.userCount,
postCount: data.postCount,
mediaCount: data.mediaCount,
isNsfw: data.isNsfw,
capabilities: data.capabilities,
lastSeenAt: new Date().toISOString(),
@@ -122,6 +124,7 @@ export async function POST(request: Request) {
softwareVersion: ourAnnouncement.softwareVersion,
userCount: ourAnnouncement.userCount,
postCount: ourAnnouncement.postCount,
mediaCount: ourAnnouncement.mediaCount,
isNsfw: ourAnnouncement.isNsfw,
capabilities: ourAnnouncement.capabilities,
lastSeenAt: new Date().toISOString(),
+3 -2
View File
@@ -30,10 +30,11 @@ const nodeInfoSchema = z.object({
softwareVersion: z.string().optional(),
userCount: z.number().optional(),
postCount: z.number().optional(),
mediaCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
lastSeenAt: z.string().optional(),
});
}).passthrough();
const gossipPayloadSchema = z.object({
sender: z.string().min(1),
@@ -41,7 +42,7 @@ const gossipPayloadSchema = z.object({
handles: z.array(handleSchema).optional(),
timestamp: z.string(),
since: z.string().optional(),
});
}).passthrough();
// Schema including signature for verification
const signedGossipSchema = gossipPayloadSchema.extend({
+1
View File
@@ -26,6 +26,7 @@ export async function GET() {
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
mediaCount: announcement.mediaCount,
isNsfw: announcement.isNsfw,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
+40 -1
View File
@@ -22,6 +22,17 @@ interface NodeInfo {
isNsfw: boolean;
}
interface NetworkStats {
totalNodes: number;
totalUsers: number;
totalMedia: number;
totalPosts: number;
}
function formatNetworkTotal(value: number | undefined): string {
return typeof value === 'number' ? value.toLocaleString() : '—';
}
export function RightSidebar() {
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node.';
const [nodeInfo, setNodeInfo] = useState<NodeInfo>({
@@ -39,6 +50,7 @@ export function RightSidebar() {
commitCount: number | null;
buildDate: string | null;
} | null>(null);
const [networkStats, setNetworkStats] = useState<NetworkStats | null>(null);
const [loading, setLoading] = useState(true);
@@ -87,7 +99,18 @@ export function RightSidebar() {
.then(data => setVersion(data))
.catch(() => setVersion({ version: 'unknown', commit: null, commitCount: null, buildDate: null }));
return () => window.removeEventListener('synapsis:node-updated', handleNodeUpdated);
const loadNetworkStats = () => fetch('/api/swarm/nodes?stats=true&limit=1', { cache: 'no-store' })
.then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to load network stats')))
.then(data => setNetworkStats(data?.stats ?? null))
.catch(() => setNetworkStats(null));
void loadNetworkStats();
const networkStatsInterval = window.setInterval(loadNetworkStats, 60_000);
return () => {
window.removeEventListener('synapsis:node-updated', handleNodeUpdated);
window.clearInterval(networkStatsInterval);
};
}, []);
if (loading) {
@@ -165,6 +188,22 @@ export function RightSidebar() {
: ''}
</p>
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)', display: 'grid', gap: '9px' }}>
{[
['Total nodes', networkStats?.totalNodes],
['Total users', networkStats?.totalUsers],
['Total media', networkStats?.totalMedia],
['Total posts', networkStats?.totalPosts],
].map(([label, value]) => (
<div key={label} style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '16px', fontSize: '13px' }}>
<span style={{ color: 'var(--foreground-secondary)' }}>{label}</span>
<strong style={{ color: 'var(--foreground)', fontVariantNumeric: 'tabular-nums' }}>
{formatNetworkTotal(value as number | undefined)}
</strong>
</div>
))}
</div>
{nodeInfo.admins.length > 0 && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)' }}>
<h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', color: 'var(--foreground-tertiary)', marginBottom: '12px', letterSpacing: '0.05em' }}>
+1
View File
@@ -699,6 +699,7 @@ export const swarmNodes = sqliteTable('swarm_nodes', {
// Stats (updated periodically)
userCount: integer('user_count'),
postCount: integer('post_count'),
mediaCount: integer('media_count'),
// NSFW flag (synced from remote node)
isNsfw: integer('is_nsfw', { mode: 'boolean' }).default(false).notNull(),
+9 -2
View File
@@ -4,8 +4,8 @@
* Handles node discovery and announcement in the swarm network.
*/
import { db, users, posts } from '@/db';
import { sql } from 'drizzle-orm';
import { db, users, posts, media } from '@/db';
import { isNotNull, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { getCurrentBuildInfo } from '@/lib/version';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
@@ -31,6 +31,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
let publicKey = '';
let userCount = 0;
let postCount = 0;
let mediaCount = 0;
let isNsfw = false;
if (db) {
@@ -50,9 +51,13 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
// Get counts
const userResult = await db.select({ count: sql<number>`count(*)` }).from(users);
const postResult = await db.select({ count: sql<number>`count(*)` }).from(posts);
const mediaResult = await db.select({ count: sql<number>`count(*)` })
.from(media)
.where(isNotNull(media.postId));
userCount = Number(userResult[0]?.count ?? 0);
postCount = Number(postResult[0]?.count ?? 0);
mediaCount = Number(mediaResult[0]?.count ?? 0);
}
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1'];
@@ -68,6 +73,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
: buildInfo.version,
userCount,
postCount,
mediaCount,
capabilities,
isNsfw,
timestamp: new Date().toISOString(),
@@ -212,6 +218,7 @@ export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | nul
softwareVersion: data.softwareVersion,
userCount: data.userCount,
postCount: data.postCount,
mediaCount: data.mediaCount,
capabilities: data.capabilities,
isNsfw: data.isNsfw,
};
+1
View File
@@ -47,6 +47,7 @@ export async function buildGossipPayload(since?: string): Promise<SwarmGossipPay
softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount,
postCount: announcement.postCount,
mediaCount: announcement.mediaCount,
isNsfw: announcement.isNsfw,
capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(),
+51 -11
View File
@@ -4,8 +4,8 @@
* Manages the local registry of known swarm nodes.
*/
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import { db, media, posts, swarmNodes, swarmSeeds, swarmSyncLog, users } from '@/db';
import { eq, desc, and, gt, isNotNull, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
import {
@@ -14,6 +14,40 @@ import {
isPublicSwarmDomain,
} from './node-domain';
interface NetworkStatNode {
isActive: boolean;
userCount: number | null;
postCount: number | null;
mediaCount: number | null;
}
interface LocalNetworkStats {
users: number;
posts: number;
media: number;
}
export function aggregateSwarmStats(
publicNodes: NetworkStatNode[],
local: LocalNetworkStats,
includeLocalNode: boolean,
) {
const activeNodes = publicNodes.filter(node => node.isActive);
const localNodeOffset = includeLocalNode ? 1 : 0;
return {
totalNodes: publicNodes.length + localNodeOffset,
// Keep this as the active peer count; the scheduler uses zero to trigger seed recovery.
activeNodes: activeNodes.length,
totalUsers: activeNodes.reduce((sum, node) => sum + (node.userCount || 0), 0)
+ (includeLocalNode ? local.users : 0),
totalPosts: activeNodes.reduce((sum, node) => sum + (node.postCount || 0), 0)
+ (includeLocalNode ? local.posts : 0),
totalMedia: activeNodes.reduce((sum, node) => sum + (node.mediaCount || 0), 0)
+ (includeLocalNode ? local.media : 0),
};
}
/**
* Get or create a swarm node entry
*/
@@ -46,6 +80,7 @@ export async function upsertSwarmNode(
softwareVersion: node.softwareVersion,
userCount: node.userCount,
postCount: node.postCount,
mediaCount: node.mediaCount,
isNsfw: node.isNsfw ?? false,
discoveredVia,
capabilities,
@@ -64,6 +99,7 @@ export async function upsertSwarmNode(
softwareVersion: node.softwareVersion ?? existing.softwareVersion,
userCount: node.userCount ?? existing.userCount,
postCount: node.postCount ?? existing.postCount,
mediaCount: node.mediaCount ?? existing.mediaCount,
isNsfw: node.isNsfw ?? existing.isNsfw,
capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(),
@@ -317,22 +353,25 @@ export async function getSwarmStats() {
activeNodes: 0,
totalUsers: 0,
totalPosts: 0,
totalMedia: 0,
};
}
const allNodes = await db.query.swarmNodes.findMany();
const publicNodes = allNodes.filter(n => isPublicSwarmDomain(n.domain));
const activeNodes = publicNodes.filter(n => n.isActive);
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
const [localUsers, localPosts, localMedia] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(users),
db.select({ count: sql<number>`count(*)` }).from(posts),
db.select({ count: sql<number>`count(*)` }).from(media).where(isNotNull(media.postId)),
]);
return {
totalNodes: publicNodes.length,
activeNodes: activeNodes.length,
totalUsers,
totalPosts,
};
const hasPublicLocalNode = isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN);
return aggregateSwarmStats(publicNodes, {
users: Number(localUsers[0]?.count ?? 0),
posts: Number(localPosts[0]?.count ?? 0),
media: Number(localMedia[0]?.count ?? 0),
}, hasPublicLocalNode);
}
// Helper to convert DB node to SwarmNodeInfo
@@ -346,6 +385,7 @@ function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
softwareVersion: node.softwareVersion ?? undefined,
userCount: node.userCount ?? undefined,
postCount: node.postCount ?? undefined,
mediaCount: node.mediaCount ?? undefined,
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
isNsfw: node.isNsfw,
lastSeenAt: node.lastSeenAt.toISOString(),
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { aggregateSwarmStats } from './registry';
describe('aggregateSwarmStats', () => {
it('includes the local node and totals active peer statistics', () => {
const result = aggregateSwarmStats([
{ isActive: true, userCount: 3, postCount: 24, mediaCount: 7 },
{ isActive: true, userCount: 2, postCount: 8, mediaCount: null },
{ isActive: false, userCount: 50, postCount: 500, mediaCount: 100 },
], {
users: 4,
posts: 12,
media: 5,
}, true);
expect(result).toEqual({
totalNodes: 4,
activeNodes: 2,
totalUsers: 9,
totalPosts: 44,
totalMedia: 12,
});
});
it('excludes local counts when the node is not publicly participating', () => {
const result = aggregateSwarmStats([
{ isActive: true, userCount: 3, postCount: 24, mediaCount: 7 },
], {
users: 100,
posts: 200,
media: 300,
}, false);
expect(result).toEqual({
totalNodes: 1,
activeNodes: 1,
totalUsers: 3,
totalPosts: 24,
totalMedia: 7,
});
});
});
+3
View File
@@ -13,6 +13,7 @@ export interface SwarmNodeInfo {
softwareVersion?: string;
userCount?: number;
postCount?: number;
mediaCount?: number;
capabilities?: SwarmCapability[];
isNsfw?: boolean;
lastSeenAt?: string;
@@ -29,6 +30,7 @@ export interface SwarmAnnouncement {
softwareVersion: string;
userCount: number;
postCount: number;
mediaCount: number;
capabilities: SwarmCapability[];
isNsfw: boolean;
timestamp: string;
@@ -91,6 +93,7 @@ export interface SwarmStats {
activeNodes: number;
totalUsers: number;
totalPosts: number;
totalMedia: number;
lastUpdated: string;
}