diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx new file mode 100644 index 0000000..b54a942 --- /dev/null +++ b/src/app/api-docs/page.tsx @@ -0,0 +1,439 @@ +import React from 'react'; + +export default function ApiDocsPage() { + return ( +
+
+
+
+

MeshExplorer API Documentation

+
+
+
+

+ Note: This documentation may be out of date as the project is continuously improving. + For the most up-to-date information, please refer to the source code or contact the development team. +

+
+
+
+ +
+ {/* Chat API */} +
+

Chat API

+
+

GET /api/chat

+

Retrieve chat messages from the mesh network.

+ +

Query Parameters

+

Note: Array parameters like privateKeys can be specified multiple times in the URL (e.g., ?privateKeys=key1&privateKeys=key2).

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
limitnumberNoNumber of messages to return (default: 20)
beforestringNoGet messages before this timestamp
afterstringNoGet messages after this timestamp
channel_idstringNoFilter by specific channel hash
regionstringNoFilter by geographic region
decryptbooleanNoEnable message decryption (any value present)
privateKeysstring[]NoArray of private keys for decryption (can be specified multiple times)
+
+ +

Response Format

+
+
+{`// Without decryption
+[
+  {
+    "ingest_timestamp": "2024-01-01T12:00:00Z",
+    "mesh_timestamp": "2024-01-01T12:00:00Z",
+    "channel_hash": "a1",
+    "mac": "b2c3",
+    "encrypted_message": "hex_encoded_data",
+    "message_count": 123,
+    "origin_key_path_array": [["origin", "pubkey", "path"]]
+  }
+]
+
+// With decryption (decrypt=true)
+[
+  {
+    "ingest_timestamp": "2024-01-01T12:00:00Z",
+    "mesh_timestamp": "2024-01-01T12:00:00Z",
+    "channel_hash": "a1",
+    "mac": "b2c3",
+    "encrypted_message": "hex_encoded_data",
+    "message_count": 123,
+    "origin_key_path_array": [["origin", "pubkey", "path"]],
+    "decrypted": {
+      "timestamp": 1704110400,
+      "msgType": 1,
+      "sender": "username",
+      "text": "Hello world!",
+      "rawText": "username: Hello world!"
+    }
+  }
+]`}
+                    
+
+
+
+ + {/* Map API */} +
+

Map API

+
+

GET /api/map

+

Retrieve node positions for map visualization.

+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
minLatnumberNoMinimum latitude for bounding box
maxLatnumberNoMaximum latitude for bounding box
minLngnumberNoMinimum longitude for bounding box
maxLngnumberNoMaximum longitude for bounding box
nodeTypesstring[]NoFilter by node types (can be specified multiple times)
lastSeennumberNoFilter by last seen time in seconds
+
+ +

Response Format

+
+
+{`[
+  {
+    "node_id": "abc123...",
+    "name": "Node Name",
+    "short_name": "NODE",
+    "latitude": 40.7128,
+    "longitude": -74.0060,
+    "last_seen": "2024-01-01T12:00:00Z",
+    "first_seen": "2024-01-01T10:00:00Z",
+    "type": "meshcore"
+  }
+]`}
+                    
+
+
+
+ + {/* Stats APIs */} +
+

Stats APIs

+ + {/* Total Nodes */} +
+

GET /api/stats/total-nodes

+

Get the total number of nodes in the network.

+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
regionstringNoFilter by geographic region
+
+ +

Response Format

+
+
+{`{
+  "total_nodes": 1234
+}`}
+                    
+
+
+ + {/* Nodes Over Time */} +
+

GET /api/stats/nodes-over-time

+

Get cumulative node statistics over time with a 7-day rolling window.

+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
regionstringNoFilter by geographic region
+
+ +

Response Format

+
+
+{`{
+  "data": [
+    {
+      "day": "2024-01-01",
+      "cumulative_unique_nodes": 100,
+      "nodes_with_location": 80,
+      "nodes_without_location": 20,
+      "repeaters": 15,
+      "room_servers": 5
+    }
+  ]
+}`}
+                    
+
+
+ + {/* Popular Channels */} +
+

GET /api/stats/popular-channels

+

Get the most popular channels by message count (top 10).

+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
regionstringNoFilter by geographic region
+
+ +

Response Format

+
+
+{`{
+  "data": [
+    {
+      "channel_hash": "a1",
+      "message_count": 1234
+    }
+  ]
+}`}
+                    
+
+
+ + {/* Repeater Prefixes */} +
+

GET /api/stats/repeater-prefixes

+

Get repeater node statistics grouped by public key prefix (first 2 characters).

+ +

Query Parameters

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
regionstringNoFilter by geographic region
+
+ +

Response Format

+
+
+{`{
+  "data": [
+    {
+      "prefix": "ab",
+      "node_count": 15,
+      "node_names": ["Node1", "Node2", "Node3"]
+    }
+  ]
+}`}
+                    
+
+
+
+ + {/* Error Handling */} +
+

Error Handling

+
+

All API endpoints return appropriate HTTP status codes and error messages:

+
    +
  • 200 OK: Successful request
  • +
  • 500 Internal Server Error: Server error with descriptive message
  • +
+ +

Error Response Format

+
+
+{`{
+  "error": "Failed to fetch data"
+}`}
+                    
+
+
+
+ + {/* Examples */} +
+

Example Requests

+
+
+

Get recent chat messages with decryption

+
+
+{`GET /api/chat?decrypt=true&limit=10&privateKeys=key1&privateKeys=key2`}
+                      
+
+
+ +
+

Get nodes in a specific region

+
+
+{`GET /api/map?minLat=40.0&maxLat=41.0&minLng=-74.0&maxLng=-73.0&nodeTypes=meshcore`}
+                      
+
+
+ +
+

Get total nodes in a region

+
+
+{`GET /api/stats/total-nodes?region=us-east`}
+                      
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 983879e..05f10e0 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getLatestChatMessages } from "@/lib/clickhouse/actions"; -import { getChannelIdFromKey } from "@/lib/meshcore"; +import { getChannelIdFromKey, decryptMeshcoreGroupMessage } from "@/lib/meshcore"; export async function GET(req: Request) { try { @@ -10,7 +10,43 @@ export async function GET(req: Request) { const after = searchParams.get("after") || undefined; const channelId = searchParams.get("channel_id") || undefined; const region = searchParams.get("region") || undefined; + const decrypt = searchParams.has("decrypt"); + const privateKeys = searchParams.getAll("privateKeys"); + + // Always include the public meshcore key + const PUBLIC_MESHCORE_KEY = "izOH6cXN6mrJ5e26oRXNcg=="; + const allKeys = [PUBLIC_MESHCORE_KEY, ...privateKeys]; + const messages = await getLatestChatMessages({ limit, before, after, channelId, region } as { limit?: number, before?: string, after?: string, channelId?: string, region?: string }); + + if (decrypt) { + const decryptedMessages = []; + + for (const message of messages) { + try { + const decrypted = await decryptMeshcoreGroupMessage({ + encrypted_message: message.encrypted_message, + mac: message.mac, + channel_hash: message.channel_hash, + knownKeys: allKeys, + parse: true + }); + + if (decrypted) { + decryptedMessages.push({ + ...message, + decrypted + }); + } + } catch (error) { + // Skip messages that fail to decrypt + console.warn("Failed to decrypt message:", error); + } + } + + return NextResponse.json(decryptedMessages); + } + return NextResponse.json(messages); } catch (error) { return NextResponse.json({ error: "Failed to fetch chat messages" }, { status: 500 }); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 14bbe62..fa6908a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { Cog6ToothIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; +import { Cog6ToothIcon, InformationCircleIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; import { useConfig } from "./ConfigContext"; -import React, { useState } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import InfoModal from "./InfoModal"; import { getAppName } from "../lib/api"; @@ -10,19 +10,152 @@ interface HeaderProps { configButtonRef?: React.Ref; } +interface NavItem { + href: string; + label: string; + icon?: React.ReactNode; + isVisible?: boolean; +} + export default function Header({ configButtonRef }: HeaderProps) { const { openConfig, configButtonRef: contextButtonRef } = useConfig(); const [infoModalOpen, setInfoModalOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [visibleItems, setVisibleItems] = useState([]); + const [hiddenItems, setHiddenItems] = useState([]); + + const navRef = useRef(null); + const itemsRef = useRef(null); + const dropdownRef = useRef(null); + + // Define all navigation items + const allNavItems: NavItem[] = [ + { href: "/messages", label: "Messages" }, + { href: "/stats", label: "Stats" }, + { href: "/api-docs", label: "API Docs" }, + ]; + + // Measure available space and determine which items can fit + const measureAndLayout = useCallback(() => { + if (!navRef.current || !itemsRef.current) return; + + const navWidth = navRef.current.offsetWidth; + const rightSectionWidth = 200; // Approximate width for buttons + const availableWidth = navWidth - rightSectionWidth - 48; // 48px for padding + + // Create temporary elements to measure item widths + const tempContainer = document.createElement('div'); + tempContainer.style.position = 'absolute'; + tempContainer.style.visibility = 'hidden'; + tempContainer.style.whiteSpace = 'nowrap'; + tempContainer.className = 'flex gap-6 items-center'; + document.body.appendChild(tempContainer); + + // Measure all items first to get their widths + const itemWidths: number[] = []; + for (const item of allNavItems) { + const tempItem = document.createElement('a'); + tempItem.href = item.href; + tempItem.textContent = item.label; + tempItem.className = 'text-gray-800 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'; + tempContainer.appendChild(tempItem); + itemWidths.push(tempItem.offsetWidth); + } + + document.body.removeChild(tempContainer); + + // Find the cutoff point while preserving order + let currentWidth = 0; + let cutoffIndex = allNavItems.length; + + for (let i = 0; i < allNavItems.length; i++) { + const itemWidth = itemWidths[i]; + const gapWidth = i > 0 ? 24 : 0; // 24px gap between items + + if (currentWidth + itemWidth + gapWidth <= availableWidth) { + currentWidth += itemWidth + gapWidth; + } else { + cutoffIndex = i; + break; + } + } + + // Split items at the cutoff point to preserve order + const visible = allNavItems.slice(0, cutoffIndex); + const hidden = allNavItems.slice(cutoffIndex); + + setVisibleItems(visible); + setHiddenItems(hidden); + }, []); + + // Handle window resize + useEffect(() => { + const handleResize = () => { + measureAndLayout(); + }; + + window.addEventListener('resize', handleResize); + measureAndLayout(); + + return () => window.removeEventListener('resize', handleResize); + }, [measureAndLayout]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); return ( <>
-