mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 06:12:27 +00:00
feat: update owner auth and refresh stats visualizations
This commit is contained in:
1 parent
833ccd8de7
commit
0d77a5027c
9 files changed
+229
-102
No files matched your search
@@ -45,6 +45,14 @@ VITE_APP_HOSTNAME=app.example.com
|
||||
# Example: MESHCORE_CHANNEL_SECRETS=mychannel:9cd4e1f2a3b4c5d6e7f8a9b0c1d2e3f4,aabbccddeeff00112233445566778899
|
||||
MESHCORE_CHANNEL_SECRETS=
|
||||
|
||||
# ── Owner portal auth ─────────────────────────────────────────────────────────
|
||||
# Map MQTT usernames to owned node public keys (lowercase 64-hex), comma-separated.
|
||||
# Format:
|
||||
# OWNER_MQTT_USERNAME_MAP=username1=nodeid1|nodeid2,username2=nodeid3
|
||||
# Example:
|
||||
# OWNER_MQTT_USERNAME_MAP=node1=10cfab...713c95,node-ukmesh=20ed75...fe2ac
|
||||
OWNER_MQTT_USERNAME_MAP=
|
||||
|
||||
# ── Viewshed / elevation API ──────────────────────────────────────────────────
|
||||
OPENTOPODATA_API=https://api.opentopodata.org
|
||||
|
||||
|
||||
+145
-49
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import mqtt from 'mqtt';
|
||||
import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
|
||||
import { getWorkerHealthOverview } from '../health/status.js';
|
||||
|
||||
@@ -16,7 +17,7 @@ const OWNER_LOGIN_LIMITER = rateLimit({
|
||||
});
|
||||
|
||||
type OwnerSession = {
|
||||
key: string;
|
||||
nodeIds: string[];
|
||||
exp: number;
|
||||
};
|
||||
|
||||
@@ -53,8 +54,12 @@ function decryptOwnerSession(token: string): OwnerSession | null {
|
||||
decipher.setAuthTag(tag);
|
||||
const decoded = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
const parsed = JSON.parse(decoded) as Partial<OwnerSession>;
|
||||
if (typeof parsed.key !== 'string' || typeof parsed.exp !== 'number') return null;
|
||||
return { key: parsed.key.toLowerCase(), exp: parsed.exp };
|
||||
if (!Array.isArray(parsed.nodeIds) || typeof parsed.exp !== 'number') return null;
|
||||
const nodeIds = parsed.nodeIds
|
||||
.map((value) => String(value).trim().toLowerCase())
|
||||
.filter((value) => /^[0-9a-f]{64}$/.test(value));
|
||||
if (nodeIds.length < 1) return null;
|
||||
return { nodeIds, exp: parsed.exp };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -71,10 +76,56 @@ function readCookieValue(cookieHeader: string | undefined, key: string): string
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEd25519Key(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(normalized)) return null;
|
||||
return normalized;
|
||||
function parseOwnerMqttUsernameMap(): Map<string, string[]> {
|
||||
const raw = String(process.env['OWNER_MQTT_USERNAME_MAP'] ?? '').trim();
|
||||
const map = new Map<string, string[]>();
|
||||
if (!raw) return map;
|
||||
|
||||
for (const entry of raw.split(',')) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
const username = trimmed.slice(0, eqIdx).trim();
|
||||
const rawNodes = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!username || !rawNodes) continue;
|
||||
const nodeIds = rawNodes
|
||||
.split('|')
|
||||
.map((nodeId) => nodeId.trim().toLowerCase())
|
||||
.filter((nodeId) => /^[0-9a-f]{64}$/.test(nodeId));
|
||||
if (nodeIds.length < 1) continue;
|
||||
map.set(username, Array.from(new Set(nodeIds)));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Promise<boolean> {
|
||||
const brokerUrl = String(process.env['MQTT_BROKER_URL'] ?? 'ws://mosquitto:9001');
|
||||
const clientId = `owner-auth-${randomBytes(6).toString('hex')}`;
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
username: mqttUsername,
|
||||
password: mqttPassword,
|
||||
reconnectPeriod: 0,
|
||||
connectTimeout: 5_000,
|
||||
clean: true,
|
||||
clientId,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
client.removeAllListeners();
|
||||
client.end(true);
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), 6_000);
|
||||
client.once('connect', () => finish(true));
|
||||
client.once('error', () => finish(false));
|
||||
client.once('close', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function isSecureRequest(req: { secure: boolean; headers: Record<string, string | string[] | undefined> }): boolean {
|
||||
@@ -83,7 +134,24 @@ function isSecureRequest(req: { secure: boolean; headers: Record<string, string
|
||||
return proto.includes('https');
|
||||
}
|
||||
|
||||
async function buildOwnerDashboard(key: string) {
|
||||
async function buildOwnerDashboard(nodeIds: string[]) {
|
||||
if (nodeIds.length < 1) {
|
||||
return {
|
||||
nodes: [],
|
||||
totals: {
|
||||
ownedNodes: 0,
|
||||
packets24h: 0,
|
||||
packets7d: 0,
|
||||
},
|
||||
roadmap: [
|
||||
'Per-node packet history for owner nodes',
|
||||
'Advert and heartbeat trend views',
|
||||
'RSSI and SNR trend views from observer reports',
|
||||
'Node placement planner (coming next)',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const [ownedNodes, packetSummary] = await Promise.all([
|
||||
query<{
|
||||
node_id: string;
|
||||
@@ -97,17 +165,17 @@ async function buildOwnerDashboard(key: string) {
|
||||
}>(
|
||||
`SELECT node_id, name, network, last_seen, advert_count, lat, lon, iata
|
||||
FROM nodes
|
||||
WHERE LOWER(node_id) = $1 OR LOWER(COALESCE(public_key, '')) = $1
|
||||
WHERE LOWER(node_id) = ANY($1::text[])
|
||||
ORDER BY last_seen DESC NULLS LAST`,
|
||||
[key],
|
||||
[nodeIds],
|
||||
),
|
||||
query<{ packets_24h: number; packets_7d: number }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h,
|
||||
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '7 days')::int AS packets_7d
|
||||
FROM packets
|
||||
WHERE LOWER(src_node_id) = $1`,
|
||||
[key],
|
||||
WHERE LOWER(src_node_id) = ANY($1::text[])`,
|
||||
[nodeIds],
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -139,24 +207,14 @@ function getOwnerSession(req: Request): OwnerSession | null {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function resolveOwnedNodeIds(key: string): Promise<string[]> {
|
||||
const result = await query<{ node_id: string }>(
|
||||
`SELECT node_id
|
||||
FROM nodes
|
||||
WHERE LOWER(node_id) = $1 OR LOWER(COALESCE(public_key, '')) = $1`,
|
||||
[key],
|
||||
);
|
||||
return result.rows.map((row) => row.node_id);
|
||||
}
|
||||
|
||||
async function requireOwnerSession(req: Request, res: Response): Promise<string | null> {
|
||||
async function requireOwnerSession(req: Request, res: Response): Promise<string[] | null> {
|
||||
const session = getOwnerSession(req);
|
||||
if (!session) {
|
||||
res.clearCookie(OWNER_COOKIE_NAME, { path: '/' });
|
||||
res.status(401).json({ error: 'Not logged in' });
|
||||
return null;
|
||||
}
|
||||
return session.key;
|
||||
return session.nodeIds;
|
||||
}
|
||||
|
||||
type NetworkFilters = {
|
||||
@@ -244,7 +302,7 @@ router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const network = req.query['network'] as string | undefined;
|
||||
const filters = networkFilters(network);
|
||||
const [mqttCount, packetCount, staleCount, totalNodeCount, longestHopCount] = await Promise.all([
|
||||
const [mqttCount, packetCount, staleCount, mapNodeCount, totalNodeCount, longestHopCount] = await Promise.all([
|
||||
query(`SELECT COUNT(DISTINCT rx_node_id) AS count FROM packets WHERE time > NOW() - INTERVAL '10 minutes' AND rx_node_id IS NOT NULL ${filters.packets}`, filters.params),
|
||||
query(`SELECT COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params),
|
||||
query(`SELECT COUNT(*) AS count FROM nodes
|
||||
@@ -254,6 +312,12 @@ router.get('/stats', async (req, res) => {
|
||||
AND last_seen <= NOW() - INTERVAL '7 days'
|
||||
AND last_seen > NOW() - INTERVAL '14 days'
|
||||
${filters.nodes}`, filters.params),
|
||||
query(`SELECT COUNT(*) AS count FROM nodes
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
AND (role IS NULL OR role = 2)
|
||||
AND (name IS NULL OR name NOT LIKE '%🚫%')
|
||||
AND last_seen > NOW() - INTERVAL '14 days'
|
||||
${filters.nodes}`, filters.params),
|
||||
query(`SELECT COUNT(*) AS count FROM nodes
|
||||
WHERE (name IS NULL OR name NOT LIKE '%🚫%')
|
||||
AND (role IS NULL OR role != 4)
|
||||
@@ -274,6 +338,7 @@ router.get('/stats', async (req, res) => {
|
||||
mqttNodes: Number(mqttCount.rows[0]?.count ?? 0),
|
||||
staleNodes: Number(staleCount.rows[0]?.count ?? 0),
|
||||
packetsDay: Number(packetCount.rows[0]?.count ?? 0),
|
||||
mapNodes: Number(mapNodeCount.rows[0]?.count ?? 0),
|
||||
totalNodes: Number(totalNodeCount.rows[0]?.count ?? 0),
|
||||
longestHop: Number(longestHopCount.rows[0]?.count ?? 0),
|
||||
longestHopHash: (longestHopCount.rows[0]?.hash as string | undefined) ?? null,
|
||||
@@ -442,23 +507,41 @@ router.get('/health', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/owner/login — login with Ed25519 public key and issue encrypted cookie session
|
||||
// POST /api/owner/login — login with MQTT username/password and issue encrypted cookie session
|
||||
router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
|
||||
try {
|
||||
const key = normalizeEd25519Key(String((req.body as { ed25519Key?: string })?.ed25519Key ?? ''));
|
||||
if (!key) {
|
||||
res.status(400).json({ error: 'Invalid Ed25519 key format' });
|
||||
const body = req.body as { mqttUsername?: string; mqttPassword?: string } | undefined;
|
||||
const mqttUsername = String(body?.mqttUsername ?? '').trim();
|
||||
const mqttPassword = String(body?.mqttPassword ?? '').trim();
|
||||
if (!mqttUsername || !mqttPassword) {
|
||||
res.status(400).json({ error: 'Missing MQTT username or password' });
|
||||
return;
|
||||
}
|
||||
const usernameMap = parseOwnerMqttUsernameMap();
|
||||
if (usernameMap.size < 1) {
|
||||
res.status(503).json({ error: 'Owner login is not configured on the server' });
|
||||
return;
|
||||
}
|
||||
const mappedNodeIds = usernameMap.get(mqttUsername);
|
||||
if (!mappedNodeIds || mappedNodeIds.length < 1) {
|
||||
res.status(403).json({ error: 'Invalid MQTT credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboard = await buildOwnerDashboard(key);
|
||||
const authOk = await verifyMqttCredentials(mqttUsername, mqttPassword);
|
||||
if (!authOk) {
|
||||
res.status(403).json({ error: 'Invalid MQTT credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboard = await buildOwnerDashboard(mappedNodeIds);
|
||||
if (dashboard.totals.ownedNodes < 1) {
|
||||
res.status(403).json({ error: 'No active node found for this key yet' });
|
||||
res.status(403).json({ error: 'No active node found for this MQTT username yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = encryptOwnerSession({
|
||||
key,
|
||||
nodeIds: mappedNodeIds,
|
||||
exp: Date.now() + OWNER_SESSION_TTL_MS,
|
||||
});
|
||||
res.cookie(OWNER_COOKIE_NAME, token, {
|
||||
@@ -478,13 +561,13 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
|
||||
// GET /api/owner/session — resolve dashboard from encrypted cookie
|
||||
router.get('/owner/session', async (req, res) => {
|
||||
try {
|
||||
const sessionKey = await requireOwnerSession(req, res);
|
||||
if (!sessionKey) return;
|
||||
const sessionNodeIds = await requireOwnerSession(req, res);
|
||||
if (!sessionNodeIds) return;
|
||||
|
||||
const dashboard = await buildOwnerDashboard(sessionKey);
|
||||
const dashboard = await buildOwnerDashboard(sessionNodeIds);
|
||||
if (dashboard.totals.ownedNodes < 1) {
|
||||
res.clearCookie(OWNER_COOKIE_NAME, { path: '/' });
|
||||
res.status(401).json({ error: 'No active node found for this key yet' });
|
||||
res.status(401).json({ error: 'No active node found for this MQTT username yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -498,9 +581,8 @@ router.get('/owner/session', async (req, res) => {
|
||||
// GET /api/owner/live?nodeId=<hex> — live owner-focused data for map + packet feed
|
||||
router.get('/owner/live', async (req, res) => {
|
||||
try {
|
||||
const sessionKey = await requireOwnerSession(req, res);
|
||||
if (!sessionKey) return;
|
||||
const ownedNodeIds = await resolveOwnedNodeIds(sessionKey);
|
||||
const ownedNodeIds = await requireOwnerSession(req, res);
|
||||
if (!ownedNodeIds) return;
|
||||
if (ownedNodeIds.length < 1) {
|
||||
res.status(404).json({ error: 'No owned nodes found' });
|
||||
return;
|
||||
@@ -711,7 +793,7 @@ router.get('/stats/charts', async (req, res) => {
|
||||
|
||||
const [
|
||||
phResult, pdResult, rhResult, rdResult,
|
||||
ptResult, rpResult, hdResult, tcResult, sumResult,
|
||||
ptResult, rpResult, hdResult, pcResult, sumResult,
|
||||
] = await Promise.all([
|
||||
// packets per hour — last 24h (deduplicated by hash)
|
||||
query(`
|
||||
@@ -776,15 +858,24 @@ router.get('/stats/charts', async (req, res) => {
|
||||
${filters.packets}
|
||||
GROUP BY hop_count ORDER BY hop_count
|
||||
`, filters.params),
|
||||
// top 10 public channel chatters by decoded sender name — last 7d (deduplicated by hash)
|
||||
// top repeated first-2-hex prefix collisions across known nodes
|
||||
query(`
|
||||
SELECT payload->'decrypted'->>'sender' AS name, COUNT(DISTINCT packet_hash) AS count
|
||||
FROM packets
|
||||
WHERE packet_type = 5
|
||||
AND time > NOW() - INTERVAL '7 days'
|
||||
AND payload->'decrypted'->>'sender' IS NOT NULL
|
||||
${filters.packets}
|
||||
GROUP BY 1 ORDER BY count DESC LIMIT 10
|
||||
WITH prefix_counts AS (
|
||||
SELECT SUBSTRING(LOWER(n.node_id) FROM 1 FOR 2) AS prefix, COUNT(*)::int AS node_count
|
||||
FROM nodes n
|
||||
WHERE n.node_id ~ '^[0-9A-Fa-f]{64}$'
|
||||
AND (n.name IS NULL OR n.name NOT LIKE '%🚫%')
|
||||
AND (n.role IS NULL OR n.role != 4)
|
||||
${filters.nodesAlias('n')}
|
||||
GROUP BY 1
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
SELECT
|
||||
prefix,
|
||||
node_count AS repeats
|
||||
FROM prefix_counts
|
||||
ORDER BY node_count DESC, prefix ASC
|
||||
LIMIT 10
|
||||
`, filters.params),
|
||||
// summary
|
||||
query(`
|
||||
@@ -819,7 +910,12 @@ router.get('/stats/charts', async (req, res) => {
|
||||
packetTypes: ptResult.rows.map(r => ({ label: PAYLOAD_LABELS[Number(r.packet_type)] ?? `Type${r.packet_type}`, count: Number(r.count) })),
|
||||
repeatersPerDay: rpResult.rows.map(r => ({ hour: fmtDay(r.hour), count: Number(r.count ?? 0) })),
|
||||
hopDistribution: hdResult.rows.map(r => ({ hops: Number(r.hops), count: Number(r.count) })),
|
||||
topChatters: tcResult.rows.map(r => ({ name: r.name ?? 'Unknown', count: Number(r.count) })),
|
||||
// Back-compat for cached older frontend bundles that still read topChatters.
|
||||
topChatters: [],
|
||||
prefixCollisions: pcResult.rows.map(r => ({
|
||||
prefix: String(r.prefix ?? '').toUpperCase(),
|
||||
repeats: Number(r.repeats),
|
||||
})),
|
||||
summary: {
|
||||
totalPackets24h: Number(sumResult.rows[0].total_24h),
|
||||
totalPackets7d: Number(sumResult.rows[0].total_7d),
|
||||
|
||||
@@ -50,6 +50,8 @@ services:
|
||||
OPENTOPODATA_API: ${OPENTOPODATA_API:-https://api.opentopodata.org}
|
||||
PORT: ${PORT:-3000}
|
||||
MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-}
|
||||
OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-}
|
||||
OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002}
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
|
||||
+1
-16
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import { MapView } from './components/Map/MapView.js';
|
||||
import { FilterPanel, type Filters } from './components/FilterPanel/FilterPanel.js';
|
||||
@@ -15,7 +15,6 @@ import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js';
|
||||
import { useAppMessageHandler } from './hooks/useAppMessageHandler.js';
|
||||
import { usePathLearningModel } from './hooks/usePathLearningModel.js';
|
||||
import { getCurrentSite } from './config/site.js';
|
||||
import { hasCoords } from './utils/pathing.js';
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
livePackets: true,
|
||||
@@ -27,7 +26,6 @@ const DEFAULT_FILTERS: Filters = {
|
||||
links: false,
|
||||
};
|
||||
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const DISCLAIMER_KEY = 'meshcore-disclaimer-dismissed';
|
||||
const FILTERS_KEY = 'meshcore-app-filters-v2';
|
||||
|
||||
@@ -100,17 +98,6 @@ export const App: React.FC = () => {
|
||||
filters,
|
||||
});
|
||||
|
||||
const mapNodes = useMemo(() => Array.from(nodes.values()).filter(
|
||||
(node) => hasCoords(node)
|
||||
&& Date.now() - new Date(node.last_seen).getTime() < FOURTEEN_DAYS_MS
|
||||
&& !node.name?.includes('🚫')
|
||||
&& (node.role === undefined || node.role === 2),
|
||||
).length, [nodes]);
|
||||
|
||||
const totalDevices = useMemo(() => Array.from(nodes.values()).filter(
|
||||
(node) => !node.name?.includes('🚫') && node.role !== 4,
|
||||
).length, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
@@ -177,8 +164,6 @@ export const App: React.FC = () => {
|
||||
wsState={wsState}
|
||||
onShowDisclaimer={() => setShowDisclaimer(true)}
|
||||
stats={stats}
|
||||
mapNodes={mapNodes}
|
||||
totalDevices={totalDevices}
|
||||
/>
|
||||
|
||||
<MobileControls
|
||||
|
||||
@@ -8,8 +8,6 @@ type AppTopBarProps = {
|
||||
wsState: WSReadyState;
|
||||
onShowDisclaimer: () => void;
|
||||
stats: DashboardStats;
|
||||
mapNodes: number;
|
||||
totalDevices: number;
|
||||
};
|
||||
|
||||
const ConnIndicator: React.FC<{ state: WSReadyState }> = ({ state }) => (
|
||||
@@ -38,8 +36,6 @@ export const AppTopBar: React.FC<AppTopBarProps> = ({
|
||||
wsState,
|
||||
onShowDisclaimer,
|
||||
stats,
|
||||
mapNodes,
|
||||
totalDevices,
|
||||
}) => (
|
||||
<header className="topbar">
|
||||
<a href={homeUrl} className="topbar__home-btn" title="Home">← Home</a>
|
||||
@@ -59,11 +55,10 @@ export const AppTopBar: React.FC<AppTopBarProps> = ({
|
||||
</button>
|
||||
<StatsPanel
|
||||
mqttNodes={stats.mqttNodes}
|
||||
mapNodes={mapNodes}
|
||||
totalDevices={totalDevices}
|
||||
mapNodes={stats.mapNodes}
|
||||
totalDevices={stats.totalNodes}
|
||||
staleNodes={stats.staleNodes}
|
||||
packetsDay={stats.packetsDay}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,19 @@ const SITE_CONFIGS: Record<SiteId, SiteConfig> = {
|
||||
};
|
||||
|
||||
export function getCurrentSite(): SiteConfig {
|
||||
const site = import.meta.env['VITE_SITE'];
|
||||
return site === 'ukmesh' ? SITE_CONFIGS.ukmesh : SITE_CONFIGS.teesside;
|
||||
}
|
||||
const siteEnv = import.meta.env['VITE_SITE'];
|
||||
if (siteEnv === 'ukmesh') return SITE_CONFIGS.ukmesh;
|
||||
if (siteEnv === 'teesside') return SITE_CONFIGS.teesside;
|
||||
|
||||
const networkEnv = import.meta.env['VITE_NETWORK'];
|
||||
if (networkEnv === 'ukmesh') return SITE_CONFIGS.ukmesh;
|
||||
if (networkEnv === 'teesside') return SITE_CONFIGS.teesside;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
if (host.includes('ukmesh.com')) return SITE_CONFIGS.ukmesh;
|
||||
if (host.includes('teessidemesh.com')) return SITE_CONFIGS.teesside;
|
||||
}
|
||||
|
||||
return SITE_CONFIGS.teesside;
|
||||
}
|
||||
@@ -5,12 +5,16 @@ export type DashboardStats = {
|
||||
mqttNodes: number;
|
||||
staleNodes: number;
|
||||
packetsDay: number;
|
||||
mapNodes: number;
|
||||
totalNodes: number;
|
||||
};
|
||||
|
||||
const EMPTY_STATS: DashboardStats = {
|
||||
mqttNodes: 0,
|
||||
staleNodes: 0,
|
||||
packetsDay: 0,
|
||||
mapNodes: 0,
|
||||
totalNodes: 0,
|
||||
};
|
||||
|
||||
export function useDashboardStats(network?: string): DashboardStats {
|
||||
|
||||
@@ -119,7 +119,8 @@ const FitToNodes: React.FC<{ points: Array<{ lat: number; lon: number }> }> = ({
|
||||
};
|
||||
|
||||
export const OwnerPortalPage: React.FC = () => {
|
||||
const [keyInput, setKeyInput] = useState('');
|
||||
const [mqttUsername, setMqttUsername] = useState('');
|
||||
const [mqttPassword, setMqttPassword] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -155,8 +156,8 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
|
||||
const handleLogin = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!keyInput.trim()) {
|
||||
setError('Enter your Ed25519 key.');
|
||||
if (!mqttUsername.trim() || !mqttPassword.trim()) {
|
||||
setError('Enter your MQTT username and password.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -164,7 +165,10 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
fetch('/api/owner/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ed25519Key: keyInput.trim() }),
|
||||
body: JSON.stringify({
|
||||
mqttUsername: mqttUsername.trim(),
|
||||
mqttPassword: mqttPassword.trim(),
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
@@ -178,7 +182,8 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
if (json.dashboard.nodes[0]?.node_id) {
|
||||
setSelectedNodeId(json.dashboard.nodes[0].node_id);
|
||||
}
|
||||
setKeyInput('');
|
||||
setMqttUsername('');
|
||||
setMqttPassword('');
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message);
|
||||
@@ -259,7 +264,7 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
<div className="site-content">
|
||||
<h1 className="site-page-hero__title">Repeater Owner Portal</h1>
|
||||
<p className="site-page-hero__sub">
|
||||
Login with your Ed25519 node key. Sessions are kept in an encrypted cookie.
|
||||
Login with your MQTT username and password. Sessions are kept in an encrypted cookie.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -270,19 +275,30 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
<section className="prose-section owner-login">
|
||||
<h2>Login</h2>
|
||||
<p className="prose-note">
|
||||
Enter the 64-character Ed25519 public key of a node currently seen by the network.
|
||||
Enter the MQTT credentials associated with your repeater observer.
|
||||
</p>
|
||||
<form className="owner-login__form" onSubmit={handleLogin}>
|
||||
<label className="owner-login__label" htmlFor="owner-key">Ed25519 key</label>
|
||||
<label className="owner-login__label" htmlFor="owner-username">MQTT username</label>
|
||||
<input
|
||||
id="owner-username"
|
||||
className="owner-login__input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={mqttUsername}
|
||||
onChange={(e) => setMqttUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
maxLength={128}
|
||||
/>
|
||||
<label className="owner-login__label" htmlFor="owner-key">MQTT password</label>
|
||||
<input
|
||||
id="owner-key"
|
||||
className="owner-login__input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder="0123abcd... (64 hex chars)"
|
||||
maxLength={128}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={mqttPassword}
|
||||
onChange={(e) => setMqttPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
maxLength={256}
|
||||
/>
|
||||
<button className="site-btn site-btn--primary owner-login__button" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Logging in...' : 'Login'}
|
||||
|
||||
@@ -53,7 +53,7 @@ interface ChartData {
|
||||
packetTypes: { label: string; count: number }[];
|
||||
repeatersPerDay: { hour: string; count: number }[];
|
||||
hopDistribution: { hops: number; count: number }[];
|
||||
topChatters: { name: string; count: number }[];
|
||||
prefixCollisions:{ prefix: string; repeats: number }[];
|
||||
summary: {
|
||||
totalPackets24h: number;
|
||||
totalPackets7d: number;
|
||||
@@ -284,7 +284,7 @@ export const StatsPage: React.FC = () => {
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* ── Hop distribution + top nodes ─────────────────────────────── */}
|
||||
{/* ── Hop distribution + prefix collisions ─────────────────────── */}
|
||||
<div className="stats-page__row" style={{ marginBottom: 64 }}>
|
||||
<ChartCard title="Hop count distribution" sub="last 7 days">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
@@ -298,18 +298,27 @@ export const StatsPage: React.FC = () => {
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Most active chatters" sub="public channel · by message count · last 7 days" tall>
|
||||
<ChartCard
|
||||
title="Repeated first-2-hex prefixes"
|
||||
sub="Top 10 by repeat count"
|
||||
tall
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={[...data.topChatters].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 16, left: 4, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid {...gridProps} horizontal={false} />
|
||||
<XAxis type="number" {...axisProps} />
|
||||
<YAxis type="category" dataKey="name" {...axisProps} width={110} tick={{ fill: LABEL_COLOR, fontSize: 10 }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" name="Messages" fill={C_PURPLE} fillOpacity={0.75} radius={[0, 3, 3, 0]} />
|
||||
<BarChart data={data.prefixCollisions} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis
|
||||
dataKey="prefix"
|
||||
{...axisProps}
|
||||
/>
|
||||
<YAxis
|
||||
{...axisProps}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
formatter={(value: number) => [value, 'Repeats']}
|
||||
/>
|
||||
<Bar dataKey="repeats" name="Repeats" fill={C_PURPLE} fillOpacity={0.8} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
Reference in new issue
Block a user