mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-09 10:23:03 +02:00
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:
@@ -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 42–59
|
||||
- **Messages API**: `src/meshcore_hub/api/routes/messages.py` — `list_messages()` endpoint, query params at lines 29–43
|
||||
- **Advertisements frontend**: `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — filter form at lines 179–215, node filter at lines 73–89
|
||||
- **Messages frontend**: `src/meshcore_hub/web/static/js/spa/pages/messages.js` — filter form at lines 306–334
|
||||
- **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 73–89**: 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 188–190**: Remove `if (sortedNodes.length > 0) { filterFields.push(() => nodesFilter); }`
|
||||
- **Line 176**: Remove `public_key` from pagination params
|
||||
|
||||
### 1b. Backend — `advertisements.py`
|
||||
|
||||
- **Lines 48–49**: Remove `public_key: Optional[str] = Query(None, description="Filter by public key")`
|
||||
- **Lines 97–98**: 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 50–52, 100–101):
|
||||
```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 36–38, 66–67): 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 13–18)
|
||||
|
||||
**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 419–428)
|
||||
|
||||
**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 555–566)
|
||||
|
||||
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 48–49)
|
||||
- Remove `if public_key: query = query.where(Advertisement.public_key == public_key)` WHERE clause (lines 97–98)
|
||||
|
||||
- [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 85–96, 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 423–427)
|
||||
- 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 558–562)
|
||||
- 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 78–89)
|
||||
- Keep `/api/v1/nodes` fetch (line 58) and `sortedNodes` mapping (lines 73–76) 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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user