Compare commits

2 Commits

Author SHA1 Message Date
Louis King
cd4f0b91dc Various UI improvements 2025-12-08 22:07:46 +00:00
Louis King
a290db0491 Updated chart stats 2025-12-08 19:37:45 +00:00
8 changed files with 208 additions and 188 deletions

View File

@@ -228,15 +228,15 @@ async def get_activity(
days: Number of days to include (default 30, max 90)
Returns:
Daily advertisement counts for each day in the period
Daily advertisement counts for each day in the period (excluding today)
"""
# Limit to max 90 days
days = min(days, 90)
now = datetime.now(timezone.utc)
start_date = (now - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
# End at start of today (exclude today's incomplete data)
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
# Query advertisement counts grouped by date
# Use SQLite's date() function for grouping (returns string 'YYYY-MM-DD')
@@ -248,6 +248,7 @@ async def get_activity(
func.count().label("count"),
)
.where(Advertisement.received_at >= start_date)
.where(Advertisement.received_at < end_date)
.group_by(date_expr)
.order_by(date_expr)
)
@@ -280,14 +281,14 @@ async def get_message_activity(
days: Number of days to include (default 30, max 90)
Returns:
Daily message counts for each day in the period
Daily message counts for each day in the period (excluding today)
"""
days = min(days, 90)
now = datetime.now(timezone.utc)
start_date = (now - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
# End at start of today (exclude today's incomplete data)
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
# Query message counts grouped by date
date_expr = func.date(Message.received_at)
@@ -298,6 +299,7 @@ async def get_message_activity(
func.count().label("count"),
)
.where(Message.received_at >= start_date)
.where(Message.received_at < end_date)
.group_by(date_expr)
.order_by(date_expr)
)
@@ -331,14 +333,14 @@ async def get_node_count_history(
days: Number of days to include (default 30, max 90)
Returns:
Cumulative node count for each day in the period
Cumulative node count for each day in the period (excluding today)
"""
days = min(days, 90)
now = datetime.now(timezone.utc)
start_date = (now - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
# End at start of today (exclude today's incomplete data)
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
# Get all nodes with their creation dates
# Count nodes created on or before each date

View File

@@ -0,0 +1,63 @@
/**
* MeshCore Hub - Common JavaScript Utilities
*/
/**
* Format a timestamp as relative time (e.g., "2m", "1h", "2d")
* @param {string|Date} timestamp - ISO timestamp string or Date object
* @returns {string} Relative time string, or empty string if invalid
*/
function formatRelativeTime(timestamp) {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) return `${diffDay}d`;
if (diffHour > 0) return `${diffHour}h`;
if (diffMin > 0) return `${diffMin}m`;
return '<1m';
}
/**
* Populate all elements with data-timestamp attribute with relative time
*/
function populateRelativeTimestamps() {
document.querySelectorAll('[data-timestamp]:not([data-receiver-tooltip])').forEach(el => {
const timestamp = el.dataset.timestamp;
if (timestamp) {
el.textContent = formatRelativeTime(timestamp);
}
});
}
/**
* Populate receiver tooltip elements with name and relative time
*/
function populateReceiverTooltips() {
document.querySelectorAll('[data-receiver-tooltip]').forEach(el => {
const name = el.dataset.name || '';
const timestamp = el.dataset.timestamp;
const relTime = timestamp ? formatRelativeTime(timestamp) : '';
// Build tooltip: "NodeName (2m ago)" or just "NodeName" or just "2m ago"
let tooltip = name;
if (relTime) {
tooltip = name ? `${name} (${relTime} ago)` : `${relTime} ago`;
}
el.title = tooltip;
});
}
// Auto-populate when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
populateRelativeTimestamps();
populateReceiverTooltips();
});

View File

@@ -39,82 +39,46 @@
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th>Received By</th>
<th>Time</th>
<th>Receivers</th>
</tr>
</thead>
<tbody>
{% for ad in advertisements %}
<tr class="hover">
<td>
<a href="/nodes/{{ ad.public_key }}" class="link link-hover">
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
{% endif %}
<a href="/nodes/{{ ad.public_key }}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
{% if ad.node_tag_name or ad.node_name or ad.name %}
<div class="font-medium">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.public_key[:16] }}...</span>
{% endif %}
</div>
</a>
</td>
<td>
{% if ad.adv_type and ad.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif ad.adv_type and ad.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif ad.adv_type %}
<span title="{{ ad.adv_type }}">📍</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td>
{% if ad.receivers and ad.receivers|length > 1 %}
<div class="dropdown dropdown-hover dropdown-end">
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
{{ ad.receivers|length }} receivers
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
{% for recv in ad.receivers %}
<li>
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% elif ad.receivers and ad.receivers|length == 1 %}
<a href="/nodes/{{ ad.receivers[0].public_key }}" class="link link-hover">
{% if ad.receivers[0].tag_name or ad.receivers[0].name %}
<div class="font-medium">{{ ad.receivers[0].tag_name or ad.receivers[0].name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.receivers[0].public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.receivers[0].public_key[:16] }}...</span>
{% endif %}
</a>
{% elif ad.received_by %}
<a href="/nodes/{{ ad.received_by }}" class="link link-hover">
{% if ad.receiver_tag_name or ad.receiver_name %}
<div class="font-medium">{{ ad.receiver_tag_name or ad.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ ad.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ ad.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-sm whitespace-nowrap">
{{ ad.received_at[:19].replace('T', ' ') if ad.received_at else '-' }}
</td>
<td>
{% if ad.receivers and ad.receivers|length >= 1 %}
<div class="flex gap-1">
{% for recv in ad.receivers %}
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
{% endfor %}
</div>
{% elif ad.received_by %}
<a href="/nodes/{{ ad.received_by }}" class="text-lg hover:opacity-70" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-8 opacity-70">No advertisements found.</td>
<td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -121,6 +121,9 @@
<!-- Leaflet JS for maps -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Common utilities -->
<script src="/static/js/utils.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -52,12 +52,6 @@
<!-- Populated dynamically -->
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">Infrastructure Only</span>
<input type="checkbox" id="filter-infra" class="checkbox checkbox-sm checkbox-primary" />
</label>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
</div>
</div>
@@ -88,14 +82,10 @@
<span class="text-lg">📍</span>
<span>Other</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg" style="filter: drop-shadow(0 0 4px gold);">📡</span>
<span>Infrastructure (gold glow)</span>
</div>
</div>
<div class="mt-2 text-sm opacity-70">
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags. Infrastructure nodes are tagged with <code>role: infra</code>.</p>
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
</div>
{% endblock %}
@@ -120,23 +110,7 @@
return type ? type.toLowerCase() : null;
}
// Format relative time (e.g., "2m", "1h", "2d")
function formatRelativeTime(lastSeenStr) {
if (!lastSeenStr) return null;
const lastSeen = new Date(lastSeenStr);
const now = new Date();
const diffMs = now - lastSeen;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) return `${diffDay}d`;
if (diffHour > 0) return `${diffHour}h`;
if (diffMin > 0) return `${diffMin}m`;
return '<1m';
}
// formatRelativeTime is provided by /static/js/utils.js
// Get emoji marker based on node type
function getNodeEmoji(node) {
@@ -159,14 +133,13 @@
// Create marker icon for a node
function createNodeIcon(node) {
const emoji = getNodeEmoji(node);
const infraGlow = node.is_infra ? 'filter: drop-shadow(0 0 4px gold);' : '';
const displayName = node.name || '';
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
return L.divIcon({
className: 'custom-div-icon',
html: `<div style="display: flex; align-items: center; gap: 2px;">
<span style="font-size: 24px; ${infraGlow} text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
</div>`,
iconSize: [82, 28],
@@ -186,8 +159,7 @@
let roleHtml = '';
if (node.role) {
const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs ${roleClass}">${node.role}</span></p>`;
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
}
const emoji = getNodeEmoji(node);
@@ -219,16 +191,12 @@
function applyFiltersCore() {
const typeFilter = document.getElementById('filter-type').value;
const ownerFilter = document.getElementById('filter-owner').value;
const infraOnly = document.getElementById('filter-infra').checked;
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Type filter (case-insensitive)
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
// Infrastructure filter
if (infraOnly && !node.is_infra) return false;
// Owner filter
if (ownerFilter) {
if (!node.owner || node.owner.public_key !== ownerFilter) return false;
@@ -314,14 +282,12 @@
function clearFilters() {
document.getElementById('filter-type').value = '';
document.getElementById('filter-owner').value = '';
document.getElementById('filter-infra').checked = false;
applyFilters();
}
// Event listeners for filters
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-owner').addEventListener('change', applyFilters);
document.getElementById('filter-infra').addEventListener('change', applyFilters);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes

View File

@@ -57,19 +57,15 @@
<th>Time</th>
<th>From/Channel</th>
<th>Message</th>
<th>Receiver</th>
<th>SNR</th>
<th class="text-center">SNR</th>
<th>Receivers</th>
</tr>
</thead>
<tbody>
{% for msg in messages %}
<tr class="hover align-top">
<td>
{% if msg.message_type == 'channel' %}
<span class="badge badge-info badge-sm">Channel</span>
{% else %}
<span class="badge badge-success badge-sm">Direct</span>
{% endif %}
<td class="text-lg" title="{{ msg.message_type|capitalize }}">
{% if msg.message_type == 'channel' %}📻{% else %}👤{% endif %}
</td>
<td class="text-sm whitespace-nowrap">
{{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }}
@@ -86,47 +82,6 @@
{% endif %}
</td>
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
<td>
{% if msg.receivers and msg.receivers|length > 1 %}
<div class="dropdown dropdown-hover dropdown-end">
<label tabindex="0" class="badge badge-outline badge-sm cursor-pointer">
{{ msg.receivers|length }} receivers
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56">
{% for recv in msg.receivers %}
<li>
<a href="/nodes/{{ recv.public_key }}" class="text-sm">
<span class="flex-1">{{ recv.tag_name or recv.name or recv.public_key[:12] + '...' }}</span>
{% if recv.snr is not none %}
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(recv.snr) }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
{% elif msg.receivers and msg.receivers|length == 1 %}
<a href="/nodes/{{ msg.receivers[0].public_key }}" class="link link-hover">
{% if msg.receivers[0].tag_name or msg.receivers[0].name %}
<div class="font-medium">{{ msg.receivers[0].tag_name or msg.receivers[0].name }}</div>
<div class="text-xs font-mono opacity-70">{{ msg.receivers[0].public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ msg.receivers[0].public_key[:16] }}...</span>
{% endif %}
</a>
{% elif msg.received_by %}
<a href="/nodes/{{ msg.received_by }}" class="link link-hover">
{% if msg.receiver_tag_name or msg.receiver_name %}
<div class="font-medium">{{ msg.receiver_tag_name or msg.receiver_name }}</div>
<div class="text-xs font-mono opacity-70">{{ msg.received_by[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ msg.received_by[:16] }}...</span>
{% endif %}
</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-center whitespace-nowrap">
{% if msg.snr is not none %}
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
@@ -134,6 +89,19 @@
-
{% endif %}
</td>
<td>
{% if msg.receivers and msg.receivers|length >= 1 %}
<div class="flex gap-1">
{% for recv in msg.receivers %}
<a href="/nodes/{{ recv.public_key }}" class="text-lg hover:opacity-70" data-receiver-tooltip data-name="{{ recv.tag_name or recv.name or recv.public_key[:12] }}" data-timestamp="{{ recv.received_at }}">📡</a>
{% endfor %}
</div>
{% elif msg.received_by %}
<a href="/nodes/{{ msg.received_by }}" class="text-lg hover:opacity-70" title="{{ msg.receiver_tag_name or msg.receiver_name or msg.received_by[:12] }}">📡</a>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>

View File

@@ -50,7 +50,6 @@
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th>Last Seen</th>
<th>Tags</th>
</tr>
@@ -65,28 +64,18 @@
{% endfor %}
<tr class="hover">
<td>
<a href="/nodes/{{ node.public_key }}" class="link link-hover">
{% if ns.tag_name or node.name %}
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
{% endif %}
<a href="/nodes/{{ node.public_key }}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
<div>
{% if ns.tag_name or node.name %}
<div class="font-medium">{{ ns.tag_name or node.name }}</div>
<div class="text-xs font-mono opacity-70">{{ node.public_key[:16] }}...</div>
{% else %}
<span class="font-mono text-sm">{{ node.public_key[:16] }}...</span>
{% endif %}
</div>
</a>
</td>
<td>
{% if node.adv_type and node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type and node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type and node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% elif node.adv_type %}
<span title="{{ node.adv_type }}">📍</span>
{% else %}
<span class="opacity-50">-</span>
{% endif %}
</td>
<td class="text-sm whitespace-nowrap">
{% if node.last_seen %}
{{ node.last_seen[:19].replace('T', ' ') }}
@@ -111,7 +100,7 @@
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-8 opacity-70">No nodes found.</td>
<td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,5 +1,11 @@
"""Tests for dashboard API routes."""
from datetime import datetime, timedelta, timezone
import pytest
from meshcore_hub.common.models import Advertisement, Message, Node
class TestDashboardStats:
"""Tests for GET /dashboard/stats endpoint."""
@@ -63,6 +69,21 @@ class TestDashboardHtml:
class TestDashboardActivity:
"""Tests for GET /dashboard/activity endpoint."""
@pytest.fixture
def past_advertisement(self, api_db_session):
"""Create an advertisement from yesterday (since today is excluded)."""
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
advert = Advertisement(
public_key="abc123def456abc123def456abc123de",
name="TestNode",
adv_type="REPEATER",
received_at=yesterday,
)
api_db_session.add(advert)
api_db_session.commit()
api_db_session.refresh(advert)
return advert
def test_get_activity_empty(self, client_no_auth):
"""Test getting activity with empty database."""
response = client_no_auth.get("/api/v1/dashboard/activity")
@@ -91,8 +112,12 @@ class TestDashboardActivity:
assert data["days"] == 90
assert len(data["data"]) == 90
def test_get_activity_with_data(self, client_no_auth, sample_advertisement):
"""Test getting activity with advertisement in database."""
def test_get_activity_with_data(self, client_no_auth, past_advertisement):
"""Test getting activity with advertisement in database.
Note: Activity endpoints exclude today's data to avoid showing
incomplete stats early in the day.
"""
response = client_no_auth.get("/api/v1/dashboard/activity")
assert response.status_code == 200
data = response.json()
@@ -104,6 +129,21 @@ class TestDashboardActivity:
class TestMessageActivity:
"""Tests for GET /dashboard/message-activity endpoint."""
@pytest.fixture
def past_message(self, api_db_session):
"""Create a message from yesterday (since today is excluded)."""
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
message = Message(
message_type="direct",
pubkey_prefix="abc123",
text="Hello World",
received_at=yesterday,
)
api_db_session.add(message)
api_db_session.commit()
api_db_session.refresh(message)
return message
def test_get_message_activity_empty(self, client_no_auth):
"""Test getting message activity with empty database."""
response = client_no_auth.get("/api/v1/dashboard/message-activity")
@@ -132,8 +172,12 @@ class TestMessageActivity:
assert data["days"] == 90
assert len(data["data"]) == 90
def test_get_message_activity_with_data(self, client_no_auth, sample_message):
"""Test getting message activity with message in database."""
def test_get_message_activity_with_data(self, client_no_auth, past_message):
"""Test getting message activity with message in database.
Note: Activity endpoints exclude today's data to avoid showing
incomplete stats early in the day.
"""
response = client_no_auth.get("/api/v1/dashboard/message-activity")
assert response.status_code == 200
data = response.json()
@@ -145,6 +189,23 @@ class TestMessageActivity:
class TestNodeCountHistory:
"""Tests for GET /dashboard/node-count endpoint."""
@pytest.fixture
def past_node(self, api_db_session):
"""Create a node from yesterday (since today is excluded)."""
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
node = Node(
public_key="abc123def456abc123def456abc123de",
name="Test Node",
adv_type="REPEATER",
first_seen=yesterday,
last_seen=yesterday,
created_at=yesterday,
)
api_db_session.add(node)
api_db_session.commit()
api_db_session.refresh(node)
return node
def test_get_node_count_empty(self, client_no_auth):
"""Test getting node count with empty database."""
response = client_no_auth.get("/api/v1/dashboard/node-count")
@@ -173,8 +234,12 @@ class TestNodeCountHistory:
assert data["days"] == 90
assert len(data["data"]) == 90
def test_get_node_count_with_data(self, client_no_auth, sample_node):
"""Test getting node count with node in database."""
def test_get_node_count_with_data(self, client_no_auth, past_node):
"""Test getting node count with node in database.
Note: Activity endpoints exclude today's data to avoid showing
incomplete stats early in the day.
"""
response = client_no_auth.get("/api/v1/dashboard/node-count")
assert response.status_code == 200
data = response.json()