This commit is contained in:
ajvpot
2025-07-05 00:00:00 +00:00
parent bdab51fd22
commit 3e6126e108
7 changed files with 167 additions and 3 deletions
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { clickhouse } from "@/lib/clickhouse/clickhouse";
export async function GET() {
try {
const query = `
WITH days AS (
SELECT arrayJoin(range(0, 30)) AS offset, toDate(subtractDays(today(), 29 - offset)) AS day
),
all_nodes AS (
SELECT toDate(ingest_timestamp) AS day, public_key, latitude, longitude
FROM meshcore_adverts
WHERE ingest_timestamp >= subtractDays(today(), 30)
),
node_days AS (
SELECT public_key, min(day) AS first_seen, any(latitude) AS latitude, any(longitude) AS longitude
FROM all_nodes
GROUP BY public_key
),
expanded AS (
SELECT d.day, nd.public_key, nd.latitude, nd.longitude
FROM days d
INNER JOIN node_days nd ON nd.first_seen <= d.day
)
SELECT day,
count(DISTINCT public_key) AS cumulative_unique_nodes,
count(DISTINCT CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN public_key END) AS nodes_with_location,
count(DISTINCT CASE WHEN latitude IS NULL OR longitude IS NULL THEN public_key END) AS nodes_without_location
FROM expanded
GROUP BY day
ORDER BY day ASC
`;
const resultSet = await clickhouse.query({ query, format: 'JSONEachRow' });
const rows = await resultSet.json() as Array<{
day: string,
cumulative_unique_nodes: number,
nodes_with_location: number,
nodes_without_location: number
}>;
return NextResponse.json({ data: rows });
} catch (error) {
return NextResponse.json({ error: "Failed to fetch nodes over time" }, { status: 500 });
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { clickhouse } from "@/lib/clickhouse/clickhouse";
export async function GET() {
try {
const query = `
SELECT channel_hash, count() AS message_count
FROM meshcore_public_channel_messages
GROUP BY channel_hash
ORDER BY message_count DESC
LIMIT 10
`;
const resultSet = await clickhouse.query({ query, format: 'JSONEachRow' });
const rows = await resultSet.json() as Array<{ channel_hash: string, message_count: number }>;
return NextResponse.json({ data: rows });
} catch (error) {
return NextResponse.json({ error: "Failed to fetch popular channels" }, { status: 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { clickhouse } from "@/lib/clickhouse/clickhouse";
export async function GET() {
try {
const query = `SELECT count() AS total_nodes FROM meshcore_adverts_latest`;
const resultSet = await clickhouse.query({ query, format: 'JSONEachRow' });
const rows = await resultSet.json() as Array<{ total_nodes: number }>;
const total = rows.length > 0 ? Number(rows[0].total_nodes) : 0;
return NextResponse.json({ total_nodes: total });
} catch (error) {
return NextResponse.json({ error: "Failed to fetch total nodes" }, { status: 500 });
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ export default function MessagesPage() {
<div className="max-w-2xl mx-auto py-8 px-4" style={{ position: 'relative' }}>
{/* Header row with title and refresh button */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">All Messages</h1>
<h1 className="text-2xl font-bold">MeshCore Messages</h1>
<RefreshButton
onClick={() => fetchMessages(undefined, true)}
loading={loading}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
export default function StatsPage() {
const [totalNodes, setTotalNodes] = useState<number | null>(null);
const [nodesOverTime, setNodesOverTime] = useState<any[]>([]);
const [popularChannels, setPopularChannels] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
setLoading(true);
const [totalNodesRes, nodesOverTimeRes, popularChannelsRes] = await Promise.all([
fetch("/api/stats/total-nodes").then(r => r.json()),
fetch("/api/stats/nodes-over-time").then(r => r.json()),
fetch("/api/stats/popular-channels").then(r => r.json()),
]);
setTotalNodes(totalNodesRes.total_nodes ?? null);
setNodesOverTime(nodesOverTimeRes.data ?? []);
setPopularChannels(popularChannelsRes.data ?? []);
setLoading(false);
}
fetchStats();
}, []);
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">MeshCore Network Stats</h1>
{loading ? (
<div>Loading...</div>
) : (
<>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Total Unique Nodes</h2>
<div className="text-3xl font-mono">{totalNodes}</div>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Nodes Heard Over Time</h2>
<table className="w-full text-sm border">
<thead>
<tr>
<th className="border px-2 py-1">Day</th>
<th className="border px-2 py-1">Total Nodes</th>
<th className="border px-2 py-1">With Location</th>
<th className="border px-2 py-1">Without Location</th>
</tr>
</thead>
<tbody>
{nodesOverTime.map((row, i) => (
<tr key={i}>
<td className="border px-2 py-1">{row.day}</td>
<td className="border px-2 py-1">{row.cumulative_unique_nodes}</td>
<td className="border px-2 py-1">{row.nodes_with_location}</td>
<td className="border px-2 py-1">{row.nodes_without_location}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Most Popular Channels</h2>
<table className="w-full text-sm border">
<thead>
<tr>
<th className="border px-2 py-1">Channel Hash</th>
<th className="border px-2 py-1">Message Count</th>
</tr>
</thead>
<tbody>
{popularChannels.map((row, i) => (
<tr key={i}>
<td className="border px-2 py-1">{row.channel_hash}</td>
<td className="border px-2 py-1">{row.message_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
+2 -2
View File
@@ -84,11 +84,11 @@ export default function ChatBox() {
}`}
>
<div className="flex items-center justify-between" style={{ minHeight: '2rem' }}>
<span className="font-semibold text-gray-800 dark:text-gray-100">Chat</span>
<span className="font-semibold text-gray-800 dark:text-gray-100">MeshCore Chat</span>
<button
className="p-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-800"
onClick={() => setMinimized((m) => !m)}
aria-label={minimized ? "Maximize chat" : "Minimize chat"}
aria-label={minimized ? "Maximize MeshCore Chat" : "Minimize MeshCore Chat"}
>
{minimized ? (
<PlusIcon className="h-5 w-5" />
+1
View File
@@ -15,6 +15,7 @@ export default function Header({ configButtonRef }: HeaderProps) {
<nav className="flex gap-6 items-center">
<Link href="/" className="font-bold text-lg">MeshExplorer</Link>
<Link href="/messages">Messages</Link>
<Link href="/stats">Stats</Link>
</nav>
<button
ref={configButtonRef || contextButtonRef}