mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-06-25 04:10:56 +02:00
stats
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user