feat: add observer multi-select and collapsible filters to list pages

- Add observer multi-select (<select multiple size=2>) to Advertisements and
  Messages filter bars, populated from /api/v1/nodes?observer=true
- Make all filter sections collapsible via <details> on Nodes, Advertisements,
  and Messages pages; collapsed by default, auto-expands when active filters
  exist, preserves open state across auto-refresh ticks
- Add backend observer=true|false query param to GET /api/v1/nodes for
  observer-only or non-observer-only node filtering via subquery
- Change observed_by in Advertisements/Messages API from single public_key
  to list[str] with .in_() for multi-select support
- Fix router.js and api.js to handle array query params (duplicate keys
  promoted to arrays, .append() per element)
- Fix createFilterHandler to use FormData.getAll() for multi-value support
- Replace DaisyUI form-control/label/label-text classes with Tailwind-native
  equivalents (flex flex-col gap-1, flex items-center py-1, opacity-80 text-sm)
  since DaisyUI CSS is tree-shaken from the build output
- Thicker collapsible border (border-2 border-base-content/25) visible in
  both light and dark themes
- Bottom-align Filter/Clear buttons via two-row form layout
- Move Observer filter to last position on Advertisements page
- Add filter_observer_label i18n key
- Add tests for observer=true node filtering and multi observer params
This commit is contained in:
Louis King
2026-05-05 12:22:52 +01:00
parent 98efc9dd06
commit 9af90efee4
15 changed files with 1001 additions and 89 deletions
+53 -18
View File
@@ -84,22 +84,6 @@ class TestListAdvertisements:
assert len(data["items"]) == 1
assert data["items"][0]["node_tag_name"] == "Friendly Search Name"
def test_list_advertisements_filter_by_public_key(
self, client_no_auth, sample_advertisement
):
"""Test filtering advertisements by public key."""
response = client_no_auth.get(
f"/api/v1/advertisements?public_key={sample_advertisement.public_key}"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
response = client_no_auth.get("/api/v1/advertisements?public_key=nonexistent")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
class TestGetAdvertisement:
"""Tests for GET /advertisements/{id} endpoint."""
@@ -199,14 +183,14 @@ class TestListAdvertisementsFilters:
data = response.json()
assert len(data["items"]) == 1
def test_filter_by_observed_by(
def test_list_advertisements_filter_by_observed_by_single(
self,
client_no_auth,
sample_advertisement,
sample_advertisement_with_receiver,
receiver_node,
):
"""Test filtering advertisements by receiver node."""
"""Test filtering advertisements by a single receiver node."""
response = client_no_auth.get(
f"/api/v1/advertisements?observed_by={receiver_node.public_key}"
)
@@ -214,6 +198,57 @@ class TestListAdvertisementsFilters:
data = response.json()
assert len(data["items"]) == 1
def test_list_advertisements_filter_by_observed_by_multiple(
self,
client_no_auth,
api_db_session,
receiver_node,
):
"""Test filtering advertisements by multiple receiver nodes."""
# Create second receiver node
second_receiver = receiver_node.__class__(
public_key="2nd1232nd1232nd1232nd1232nd1232n",
name="SecondObserver",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add(second_receiver)
api_db_session.commit()
# Create two advertisements, each observed by a different receiver
ad1 = Advertisement(
public_key="ad1pubad1pubad1pubad1pubad1pubad",
name="AD1",
adv_type="CLIENT",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
)
ad2 = Advertisement(
public_key="ad2pubad2pubad2pubad2pubad2pubad",
name="AD2",
adv_type="CLIENT",
received_at=datetime.now(timezone.utc),
observer_node_id=second_receiver.id,
)
api_db_session.add_all([ad1, ad2])
api_db_session.commit()
# Filter by both receivers
response = client_no_auth.get(
f"/api/v1/advertisements?observed_by={receiver_node.public_key}&observed_by={second_receiver.public_key}"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
# Filter by just the first receiver
response = client_no_auth.get(
f"/api/v1/advertisements?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["name"] == "AD1"
def test_filter_by_since(self, client_no_auth, api_db_session):
"""Test filtering advertisements by since timestamp."""
now = datetime.now(timezone.utc)
+53 -2
View File
@@ -216,14 +216,14 @@ class TestListMessagesFilters:
data = response.json()
assert len(data["items"]) == 0
def test_filter_by_observed_by(
def test_filter_by_observed_by_single(
self,
client_no_auth,
sample_message,
sample_message_with_receiver,
receiver_node,
):
"""Test filtering messages by receiver node."""
"""Test filtering messages by a single receiver node."""
response = client_no_auth.get(
f"/api/v1/messages?observed_by={receiver_node.public_key}"
)
@@ -232,6 +232,57 @@ class TestListMessagesFilters:
assert len(data["items"]) == 1
assert data["items"][0]["text"] == sample_message_with_receiver.text
def test_filter_by_observed_by_multiple(
self,
client_no_auth,
api_db_session,
receiver_node,
):
"""Test filtering messages by multiple receiver nodes."""
# Create second receiver node
second_receiver = Node(
public_key="2ndmsg2ndmsg2ndmsg2ndmsg2ndmsg2n",
name="SecondMsgObserver",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add(second_receiver)
api_db_session.commit()
# Create two messages, each observed by a different receiver
msg1 = Message(
message_type="channel",
channel_idx=1,
text="Msg from receiver A",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
)
msg2 = Message(
message_type="channel",
channel_idx=2,
text="Msg from receiver B",
received_at=datetime.now(timezone.utc),
observer_node_id=second_receiver.id,
)
api_db_session.add_all([msg1, msg2])
api_db_session.commit()
# Filter by both receivers
response = client_no_auth.get(
f"/api/v1/messages?observed_by={receiver_node.public_key}&observed_by={second_receiver.public_key}"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
# Filter by just the first receiver
response = client_no_auth.get(
f"/api/v1/messages?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["text"] == "Msg from receiver A"
def test_filter_by_since(self, client_no_auth, api_db_session):
"""Test filtering messages by since timestamp."""
now = datetime.now(timezone.utc)
+38
View File
@@ -168,6 +168,44 @@ class TestListNodesFilters:
assert room_node.public_key in room_keys
assert name_only_room_node.public_key not in room_keys
def test_filter_by_observer_true(
self, client_no_auth, api_db_session, receiver_node
):
"""Test filtering nodes that have observed events."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Advertisement, Message
# This node has observed an ad and a message
advert = Advertisement(
public_key="obsflt1obsflt1obsflt1obsflt1ob",
name="ObservedAd",
adv_type="CLIENT",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
)
msg = Message(
message_type="channel",
channel_idx=1,
text="Observed msg",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
)
api_db_session.add_all([advert, msg])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?observer=true")
assert response.status_code == 200
data = response.json()
observer_keys = {item["public_key"] for item in data["items"]}
assert receiver_node.public_key in observer_keys
response = client_no_auth.get("/api/v1/nodes?observer=false")
assert response.status_code == 200
data = response.json()
non_observer_keys = {item["public_key"] for item in data["items"]}
assert receiver_node.public_key not in non_observer_keys
class TestGetNode:
"""Tests for GET /nodes/{public_key} endpoint."""