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
@@ -0,0 +1 @@
ALTER TABLE `swarm_nodes` ADD `media_count` integer;
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -32,10 +32,11 @@ const announcementSchema = z.object({
softwareVersion: z.string().optional(), softwareVersion: z.string().optional(),
userCount: z.number().optional(), userCount: z.number().optional(),
postCount: z.number().optional(), postCount: z.number().optional(),
mediaCount: z.number().optional(),
isNsfw: z.boolean().optional(), isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(), capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
timestamp: z.string().optional(), timestamp: z.string().optional(),
}); }).passthrough();
// Schema including signature for verification // Schema including signature for verification
const signedAnnouncementSchema = announcementSchema.extend({ const signedAnnouncementSchema = announcementSchema.extend({
@@ -101,6 +102,7 @@ export async function POST(request: Request) {
softwareVersion: data.softwareVersion, softwareVersion: data.softwareVersion,
userCount: data.userCount, userCount: data.userCount,
postCount: data.postCount, postCount: data.postCount,
mediaCount: data.mediaCount,
isNsfw: data.isNsfw, isNsfw: data.isNsfw,
capabilities: data.capabilities, capabilities: data.capabilities,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
@@ -122,6 +124,7 @@ export async function POST(request: Request) {
softwareVersion: ourAnnouncement.softwareVersion, softwareVersion: ourAnnouncement.softwareVersion,
userCount: ourAnnouncement.userCount, userCount: ourAnnouncement.userCount,
postCount: ourAnnouncement.postCount, postCount: ourAnnouncement.postCount,
mediaCount: ourAnnouncement.mediaCount,
isNsfw: ourAnnouncement.isNsfw, isNsfw: ourAnnouncement.isNsfw,
capabilities: ourAnnouncement.capabilities, capabilities: ourAnnouncement.capabilities,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
+3 -2
View File
@@ -30,10 +30,11 @@ const nodeInfoSchema = z.object({
softwareVersion: z.string().optional(), softwareVersion: z.string().optional(),
userCount: z.number().optional(), userCount: z.number().optional(),
postCount: z.number().optional(), postCount: z.number().optional(),
mediaCount: z.number().optional(),
isNsfw: z.boolean().optional(), isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(), capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
lastSeenAt: z.string().optional(), lastSeenAt: z.string().optional(),
}); }).passthrough();
const gossipPayloadSchema = z.object({ const gossipPayloadSchema = z.object({
sender: z.string().min(1), sender: z.string().min(1),
@@ -41,7 +42,7 @@ const gossipPayloadSchema = z.object({
handles: z.array(handleSchema).optional(), handles: z.array(handleSchema).optional(),
timestamp: z.string(), timestamp: z.string(),
since: z.string().optional(), since: z.string().optional(),
}); }).passthrough();
// Schema including signature for verification // Schema including signature for verification
const signedGossipSchema = gossipPayloadSchema.extend({ const signedGossipSchema = gossipPayloadSchema.extend({
+1
View File
@@ -26,6 +26,7 @@ export async function GET() {
softwareVersion: announcement.softwareVersion, softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount, userCount: announcement.userCount,
postCount: announcement.postCount, postCount: announcement.postCount,
mediaCount: announcement.mediaCount,
isNsfw: announcement.isNsfw, isNsfw: announcement.isNsfw,
capabilities: announcement.capabilities, capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
+40 -1
View File
@@ -22,6 +22,17 @@ interface NodeInfo {
isNsfw: boolean; 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() { export function RightSidebar() {
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node.'; const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node.';
const [nodeInfo, setNodeInfo] = useState<NodeInfo>({ const [nodeInfo, setNodeInfo] = useState<NodeInfo>({
@@ -39,6 +50,7 @@ export function RightSidebar() {
commitCount: number | null; commitCount: number | null;
buildDate: string | null; buildDate: string | null;
} | null>(null); } | null>(null);
const [networkStats, setNetworkStats] = useState<NetworkStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -87,7 +99,18 @@ export function RightSidebar() {
.then(data => setVersion(data)) .then(data => setVersion(data))
.catch(() => setVersion({ version: 'unknown', commit: null, commitCount: null, buildDate: null })); .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) { if (loading) {
@@ -165,6 +188,22 @@ export function RightSidebar() {
: ''} : ''}
</p> </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 && ( {nodeInfo.admins.length > 0 && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)' }}> <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' }}> <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) // Stats (updated periodically)
userCount: integer('user_count'), userCount: integer('user_count'),
postCount: integer('post_count'), postCount: integer('post_count'),
mediaCount: integer('media_count'),
// NSFW flag (synced from remote node) // NSFW flag (synced from remote node)
isNsfw: integer('is_nsfw', { mode: 'boolean' }).default(false).notNull(), 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. * Handles node discovery and announcement in the swarm network.
*/ */
import { db, users, posts } from '@/db'; import { db, users, posts, media } from '@/db';
import { sql } from 'drizzle-orm'; import { isNotNull, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types'; import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { getCurrentBuildInfo } from '@/lib/version'; import { getCurrentBuildInfo } from '@/lib/version';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry'; import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
@@ -31,6 +31,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
let publicKey = ''; let publicKey = '';
let userCount = 0; let userCount = 0;
let postCount = 0; let postCount = 0;
let mediaCount = 0;
let isNsfw = false; let isNsfw = false;
if (db) { if (db) {
@@ -50,9 +51,13 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
// Get counts // Get counts
const userResult = await db.select({ count: sql<number>`count(*)` }).from(users); const userResult = await db.select({ count: sql<number>`count(*)` }).from(users);
const postResult = await db.select({ count: sql<number>`count(*)` }).from(posts); 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); userCount = Number(userResult[0]?.count ?? 0);
postCount = Number(postResult[0]?.count ?? 0); postCount = Number(postResult[0]?.count ?? 0);
mediaCount = Number(mediaResult[0]?.count ?? 0);
} }
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1']; const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1'];
@@ -68,6 +73,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
: buildInfo.version, : buildInfo.version,
userCount, userCount,
postCount, postCount,
mediaCount,
capabilities, capabilities,
isNsfw, isNsfw,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -212,6 +218,7 @@ export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | nul
softwareVersion: data.softwareVersion, softwareVersion: data.softwareVersion,
userCount: data.userCount, userCount: data.userCount,
postCount: data.postCount, postCount: data.postCount,
mediaCount: data.mediaCount,
capabilities: data.capabilities, capabilities: data.capabilities,
isNsfw: data.isNsfw, isNsfw: data.isNsfw,
}; };
+1
View File
@@ -47,6 +47,7 @@ export async function buildGossipPayload(since?: string): Promise<SwarmGossipPay
softwareVersion: announcement.softwareVersion, softwareVersion: announcement.softwareVersion,
userCount: announcement.userCount, userCount: announcement.userCount,
postCount: announcement.postCount, postCount: announcement.postCount,
mediaCount: announcement.mediaCount,
isNsfw: announcement.isNsfw, isNsfw: announcement.isNsfw,
capabilities: announcement.capabilities, capabilities: announcement.capabilities,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
+51 -11
View File
@@ -4,8 +4,8 @@
* Manages the local registry of known swarm nodes. * Manages the local registry of known swarm nodes.
*/ */
import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db'; import { db, media, posts, swarmNodes, swarmSeeds, swarmSyncLog, users } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm'; import { eq, desc, and, gt, isNotNull, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types'; import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types'; import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
import { import {
@@ -14,6 +14,40 @@ import {
isPublicSwarmDomain, isPublicSwarmDomain,
} from './node-domain'; } 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 * Get or create a swarm node entry
*/ */
@@ -46,6 +80,7 @@ export async function upsertSwarmNode(
softwareVersion: node.softwareVersion, softwareVersion: node.softwareVersion,
userCount: node.userCount, userCount: node.userCount,
postCount: node.postCount, postCount: node.postCount,
mediaCount: node.mediaCount,
isNsfw: node.isNsfw ?? false, isNsfw: node.isNsfw ?? false,
discoveredVia, discoveredVia,
capabilities, capabilities,
@@ -64,6 +99,7 @@ export async function upsertSwarmNode(
softwareVersion: node.softwareVersion ?? existing.softwareVersion, softwareVersion: node.softwareVersion ?? existing.softwareVersion,
userCount: node.userCount ?? existing.userCount, userCount: node.userCount ?? existing.userCount,
postCount: node.postCount ?? existing.postCount, postCount: node.postCount ?? existing.postCount,
mediaCount: node.mediaCount ?? existing.mediaCount,
isNsfw: node.isNsfw ?? existing.isNsfw, isNsfw: node.isNsfw ?? existing.isNsfw,
capabilities: capabilities ?? existing.capabilities, capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(), lastSeenAt: new Date(),
@@ -317,22 +353,25 @@ export async function getSwarmStats() {
activeNodes: 0, activeNodes: 0,
totalUsers: 0, totalUsers: 0,
totalPosts: 0, totalPosts: 0,
totalMedia: 0,
}; };
} }
const allNodes = await db.query.swarmNodes.findMany(); const allNodes = await db.query.swarmNodes.findMany();
const publicNodes = allNodes.filter(n => isPublicSwarmDomain(n.domain)); 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 [localUsers, localPosts, localMedia] = await Promise.all([
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0); 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 { const hasPublicLocalNode = isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN);
totalNodes: publicNodes.length, return aggregateSwarmStats(publicNodes, {
activeNodes: activeNodes.length, users: Number(localUsers[0]?.count ?? 0),
totalUsers, posts: Number(localPosts[0]?.count ?? 0),
totalPosts, media: Number(localMedia[0]?.count ?? 0),
}; }, hasPublicLocalNode);
} }
// Helper to convert DB node to SwarmNodeInfo // Helper to convert DB node to SwarmNodeInfo
@@ -346,6 +385,7 @@ function nodeToInfo(node: typeof swarmNodes.$inferSelect): SwarmNodeInfo {
softwareVersion: node.softwareVersion ?? undefined, softwareVersion: node.softwareVersion ?? undefined,
userCount: node.userCount ?? undefined, userCount: node.userCount ?? undefined,
postCount: node.postCount ?? undefined, postCount: node.postCount ?? undefined,
mediaCount: node.mediaCount ?? undefined,
capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined, capabilities: node.capabilities ? JSON.parse(node.capabilities) : undefined,
isNsfw: node.isNsfw, isNsfw: node.isNsfw,
lastSeenAt: node.lastSeenAt.toISOString(), 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; softwareVersion?: string;
userCount?: number; userCount?: number;
postCount?: number; postCount?: number;
mediaCount?: number;
capabilities?: SwarmCapability[]; capabilities?: SwarmCapability[];
isNsfw?: boolean; isNsfw?: boolean;
lastSeenAt?: string; lastSeenAt?: string;
@@ -29,6 +30,7 @@ export interface SwarmAnnouncement {
softwareVersion: string; softwareVersion: string;
userCount: number; userCount: number;
postCount: number; postCount: number;
mediaCount: number;
capabilities: SwarmCapability[]; capabilities: SwarmCapability[];
isNsfw: boolean; isNsfw: boolean;
timestamp: string; timestamp: string;
@@ -91,6 +93,7 @@ export interface SwarmStats {
activeNodes: number; activeNodes: number;
totalUsers: number; totalUsers: number;
totalPosts: number; totalPosts: number;
totalMedia: number;
lastUpdated: string; lastUpdated: string;
} }