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:
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user