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
@@ -0,0 +1,515 @@
# Plan: Improve Filter Options — Remove Node Filter & Add Observer Multi-Select
**Date**: 2026-05-05
**Status**: Draft
---
## Summary
Two changes to the Advertisements and Messages pages:
1. **Remove the Node filter** from the Advertisements page (frontend dropdown + API query param `public_key`)
2. **Add an Observer multi-select filter** to both Advertisements and Messages pages, using a standard `<select multiple>` inside a collapsible filter section (DaisyUI `collapse`, collapsed by default)
The collapsible section solves the vertical space concern: the multi-select only consumes space when the user expands the filter panel.
---
## Current State
| Feature | Ads Backend | Ads Frontend | Msgs Backend | Msgs Frontend |
|---|---|---|---|---|
| `public_key` (node filter) | Yes | **Yes (remove)** | N/A | N/A |
| `observed_by` (observer) | Yes (single) | **No** | Yes (single) | **No** |
| Search text | Yes | Yes | Yes | No |
| `since`/`until` timestamps | Yes | No | Yes | No |
| `adopted_by` (member) | Yes | Yes (OIDC cond.) | N/A | N/A |
| `message_type` | N/A | N/A | Yes | Yes |
| `channel_idx` | N/A | N/A | Yes | Yes |
| `pubkey_prefix` (sender) | N/A | N/A | Yes | No |
### Key files
- **Advertisements API**: `src/meshcore_hub/api/routes/advertisements.py``list_advertisements()` endpoint, query params at lines 4259
- **Messages API**: `src/meshcore_hub/api/routes/messages.py``list_messages()` endpoint, query params at lines 2943
- **Advertisements frontend**: `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — filter form at lines 179215, node filter at lines 7389
- **Messages frontend**: `src/meshcore_hub/web/static/js/spa/pages/messages.js` — filter form at lines 306334
- **Shared components**: `src/meshcore_hub/web/static/js/spa/components.js``renderFilterCard` (line 659), `createFilterHandler` (line 555), `pagination` (line 419)
- **Router**: `src/meshcore_hub/web/static/js/spa/router.js` — query parsing at line 100
- **API client**: `src/meshcore_hub/web/static/js/spa/api.js``apiGet` at line 13
- **i18n**: `src/meshcore_hub/web/static/locales/en.json`
- **DaisyUI collapse**: `node_modules/daisyui/components/collapse.css` — supports both checkbox-based and `<details>`-based toggling
---
## Change 1: Remove Node Filter from Advertisements
**Rationale**: The `public_key` filter filters by the originating node. Since ads already show the originating node in the table and users navigate from there, this filter adds little value beyond the existing `search` field (which already matches node names, tag names, and public keys with `ILIKE` wildcards). Removing it simplifies the UI and frees its slot for the observer filter.
### 1a. Frontend — `advertisements.js`
Lines to remove/change:
- **Line 14**: Remove `const public_key = query.public_key || '';`
- **Line 54**: Remove `public_key` from `apiParams``const apiParams = { limit, offset, search };`
- **Lines 7389**: Remove the entire `sortedNodes` mapping + `nodesFilter` template block. The `/api/v1/nodes` fetch (line 58) is **kept** and repurposed to populate the observer multi-select (see Change 2e).
- **Lines 188190**: Remove `if (sortedNodes.length > 0) { filterFields.push(() => nodesFilter); }`
- **Line 176**: Remove `public_key` from pagination params
### 1b. Backend — `advertisements.py`
- **Lines 4849**: Remove `public_key: Optional[str] = Query(None, description="Filter by public key")`
- **Lines 9798**: Remove `if public_key: query = query.where(Advertisement.public_key == public_key)`
### 1c. Tests
- `tests/test_api/test_advertisements.py` — remove or update tests exercising the `public_key` query parameter
---
## Change 2: Add Observer Multi-Select Filter
**Rationale**: Both APIs already support `observed_by` (filtering by which observer node received the event) as a single-value parameter. Making it multi-value and exposing it in the frontend lets users filter events by one or more observer nodes. Wrapping the filter form in a collapsible section keeps the multi-select from permanently consuming vertical space.
### 2a. Backend — Both API Routes
**`advertisements.py`** (lines 5052, 100101):
```python
# Before
observed_by: Optional[str] = Query(None, description="Filter by receiver node public key")
# After
observed_by: Optional[list[str]] = Query(None, description="Filter by receiver node public keys")
```
```python
# Before
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
# After
if observed_by:
query = query.where(ObserverNode.public_key.in_(observed_by))
```
**`messages.py`** (lines 3638, 6667): Same changes.
FastAPI natively supports `?observed_by=key1&observed_by=key2` for `list[str]` query params. A single value (`?observed_by=key1`) is parsed as `["key1"]` — fully backward-compatible with `.in_()`.
### 2b. Router Multi-Value Query Parsing — `router.js`
**File**: `router.js`, line 100
**Problem**: `Object.fromEntries(new URLSearchParams(window.location.search))` **overwrites duplicate keys**. For `?observed_by=a&observed_by=b`, the result is `{ observed_by: "b" }` — only the last value survives.
**Fix**: Modify query parsing to promote duplicate keys to arrays, keeping single values as strings:
```js
// Before (line 100)
const query = Object.fromEntries(new URLSearchParams(window.location.search));
// After
const sp = new URLSearchParams(window.location.search);
const query = {};
for (const [k, v] of sp.entries()) {
if (k in query) {
query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v];
} else {
query[k] = v;
}
}
```
Behavior: `?search=foo``{ search: "foo" }` (string). `?observed_by=a&observed_by=b``{ observed_by: ["a", "b"] }` (array). `?observed_by=a``{ observed_by: "a" }` (string — single values unchanged). Backward-compatible with all existing pages.
### 2c. API Client Array Params — `api.js`
**File**: `api.js`, `apiGet` function (line 1318)
**Problem**: `url.searchParams.set(k, String(v))` converts `['a','b']` to `"a,b"` instead of separate `observed_by=a&observed_by=b` entries.
**Fix**: Detect array values and call `.append()` per element:
```js
// Before
export async function apiGet(path, params = {}) {
const url = new URL(path, window.location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') {
url.searchParams.set(k, String(v));
}
}
// ...
}
// After
export async function apiGet(path, params = {}) {
const url = new URL(path, window.location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') {
if (Array.isArray(v)) {
v.forEach(item => url.searchParams.append(k, String(item)));
} else {
url.searchParams.set(k, String(v));
}
}
}
// ...
}
```
### 2d. Pagination Array Params — `components.js`
**File**: `components.js`, `pagination` function (lines 419428)
**Problem**: `encodeURIComponent(v)` on an array serializes to `"a%2Cb"` — wrong.
**Fix**: Handle array values by appending multiple key-value pairs:
```js
// Before
for (const [k, v] of Object.entries(params)) {
if (k !== 'page' && v !== null && v !== undefined && v !== '') {
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
}
// After
for (const [k, v] of Object.entries(params)) {
if (k === 'page' || v === null || v === undefined || v === '') continue;
if (Array.isArray(v)) {
v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`));
} else {
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
}
```
### 2e. Multi-Value Form Handler — `components.js`
**File**: `components.js`, function `createFilterHandler` (lines 555566)
The current handler uses `params.set(k, v)` which overwrites duplicate keys. Multi-value form fields (`<select multiple>`) produce multiple entries with the same name — these need `params.append(k, v)`.
```js
// Before
export function createFilterHandler(basePath, navigate) {
return (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams();
for (const [k, v] of formData.entries()) {
if (v) params.set(k, v);
}
const queryStr = params.toString();
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
};
}
// After
export function createFilterHandler(basePath, navigate) {
return (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams();
const keys = new Set(formData.keys());
for (const k of keys) {
for (const v of formData.getAll(k)) {
if (v) params.append(k, v);
}
}
const queryStr = params.toString();
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
};
}
```
Backward-compatible: single-select `<select>` elements produce one entry per key, so `append` with one value behaves identically to `set`.
### 2f. Observer Multi-Select Field Template
Standard `<select multiple>` with DaisyUI classes:
```html
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.filter_observer_label')}</span>
</label>
<select name="observed_by" multiple size="6"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>
```
Key design decisions:
- `size="6"` — shows 6 rows; scrollable when there are more nodes (browser-native scrollbar)
- `max-w-xs` — prevents the select from growing too wide
- No `@change=${autoSubmit}` — user makes selections then clicks the Filter button (inside the collapse)
- Pre-selection via `?selected=` binds to URL state
- Nodes are sorted by display name (same as existing node dropdown in ads)
### 2g. Collapsible Filter Section — `components.js`
Modify `renderFilterCard` to accept a `collapsible` option. When enabled, the form is wrapped in a DaisyUI `collapse` component using native `<details>`/`<summary>`.
**DaisyUI collapse with `<details>`:**
```html
<details class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-box mb-6"
?open=${defaultOpen}>
<summary class="collapse-title text-sm font-medium cursor-pointer">
Filters
</summary>
<div class="collapse-content pt-4">
<!-- form goes here -->
</div>
</details>
```
DaisyUI's collapse CSS responds to the native `[open]` attribute on `<details>`, with an animated expand/collapse via `grid-template-rows` transition. Clicking the `<summary>` toggles `open` natively (no JS, no checkbox). `?open=${defaultOpen}` sets the initial state from lit-html.
**Updated `renderFilterCard` signature:**
```js
export function renderFilterCard({
fields, basePath, navigate,
submitLabel, clearLabel,
collapsible = false, // NEW: wrap in <details> collapse
defaultOpen = false, // NEW: <details open> when active filters exist
}) { ... }
```
**Behavior:**
- When `collapsible: true` and `defaultOpen: false` → collapse starts closed
- When `collapsible: true` and `defaultOpen: true` → collapse starts expanded (user sees active filters)
- Page modules compute `defaultOpen` by checking whether any filter is active (e.g., `search !== '' || observed_by.length > 0 || ...`)
- Clicking the summary title toggles open/closed with DaisyUI's animated transition
**Structure when collapsible:**
```
┌──────────────────────────────────────┐
│ Filters ▼ │ ← <summary> (always visible)
├──────────────────────────────────────┤
│ [search input] [Observer multi] │ ← <div class="collapse-content">
│ [Member select] │ (hidden when closed)
│ [Filter btn] [Clear] │
└──────────────────────────────────────┘
```
**Implementation for `renderFilterCard`:**
```js
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) {
const formBody = html`
<form method="GET" action=${basePath}
class="flex gap-4 flex-wrap items-end"
@submit=${createFilterHandler(basePath, navigate)}>
${fields.map(f => f())}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">
${submitLabel || t('common.filter')}
</button>
<a href=${basePath} class="btn btn-ghost btn-sm">
${clearLabel || t('common.clear')}
</a>
</div>
</form>
`;
if (!collapsible) {
return html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">${formBody}</div>
</div>
`;
}
return html`
<details class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-box mb-6"
?open=${defaultOpen}>
<summary class="collapse-title text-sm font-medium cursor-pointer">
${t('common.filters')}
</summary>
<div class="collapse-content pt-4">
${formBody}
</div>
</details>
`;
}
```
Note: When the collapse is closed, the form fields are still in the DOM (only visually clipped via `overflow: hidden`). The Filter button is only visible when expanded, so submission only happens with user intent.
**Collapse state preservation across auto-refresh:** Both pages use `createAutoRefresh` which calls `fetchAndRenderData` periodically, re-rendering the entire page (including the filter card). Without mitigation, `?open=${defaultOpen}` would reset the collapse state on every refresh tick.
**Fix**: Before re-rendering, read the current `<details>.open` DOM state and pass it as `defaultOpen`:
```js
// In fetchAndRenderData, before building the filter card:
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/advertisements',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
```
This preserves the user's collapse toggle across re-renders.
### 2h. Frontend — Advertisements Page
**File**: `advertisements.js`
- **Extract** `observed_by` from URL (via router, which now preserves multi-value as array):
```js
const observed_by = query.observed_by
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
: [];
```
- **Repurpose** the `/api/v1/nodes` fetch (line 58, currently for old node filter) to populate the observer dropdown. Keep `sortedNodes` mapping with `_displayName` and `_sortName` props.
- **Replace** the old `nodesFilter` `<select>` block with the observer multi-select:
```js
const observerFilter = sortedNodes.length > 0
? () => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.filter_observer_label')}</span>
</label>
<select name="observed_by" multiple size="6"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>`
: nothing;
```
- **Update** `filterFields` array — observer field goes where node filter was (between search and member):
```js
const filterFields = [/* search field */];
if (sortedNodes.length > 0) {
filterFields.push(() => observerFilter); // note: wrapped in closure for lazy render
}
if (config.oidc_enabled && profiles.length > 0) {
filterFields.push(/* member field */);
}
```
- **Pass** `observed_by` array in API params (now supported by `apiGet`):
```js
const apiParams = { limit, offset, search };
if (observed_by.length > 0) {
apiParams.observed_by = observed_by;
}
```
- **Update** pagination to include `observed_by` as array (now supported by `pagination`).
- **Enable collapsible** mode with state preservation:
```js
const hasActiveFilters = search !== '' || observed_by.length > 0 || (config.oidc_enabled && adopted_by !== '');
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/advertisements',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
```
### 2i. Frontend — Messages Page
**File**: `messages.js`
- **Extract** `observed_by` from URL (same pattern as ads)
- **Add** node fetch: `apiGet('/api/v1/nodes', { limit: 500 })` — messages page currently does NOT fetch extra data
- **Build** same observer `<select multiple>` as advertisements
- **Add** to `filterFields` array (after type and channel dropdowns)
- **Pass** `observed_by` in API params
- **Update** pagination to include `observed_by`
- **Enable collapsible** mode with state preservation:
```js
const hasActiveFilters = message_type !== '' || channel_idx !== '' || observed_by.length > 0;
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/messages',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
```
---
## Change 3: i18n Updates
**File**: `src/meshcore_hub/web/static/locales/en.json`
Add under `"common"`:
```json
"filters": "Filters",
"filter_observer_label": "Observer"
```
(`"observers": "Observers"` already exists at line 98 — needed for observer display, not the filter label.)
---
## Files Changed — Summary
| File | Change |
|---|---|
| `src/meshcore_hub/web/static/js/spa/router.js` | Query parsing: promote duplicate keys to arrays |
| `src/meshcore_hub/web/static/js/spa/api.js` | `apiGet`: detect array param values, call `.append()` per element |
| `src/meshcore_hub/web/static/js/spa/components.js` | `createFilterHandler`: use `getAll`/`append`; `pagination`: handle array params; `renderFilterCard`: add `collapsible` + `defaultOpen` with `<details>`/`<summary>` |
| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Remove node filter; add observer `<select multiple>`; enable collapsible mode with state preservation |
| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Add observer `<select multiple>` + node fetch; enable collapsible mode with state preservation |
| `src/meshcore_hub/api/routes/advertisements.py` | Remove `public_key` param + WHERE clause; `observed_by` → `list[str]` |
| `src/meshcore_hub/api/routes/messages.py` | `observed_by` → `list[str]` |
| `src/meshcore_hub/web/static/locales/en.json` | Add `filters`, `filter_observer_label` |
| `tests/test_api/test_advertisements.py` | Remove `public_key` tests; add multi-observer tests |
| `tests/test_api/test_messages.py` | Add multi-observer tests |
---
## Verification
```bash
# Targeted backend tests
pytest tests/test_api/test_advertisements.py -v
pytest tests/test_api/test_messages.py -v
# Quality checks
pre-commit run --all-files
```
---
## Risks & Mitigations
| Risk | Mitigation |
|---|---|
| `createFilterHandler` change breaks single-select filters | `formData.getAll()` returns single-element arrays for single-select fields; `append` with one value ≡ `set` |
| Dropped node filter confuses users | The `search` field already finds nodes by name, tag name, or public key prefix with `ILIKE` wildcards |
| Router multi-value parsing affects other pages | Single-value params remain strings (unchanged); only duplicate keys become arrays; no current page ever sends duplicate keys |
| `apiGet` array handling affects other callers | Non-array values pass through `else` branch unchanged; only callers that pass arrays are the new observer pages |
| Collapse state resets on auto-refresh | DOM state read from `container.querySelector('details.collapse').open` before each re-render preserves toggle state |
| Large observer node list makes `<select>` unwieldy | Node fetch limited to 500 (same as existing ads page); `size="6"` with scrollbar handles overflow |
| DaisyUI collapse animation performance | CSS `grid-template-rows` transition with `prefers-reduced-motion` support — well-behaved |
@@ -0,0 +1,110 @@
# Tasks — Improve Filter Options
## Phase 1: Backend API Changes
- [x] **1.1** Remove `public_key` filter from `src/meshcore_hub/api/routes/advertisements.py`
- Remove `public_key: Optional[str] = Query(...)` param declaration (lines 4849)
- Remove `if public_key: query = query.where(Advertisement.public_key == public_key)` WHERE clause (lines 9798)
- [x] **1.2** Change `observed_by` to `list[str]` in `src/meshcore_hub/api/routes/advertisements.py`
- Change type: `Optional[str]``Optional[list[str]]` (line 50)
- Change WHERE: `== observed_by``.in_(observed_by)` (line 101)
- Update `asyncio.gather` to remove `public_key` from fetch if present (lines 8596, review only)
- [x] **1.3** Change `observed_by` to `list[str]` in `src/meshcore_hub/api/routes/messages.py`
- Change type: `Optional[str]``Optional[list[str]]` (line 36)
- Change WHERE: `== observed_by``.in_(observed_by)` (line 67)
## Phase 2: Frontend Infrastructure Fixes
- [x] **2.1** Fix router query parsing in `src/meshcore_hub/web/static/js/spa/router.js` (line 100)
- Replace `Object.fromEntries(new URLSearchParams(...))` with loop that promotes duplicate keys to arrays
- Single values remain strings; duplicate keys become `[value1, value2]`
- [x] **2.2** Add array param support to `apiGet` in `src/meshcore_hub/web/static/js/spa/api.js` (line 17)
- Detect `Array.isArray(v)` and call `url.searchParams.append(k, String(item))` per element
- Non-array values pass through existing `url.searchParams.set(k, String(v))`
- [x] **2.3** Add array param support to `pagination` in `src/meshcore_hub/web/static/js/spa/components.js` (lines 423427)
- Same array-handling pattern: `Array.isArray(v)` → append per element, otherwise `encodeURIComponent`
- [x] **2.4** Fix `createFilterHandler` for multi-value in `src/meshcore_hub/web/static/js/spa/components.js` (lines 558562)
- Replace `params.set(k, v)` with `params.append(k, v)`
- Iterate `new Set(formData.keys())` to get unique keys, then use `formData.getAll(k)` per key
## Phase 3: Collapsible Filter Section
- [x] **3.1** Update `renderFilterCard` signature in `src/meshcore_hub/web/static/js/spa/components.js` (line 659)
- Add `collapsible = false` and `defaultOpen = false` parameters
- [x] **3.2** Implement collapsible rendering in `renderFilterCard`
- When `!collapsible`: render existing card layout unchanged
- When `collapsible`: wrap form in `<details class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-box mb-6" ?open=${defaultOpen}>` with `<summary class="collapse-title text-sm font-medium cursor-pointer">${t('common.filters')}</summary>` and form in `<div class="collapse-content pt-4">`
## Phase 4: Advertisements Page
- [x] **4.1** Remove node filter from `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
- Remove `const public_key = query.public_key || '';` (line 14)
- Remove `public_key` from `apiParams` (`apiParams.append` → direct object property) (line 54)
- Remove `public_key` from pagination params (line 176)
- Remove `nodesFilter` template block (lines 7889)
- Keep `/api/v1/nodes` fetch (line 58) and `sortedNodes` mapping (lines 7376) for observer dropdown
- [x] **4.2** Add observer multi-select to advertisements page
- Extract `observed_by` from router query: handle both string and array (guard with `Array.isArray()`)
- Add observer `<select multiple size="6">` template using `sortedNodes` with DaisyUI classes
- Push observer field to `filterFields` array (between search and member, guarded by `sortedNodes.length > 0`)
- Pass `observed_by` array in `apiParams` when non-empty
- [x] **4.3** Enable collapsible mode with state preservation on advertisements page
- Compute `hasActiveFilters` from `search`, `observed_by.length`, and `adopted_by` (when OIDC enabled)
- Before each re-render, read `container.querySelector('details.collapse').open` from DOM
- Pass `collapsible: true` and `defaultOpen: isFilterOpen` to `renderFilterCard`
## Phase 5: Messages Page
- [x] **5.1** Add node fetch to `src/meshcore_hub/web/static/js/spa/pages/messages.js`
- Add `apiGet('/api/v1/nodes', { limit: 500 })` in `fetchAndRenderData`
- Build `sortedNodes` mapping with `_displayName` and `_sortName` (same pattern as ads)
- [x] **5.2** Add observer multi-select to messages page
- Extract `observed_by` from router query (same pattern as ads)
- Add observer `<select multiple size="6">` template using `sortedNodes`
- Push observer field to `filterFields` array (after type and channel selects)
- Pass `observed_by` array in `apiParams` when non-empty
- Include `observed_by` in pagination params
- [x] **5.3** Enable collapsible mode with state preservation on messages page
- Compute `hasActiveFilters` from `message_type`, `channel_idx`, and `observed_by.length`
- Before each re-render, read `container.querySelector('details.collapse').open` from DOM
- Pass `collapsible: true` and `defaultOpen: isFilterOpen` to `renderFilterCard`
## Phase 6: i18n
- [x] **6.1** Add new keys to `src/meshcore_hub/web/static/locales/en.json` under `"common"`:
- `"filters": "Filters"` — collapse title
- `"filter_observer_label": "Observer"` — multi-select label
## Phase 7: Tests
- [x] **7.1** Update `tests/test_api/test_advertisements.py`
- Remove tests exercising the `public_key` query parameter
- Add test: single observer filter returns matching ads
- Add test: multiple observer filter returns ads from any matching observer
- [x] **7.2** Update `tests/test_api/test_messages.py`
- Add test: single observer filter returns matching messages
- Add test: multiple observer filter returns messages from any matching observer
## Verification
- [x] Run `pytest tests/test_api/test_advertisements.py -v`
- [x] Run `pytest tests/test_api/test_messages.py -v`
- [x] Manually verify in browser:
- Advertisements page: no node filter, observer multi-select in collapsible section
- Messages page: observer multi-select in collapsible section
- Multi-value selection → URL reflects `?observed_by=a&observed_by=b`
- Pagination preserves observer params
- Auto-refresh preserves collapse state
- Backward-compatible: single-select filters still work via Filter button
- [x] Run `pre-commit run --all-files`
@@ -46,9 +46,8 @@ async def list_advertisements(
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
public_key: Optional[str] = Query(None, description="Filter by public key"),
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
observed_by: Optional[list[str]] = Query(
None, description="Filter by receiver node public keys"
),
adopted_by: Optional[str] = Query(
None, description="Filter by adopting user profile UUID"
@@ -94,11 +93,8 @@ async def list_advertisements(
)
)
if public_key:
query = query.where(Advertisement.public_key == public_key)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
query = query.where(ObserverNode.public_key.in_(observed_by))
if adopted_by:
query = query.where(
+3 -3
View File
@@ -33,8 +33,8 @@ async def list_messages(
message_type: Optional[str] = Query(None, description="Filter by message type"),
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
observed_by: Optional[list[str]] = Query(
None, description="Filter by receiver node public keys"
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
until: Optional[datetime] = Query(None, description="End timestamp"),
@@ -64,7 +64,7 @@ async def list_messages(
query = query.where(Message.channel_idx == channel_idx)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
query = query.where(ObserverNode.public_key.in_(observed_by))
if since:
query = query.where(Message.received_at >= since)
+65 -1
View File
@@ -8,7 +8,16 @@ from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Node, NodeTag, UserProfileNode
from meshcore_hub.common.models import (
Advertisement,
EventObserver,
Message,
Node,
NodeTag,
Telemetry,
TracePath,
UserProfileNode,
)
from meshcore_hub.common.schemas.nodes import AdoptedByUser, NodeList, NodeRead
router = APIRouter()
@@ -46,6 +55,9 @@ async def list_nodes(
None, description="Filter by adopting user profile UUID"
),
role: Optional[str] = Query(None, description="Filter by role tag value"),
observer: Optional[bool] = Query(
None, description="Filter to nodes that have observed events"
),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
@@ -128,6 +140,58 @@ async def list_nodes(
)
)
if observer is not None:
if observer:
query = query.where(
or_(
Node.id.in_(
select(Advertisement.observer_node_id).where(
Advertisement.observer_node_id.is_not(None)
)
),
Node.id.in_(
select(Message.observer_node_id).where(
Message.observer_node_id.is_not(None)
)
),
Node.id.in_(
select(Telemetry.observer_node_id).where(
Telemetry.observer_node_id.is_not(None)
)
),
Node.id.in_(
select(TracePath.observer_node_id).where(
TracePath.observer_node_id.is_not(None)
)
),
Node.id.in_(select(EventObserver.observer_node_id)),
)
)
else:
query = query.where(
~Node.id.in_(
select(Advertisement.observer_node_id).where(
Advertisement.observer_node_id.is_not(None)
)
),
~Node.id.in_(
select(Message.observer_node_id).where(
Message.observer_node_id.is_not(None)
)
),
~Node.id.in_(
select(Telemetry.observer_node_id).where(
Telemetry.observer_node_id.is_not(None)
)
),
~Node.id.in_(
select(TracePath.observer_node_id).where(
TracePath.observer_node_id.is_not(None)
)
),
~Node.id.in_(select(EventObserver.observer_node_id)),
)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
+5 -1
View File
@@ -14,7 +14,11 @@ export async function apiGet(path, params = {}) {
const url = new URL(path, window.location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') {
url.searchParams.set(k, String(v));
if (Array.isArray(v)) {
v.forEach(item => url.searchParams.append(k, String(item)));
} else {
url.searchParams.set(k, String(v));
}
}
}
const response = await fetch(url);
@@ -421,7 +421,10 @@ export function pagination(page, totalPages, basePath, params = {}) {
const queryParts = [];
for (const [k, v] of Object.entries(params)) {
if (k !== 'page' && v !== null && v !== undefined && v !== '') {
if (k === 'page' || v === null || v === undefined || v === '') continue;
if (Array.isArray(v)) {
v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`));
} else {
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
}
@@ -557,8 +560,11 @@ export function createFilterHandler(basePath, navigate) {
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams();
for (const [k, v] of formData.entries()) {
if (v) params.set(k, v);
const keys = new Set(formData.keys());
for (const k of keys) {
for (const v of formData.getAll(k)) {
if (v) params.append(k, v);
}
}
const queryStr = params.toString();
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
@@ -654,21 +660,41 @@ export function renderAuthSection(container, config) {
* @param {Function} options.navigate - Router navigate function
* @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter")
* @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear")
* @param {boolean} [options.collapsible=false] - Wrap in DaisyUI collapsible <details>
* @param {boolean} [options.defaultOpen=false] - Start expanded when collapsible
* @returns {TemplateResult}
*/
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel }) {
return html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action=${basePath} class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler(basePath, navigate)}>
${fields.map(f => f())}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${submitLabel || t('common.filter')}</button>
<a href=${basePath} class="btn btn-ghost btn-sm">${clearLabel || t('common.clear')}</a>
</div>
</form>
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) {
const formBody = html`
<form method="GET" action=${basePath} class="flex flex-col gap-4" @submit=${createFilterHandler(basePath, navigate)}>
<div class="flex gap-4 flex-wrap items-start">
${fields.map(f => f())}
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">${submitLabel || t('common.filter')}</button>
<a href=${basePath} class="btn btn-ghost btn-sm">${clearLabel || t('common.clear')}</a>
</div>
</form>
`;
if (!collapsible) {
return html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">${formBody}</div>
</div>
`;
}
return html`
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6"
?open=${defaultOpen}>
<summary class="collapse-title text-sm font-medium cursor-pointer">
${t('common.filters')}
</summary>
<div class="collapse-content pt-4">
${formBody}
</div>
</details>
`;
}
@@ -11,7 +11,9 @@ import { createAutoRefresh } from '../auto-refresh.js';
export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const public_key = query.public_key || '';
const observed_by = query.observed_by
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
: [];
const adopted_by = query.adopted_by || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
@@ -51,11 +53,12 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const apiParams = { limit, offset, search, public_key };
const apiParams = { limit, offset, search };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [
apiGet('/api/v1/advertisements', apiParams),
apiGet('/api/v1/nodes', { limit: 500 }),
apiGet('/api/v1/nodes', { limit: 500, observer: true }),
];
if (config.oidc_enabled) {
fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }));
@@ -77,13 +80,18 @@ ${displayContent}`, container);
const nodesFilter = sortedNodes.length > 0
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.node')}</span>
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
</label>
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
${sortedNodes.map(n => html`<option value=${n.public_key} ?selected=${public_key === n.public_key}>${n._displayName}</option>`)}
<select name="observed_by" multiple size="2"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>`
: nothing;
@@ -173,26 +181,23 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
search, public_key, adopted_by, limit,
search, observed_by, adopted_by, limit,
});
const filterFields = [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>`,
];
if (sortedNodes.length > 0) {
filterFields.push(() => nodesFilter);
}
if (config.oidc_enabled && profiles.length > 0) {
filterFields.push(() => html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
<div class="flex flex-col gap-1 max-w-56">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_member_label')}</span>
</label>
<select name="adopted_by" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="" ?selected=${!adopted_by}>${t('common.all_members')}</option>
@@ -207,11 +212,20 @@ ${displayContent}`, container);
</select>
</div>`);
}
if (sortedNodes.length > 0) {
filterFields.push(() => nodesFilter);
}
const hasActiveFilters = search !== '' || observed_by.length > 0 || (config.oidc_enabled && adopted_by !== '');
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/advertisements',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
renderPage(html`${filterCard}
@@ -14,6 +14,9 @@ export async function render(container, params, router) {
const query = params.query || {};
const message_type = query.message_type || '';
const channel_idx = query.channel_idx || '';
const observed_by = query.observed_by
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
: [];
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 50;
const offset = (page - 1) * limit;
@@ -199,8 +202,19 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const data = await apiGet('/api/v1/messages', { limit, offset, message_type, channel_idx });
const apiParams = { limit, offset, message_type, channel_idx };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
const [data, nodesData] = await Promise.all([
apiGet('/api/v1/messages', apiParams),
apiGet('/api/v1/nodes', { limit: 500, observer: true }),
]);
const messages = dedupeBySignature(data.items || []);
const allNodes = nodesData.items || [];
const sortedNodes = allNodes.map(n => {
const tagName = n.tags?.find(t => t.key === 'name')?.value;
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
@@ -300,15 +314,32 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/messages', {
message_type, channel_idx, limit,
message_type, channel_idx, observed_by, limit,
});
const filterCard = renderFilterCard({
fields: [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
const observerFilter = sortedNodes.length > 0
? html`
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
</label>
<select name="observed_by" multiple size="3"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>`
: nothing;
const filterFields = [
() => html`
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.type')}</span>
</label>
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_types')}</option>
@@ -316,10 +347,10 @@ ${displayContent}`, container);
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
</select>
</div>`,
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.channel')}</span>
() => html`
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('entities.channel')}</span>
</label>
<select name="channel_idx" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_channels')}</option>
@@ -328,9 +359,21 @@ ${displayContent}`, container);
)}
</select>
</div>`,
],
];
if (sortedNodes.length > 0) {
filterFields.push(() => observerFilter);
}
const hasActiveFilters = message_type !== '' || channel_idx !== '' || observed_by.length > 0;
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/messages',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
renderPage(html`${filterCard}
@@ -124,16 +124,16 @@ ${displayContent}`, container);
const filterFields = [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>`,
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.type')}</span>
</label>
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_types')}</option>
@@ -146,9 +146,9 @@ ${displayContent}`, container);
];
if (config.oidc_enabled && profiles.length > 0) {
filterFields.push(() => html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
<div class="flex flex-col gap-1 max-w-56">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_member_label')}</span>
</label>
<select name="adopted_by" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="" ?selected=${!adopted_by}>${t('common.all_members')}</option>
@@ -164,10 +164,16 @@ ${displayContent}`, container);
</div>`);
}
const hasActiveFilters = search !== '' || adv_type !== '' || (config.oidc_enabled && adopted_by !== '');
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/nodes',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
renderPage(html`${filterCard}
+9 -1
View File
@@ -97,7 +97,15 @@ export class Router {
}
const pathname = window.location.pathname;
const query = Object.fromEntries(new URLSearchParams(window.location.search));
const sp = new URLSearchParams(window.location.search);
const query = {};
for (const [k, v] of sp.entries()) {
if (k in query) {
query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v];
} else {
query[k] = v;
}
}
// Notify navigation listener
if (this._onNavigate) {
@@ -18,6 +18,7 @@
},
"common": {
"filter": "Filter",
"filters": "Filters",
"clear": "Clear",
"clear_filters": "Clear Filters",
"search": "Search",
@@ -81,6 +82,7 @@
"all_channels": "All Channels",
"all_members": "All Members",
"filter_member_label": "Member",
"filter_observer_label": "Observer",
"node_type": "Node Type",
"show": "Show",
"search_placeholder": "Search by name, ID, or public key...",
+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."""