diff --git a/docs/plans/20260505-1555-sort-nodes-alpha/plan.md b/docs/plans/20260505-1555-sort-nodes-alpha/plan.md
new file mode 100644
index 0000000..35ffc5e
--- /dev/null
+++ b/docs/plans/20260505-1555-sort-nodes-alpha/plan.md
@@ -0,0 +1,484 @@
+# Plan: Column Sort Controls on List Pages + Alpha Sort Default for Nodes
+
+**Date:** 2026-05-05
+**Branch:** `feat/list-sort-controls`
+
+---
+
+## Background
+
+The Nodes, Advertisements, and Messages list pages each render a table with static column headers. Sorting is entirely server-side and hardcoded:
+
+| Page | Current Sort |
+|------|-------------|
+| Nodes | `last_seen DESC` |
+| Advertisements | `received_at DESC` |
+| Messages | `received_at DESC` |
+
+Two requests:
+1. **Change Nodes default sort** to alpha by display name (name tag → node.name → public_key fallback) so observers/companions don't constantly sit at the top.
+2. **Add clickable column headers** on all three list pages so users can sort by any column. Sort state should be encoded in the URL query string, which naturally survives auto-refresh (since auto-refresh re-reads `params.query` on each tick).
+
+---
+
+## Feature 1: Server-Side Sort Parameters (API)
+
+Add `sort` and `order` query parameters to all three list endpoints. The `sort` parameter accepts a set of column names per endpoint; invalid values are ignored (default sort is applied). The `order` parameter accepts `asc` or `desc`.
+
+### 1A. `GET /api/v1/nodes` — `src/meshcore_hub/api/routes/nodes.py`
+
+**New query parameters:**
+
+| Parameter | Type | Default | Valid Values |
+|-----------|------|---------|--------------|
+| `sort` | `str` | `name` | `name`, `public_key`, `last_seen` |
+| `order` | `str` | `asc` (when `sort=name`) / `desc` (when `sort=public_key` or `last_seen`) | `asc`, `desc` |
+
+**Sort column mapping:**
+
+| `sort` value | ORDER BY expression | Index required? |
+|-------------|-------------------|----------------|
+| `name` (new default) | `COALESCE(name_tag_value, Node.name, Node.public_key)` | Functional index recommended |
+| `public_key` | `Node.public_key` | Already indexed |
+| `last_seen` | `Node.last_seen` | Already indexed (`ix_nodes_last_seen`) |
+
+**Name sort implementation:**
+
+The display name resolution used in the frontend (`tagName || node.name || truncated public_key`) must be mirrored in SQL. Use a correlated scalar subquery to fetch the `name` tag value:
+
+```python
+from sqlalchemy import func
+
+name_tag_subq = (
+ select(NodeTag.value)
+ .where(NodeTag.node_id == Node.id, NodeTag.key == "name")
+ .correlate(Node)
+ .scalar_subquery()
+)
+
+if sort == "name":
+ sort_col = func.coalesce(name_tag_subq, Node.name, Node.public_key)
+elif sort == "public_key":
+ sort_col = Node.public_key
+elif sort == "last_seen":
+ sort_col = Node.last_seen
+else:
+ sort_col = func.coalesce(name_tag_subq, Node.name, Node.public_key)
+
+if order == "desc":
+ query = query.order_by(sort_col.desc())
+else:
+ query = query.order_by(sort_col.asc())
+```
+
+**Edge case:** `Node.last_seen` can be `NULL`. When `order=asc` and sorting by `last_seen`, `NULL` values sort first in SQLite (which is fine — unseen nodes naturally sort to top). When `order=desc`, `NULL` values sort last (also fine). No special treatment needed.
+
+**Default change:** The default when no `sort` param is provided changes from `last_seen DESC` to alpha-by-name ascending.
+
+**Functional index (optional optimization):**
+
+An expression index on `COALESCE((SELECT value FROM node_tags WHERE node_id = nodes.id AND key = 'name'), name, public_key)` would speed up name sorts. This can be deferred — SQLite handles ORDER BY reasonably without indexes for typical dataset sizes. If added later, it would go in an Alembic migration.
+
+### 1B. `GET /api/v1/advertisements` — `src/meshcore_hub/api/routes/advertisements.py`
+
+**New query parameters:**
+
+| Parameter | Type | Default | Valid Values |
+|-----------|------|---------|--------------|
+| `sort` | `str` | `time` | `time`, `node_name`, `public_key` |
+| `order` | `str` | `desc` | `asc`, `desc` |
+
+**Sort column mapping:**
+
+| `sort` value | ORDER BY expression |
+|-------------|-------------------|
+| `time` (default) | `Advertisement.received_at` |
+| `node_name` | `COALESCE(name_tag_subq, SourceNode.name, Advertisement.public_key)` |
+| `public_key` | `Advertisement.public_key` |
+
+**Node name sort implementation:**
+
+The ads route already joins `SourceNode` (aliased `Node`) via `outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)`. To sort by display name, add a correlated scalar subquery for the name tag — same pattern as the nodes endpoint:
+
+```python
+from sqlalchemy import func
+
+# SourceNodeNameTag is an alias for the tag lookup
+SourceNodeNameTag = aliased(NodeTag)
+
+name_tag_subq = (
+ select(SourceNodeNameTag.value)
+ .where(
+ SourceNodeNameTag.node_id == SourceNode.id,
+ SourceNodeNameTag.key == "name",
+ )
+ .correlate(SourceNode)
+ .scalar_subquery()
+)
+
+if sort == "node_name":
+ sort_col = func.coalesce(name_tag_subq, SourceNode.name, Advertisement.public_key)
+elif sort == "public_key":
+ sort_col = Advertisement.public_key
+elif sort == "time":
+ sort_col = Advertisement.received_at
+else:
+ sort_col = Advertisement.received_at
+```
+
+The `SourceNode` alias is already defined in the route. The `NodeTag` model is already imported. Only the `aliased(NodeTag)` and the subquery are new.
+
+The "Observers" column is not sortable (it is a count from a separate table) and will render as a plain `
` header.
+
+### 1C. `GET /api/v1/messages` — `src/meshcore_hub/api/routes/messages.py`
+
+**New query parameters:**
+
+| Parameter | Type | Default | Valid Values |
+|-----------|------|---------|--------------|
+| `sort` | `str` | `time` | `time`, `type`, `from`, `message` |
+| `order` | `str` | `desc` | `asc`, `desc` |
+
+**Sort column mapping:**
+
+| `sort` value | ORDER BY expression |
+|-------------|-------------------|
+| `time` (default) | `Message.received_at` |
+| `type` | `Message.message_type` |
+| `from` | `Message.pubkey_prefix` |
+| `message` | `Message.text` |
+
+The "Observers" column is not sortable. The "From" column sorts by `pubkey_prefix` (raw value) rather than resolved display name, since sender name resolution happens in a post-query step.
+
+**Note on `message` sort:** Sorting a large text column alphabetically is valid SQL but may have limited UX value. Still included for consistency since the column is displayed.
+
+### Implementation pattern (shared across all three endpoints)
+
+A helper pattern handles validation and defaults:
+
+```python
+VALID_SORT_COLUMNS: dict[str, Any] = { ... } # column name → ORM attribute or expression
+DEFAULT_SORT = "name" # or "time" for ads/messages
+
+sort = sort if sort in VALID_SORT_COLUMNS else DEFAULT_SORT
+order = order if order in ("asc", "desc") else "asc" if sort in ("name", "node_name") else "desc"
+```
+
+---
+
+## Feature 2: Clickable Sort Headers (Frontend)
+
+### 2A. Shared sort header component — `src/meshcore_hub/web/static/js/spa/components.js`
+
+Add a `sortableTableHeader()` function that renders a ` | ` with an `` link. Clicking cycles through: none → asc → desc → none.
+
+**Signature:**
+
+```js
+export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params })
+```
+
+**Parameters:**
+- `label`: already-translated label string (e.g., `t('entities.node')`)
+- `sortKey`: the column name sent to the API
+- `currentSort`: currently active sort column (from URL params)
+- `currentOrder`: currently active sort direction (`asc` or `desc`)
+- `navigate`: SPA router navigate function
+- `basePath`: e.g., `'/nodes'`
+- `params`: all current query params (to preserve filters)
+
+**Behavior:**
+
+| Current state | Next state | Indicator |
+|--------------|-----------|-----------|
+| Not sorted by this column | `sort=key&order=asc` | (none shown before click) |
+| Sorted `asc` by this column | `sort=key&order=desc` | ▴ |
+| Sorted `desc` by this column | (remove sort params) | ▾ |
+
+**URL construction:**
+
+```js
+function buildSortUrl(basePath, params, nextSort, nextOrder) {
+ const sp = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null && value !== undefined && value !== '') {
+ sp.set(key, value);
+ }
+ }
+ if (nextSort && nextOrder) {
+ sp.set('sort', nextSort);
+ sp.set('order', nextOrder);
+ } else {
+ sp.delete('sort');
+ sp.delete('order');
+ }
+ const qs = sp.toString();
+ return qs ? `${basePath}?${qs}` : basePath;
+}
+```
+
+The `params` object contains filter params only (no `page` — pagination is implicitly reset to page 1 on sort change). When the third click removes sort params, the function returns to the page default via the frontend default logic (see Feature 4).
+
+**Rendering:**
+
+```js
+export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) {
+ let indicator = '';
+ let nextSort, nextOrder;
+
+ if (currentSort !== sortKey) {
+ nextSort = sortKey;
+ nextOrder = 'asc';
+ } else if (currentOrder === 'asc') {
+ nextSort = sortKey;
+ nextOrder = 'desc';
+ indicator = ' ▴';
+ } else {
+ nextSort = null;
+ nextOrder = null;
+ indicator = ' ▾';
+ }
+
+ const url = buildSortUrl(basePath, params, nextSort, nextOrder);
+
+ return html` |
+ { e.preventDefault(); navigate(url); }}>
+ ${label}${indicator}
+
+ | `;
+}
+```
+
+**Not sortable:** Columns without a sort key render as plain `` elements (unchanged from current behavior).
+
+### 2B. Nodes page — `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
+
+**Changes:**
+1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing:
+
+```js
+const sort = query.sort || 'name';
+const order = query.order || 'asc';
+```
+
+2. Pass `sort`/`order` to `apiGet('/api/v1/nodes', { ..., sort, order })`
+3. Replace the static ` | ` elements in the table header with `sortableTableHeader()` calls:
+
+```js
+const headerParams = { search, adv_type, adopted_by, limit };
+const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/nodes', params: headerParams,
+});
+
+// In table header:
+
+ ${sortable(t('entities.node'), 'name')}
+ ${sortable(t('common.public_key'), 'public_key')}
+ ${sortable(t('common.last_seen'), 'last_seen')}
+
+```
+
+**Why set defaults in the frontend?** The server applies its own defaults when `sort`/`order` are omitted, but the frontend needs to know the effective sort state to render the correct indicator on the active column. By setting `sort='name'` and `order='asc'` when the URL has no sort params, the "Node" header shows the ▴ indicator on first load and the URL state is always explicit.
+
+### 2C. Advertisements page — `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
+
+**Changes:**
+1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing:
+
+```js
+const sort = query.sort || 'time';
+const order = query.order || 'desc';
+```
+
+2. Pass `sort`/`order` to `apiGet('/api/v1/advertisements', { ..., sort, order })`
+3. Make the "Node" header sortable by `node_name`, the "Public Key" header sortable by `public_key`, and the "Time" header sortable by `time`:
+
+```js
+const headerParams = { search, observed_by, adopted_by, limit };
+const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/advertisements', params: headerParams,
+});
+
+// In table header:
+
+ ${sortable(t('entities.node'), 'node_name')}
+ ${sortable(t('common.public_key'), 'public_key')}
+ ${sortable(t('common.time'), 'time')}
+ | ${t('common.observers')} |
+
+```
+
+Sorting the "Node" column uses the same COALESCE display name logic as the nodes endpoint (name tag → SourceNode.name → public_key fallback), so the sort order matches the displayed names.
+
+### 2D. Messages page — `src/meshcore_hub/web/static/js/spa/pages/messages.js`
+
+**Changes:**
+1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing:
+
+```js
+const sort = query.sort || 'time';
+const order = query.order || 'desc';
+```
+
+2. Pass `sort`/`order` to `apiGet('/api/v1/messages', { ..., sort, order })`
+3. Make Type, Time, From, and Message headers sortable; Observers stays plain:
+
+```js
+const headerParams = { message_type, channel_idx, observed_by, limit };
+const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/messages', params: headerParams,
+});
+
+// In table header:
+
+ ${sortable(t('common.type'), 'type')}
+ ${sortable(t('common.time'), 'time')}
+ ${sortable(t('common.from'), 'from')}
+ ${sortable(t('entities.message'), 'message')}
+ | ${t('common.observers')} |
+
+```
+
+---
+
+## Feature 3: Sort State Persistence
+
+### How it works (no code changes needed beyond what's above)
+
+1. Clicking a column header calls `router.navigate(url)`, which pushes a new history entry and triggers a page re-render.
+2. The page component reads `sort`/`order` from `params.query` and passes them to the API.
+3. The auto-refresh timer calls `fetchAndRenderData()`, which reads the same `params.query` — so the sort params are included in each refresh tick.
+4. The `pagination()` component already preserves all non-empty params (it skips `page` explicitly), so `sort`/`order` flow through pagination links automatically.
+
+### Edge case: What happens to sort when filters change?
+
+Filter form submits use `createFilterHandler`, which navigates to a URL with only the filter params (no sort/order). This means changing a filter **loses** the current sort state. This is acceptable UX:
+- Changing a filter implies a new query intent; resetting sort is reasonable.
+- Users can re-apply sort after filtering.
+
+If this becomes a pain point later, the filter form could be updated to preserve `sort`/`order` params.
+
+---
+
+## Feature 4: Frontend Default Sort (Explicit State)
+
+To ensure sort indicators are always correct on first load, each page sets explicit `sort`/`order` values when the URL has none:
+
+| Page | Frontend default |
+|------|-----------------|
+| Nodes | `sort=name, order=asc` |
+| Advertisements | `sort=time, order=desc` |
+| Messages | `sort=time, order=desc` |
+
+```js
+// Nodes page
+const sort = query.sort || 'name';
+const order = query.order || 'asc';
+
+// Advertisements / Messages pages
+const sort = query.sort || 'time';
+const order = query.order || 'desc';
+```
+
+These defaults are passed to both the API call and the `sortableTableHeader()` component. This ensures:
+- The active column header shows the correct indicator (▴ or ▾) on first load.
+- The URL state is always explicit — no ambiguous "implicit default" state.
+- Clicking the default-sorted column's header cycles to the next state (desc), rather than being a no-op.
+
+---
+
+## i18n Changes
+
+### `src/meshcore_hub/web/static/locales/en.json`
+
+No new translation keys are strictly needed — the column headers already have translations (`entities.node`, `common.public_key`, etc.). Sort indicators use Unicode arrows (▴ / ▾), which are language-neutral.
+
+**Optional:** Add `aria-sort` attribute values for screen readers. These would be English-only static strings embedded in the component (e.g., `"ascending"`, `"descending"`) since they are accessibility metadata, not user-visible labels. Not required for initial implementation.
+
+---
+
+## Test Plan
+
+### API Tests (`tests/test_api/`)
+
+**test_nodes.py** — Add test class `TestNodeSort`:
+
+| Test | Description |
+|------|-------------|
+| `test_sort_by_name_default` | Default (no `sort` param) returns nodes alpha by display name |
+| `test_sort_by_name_asc` | `sort=name&order=asc` |
+| `test_sort_by_name_desc` | `sort=name&order=desc` — Z-to-A |
+| `test_sort_by_public_key` | `sort=public_key` |
+| `test_sort_by_last_seen` | `sort=last_seen&order=asc` |
+| `test_sort_name_tag_priority` | Name tag value takes priority over `node.name` in sort order |
+| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default |
+| `test_sort_nodes_with_null_name` | Nodes with `name=NULL` sort by public_key via COALESCE fallback |
+
+**test_advertisements.py** — Add test class `TestAdvertisementSort`:
+
+| Test | Description |
+|------|-------------|
+| `test_sort_by_time_default` | Default sorts by `received_at DESC` |
+| `test_sort_by_time_asc` | `sort=time&order=asc` |
+| `test_sort_by_node_name` | `sort=node_name&order=asc` — sorts by display name (COALESCE of name tag → SourceNode.name → public_key) |
+| `test_sort_by_node_name_tag_priority` | Name tag value takes priority over SourceNode.name in sort order |
+| `test_sort_by_public_key` | `sort=public_key&order=asc` |
+| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default |
+
+**test_messages.py** — Add test class `TestMessageSort`:
+
+| Test | Description |
+|------|-------------|
+| `test_sort_by_time_default` | Default sorts by `received_at DESC` |
+| `test_sort_by_type` | `sort=type&order=asc` |
+| `test_sort_by_from` | `sort=from&order=asc` (sorts by `pubkey_prefix`) |
+| `test_sort_by_message` | `sort=message&order=asc` (sorts by `text`) |
+| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default |
+
+### Frontend Tests (`tests/test_web/`)
+
+No existing frontend JS test infrastructure. Visual verification:
+1. Load Nodes page — verify default sort is alpha by name
+2. Click "Last Seen" header — verify sort switches to newest-first, indicator shows ▾
+3. Click "Last Seen" again — verify indicator switches to ▴ (oldest-first)
+4. Click "Last Seen" a third time — verify sort returns to default (name alpha)
+5. Enable auto-refresh — verify sort state persists across refresh ticks
+6. Repeat steps 2-5 on Advertisements and Messages pages
+7. Verify pagination preserves sort params in page links
+8. Verify the "Observers" column header on ads/messages is not clickable
+
+---
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `src/meshcore_hub/api/routes/nodes.py` | Add `sort`/`order` params, name-sort via COALESCE subquery, change default sort |
+| `src/meshcore_hub/api/routes/advertisements.py` | Add `sort`/`order` params (time, node_name, public_key) with COALESCE name-sort |
+| `src/meshcore_hub/api/routes/messages.py` | Add `sort`/`order` params (time, type, from, message) |
+| `src/meshcore_hub/web/static/js/spa/components.js` | Add `sortableTableHeader()` export |
+| `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Parse sort/order, pass to API, replace ` | ` with sortable headers |
+| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Parse sort/order, pass to API, replace ` | ` with sortable headers |
+| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Parse sort/order, pass to API, replace ` | ` with sortable headers |
+| `tests/test_api/test_nodes.py` | Add `TestNodeSort` class |
+| `tests/test_api/test_advertisements.py` | Add `TestAdvertisementSort` class |
+| `tests/test_api/test_messages.py` | Add `TestMessageSort` class |
+
+---
+
+## Sequence
+
+1. Add `sortableTableHeader()` to `components.js`
+2. Add `sort`/`order` params to `nodes.py` with COALESCE name-sort (changing default)
+3. Update `nodes.js` with sortable headers and sort/order param passing
+4. Add `sort`/`order` params to `advertisements.py`
+5. Update `advertisements.js` with sortable headers
+6. Add `sort`/`order` params to `messages.py`
+7. Update `messages.js` with sortable headers
+8. Write API tests for all three endpoints
+9. Run `pytest tests/test_api/` to verify
+10. Visual verification of all three pages
diff --git a/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md b/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md
new file mode 100644
index 0000000..a94dd6f
--- /dev/null
+++ b/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md
@@ -0,0 +1,108 @@
+# Tasks: Column Sort Controls on List Pages + Alpha Sort Default for Nodes
+
+**Plan:** [plan.md](plan.md)
+**Branch:** `feat/list-sort-controls`
+
+---
+
+## Phase 1: Shared Frontend Component
+
+- [ ] **1.1** Add `buildSortUrl()` helper in `src/meshcore_hub/web/static/js/spa/components.js`
+ - Accepts `(basePath, params, nextSort, nextOrder)` and returns a URL string
+ - Uses `URLSearchParams` to build query string from `params`, adds/removes `sort`/`order`
+
+- [ ] **1.2** Add `sortableTableHeader()` export in `src/meshcore_hub/web/static/js/spa/components.js`
+ - Accepts `(label, { sortKey, currentSort, currentOrder, navigate, basePath, params })`
+ - Renders ` | ` with cycling sort indicator (▴ / ▾) using `buildSortUrl()`
+ - Cycle: none → asc → desc → none (removes sort/order on third click)
+
+## Phase 2: Nodes — Backend + Frontend
+
+- [ ] **2.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/nodes.py`
+ - Add `sort: Optional[str] = Query(default=None)` param
+ - Add `order: Optional[str] = Query(default=None)` param
+ - Define `VALID_SORT_COLUMNS` dict: `name`, `public_key`, `last_seen`
+ - Build correlated subquery: `COALESCE(name_tag_subq, Node.name, Node.public_key)` for `name` sort
+ - Change default sort from `last_seen DESC` to alpha-by-name ascending (when no params provided)
+ - Invalid `sort`/`order` falls back to defaults
+
+- [ ] **2.2** Update `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
+ - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'name'`, `order = query.order || 'asc'`
+ - Pass `sort`/`order` to `apiGet('/api/v1/nodes', { ..., sort, order })`
+ - Build `headerParams` from current filter params (search, adv_type, adopted_by, limit)
+ - Replace static ` | ` elements with `sortableTableHeader()` calls for Node, Public Key, Last Seen columns
+
+## Phase 3: Advertisements — Backend + Frontend
+
+- [ ] **3.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/advertisements.py`
+ - Add `sort: Optional[str] = Query(default=None)` param
+ - Add `order: Optional[str] = Query(default=None)` param
+ - Define `VALID_SORT_COLUMNS` dict: `time`, `node_name`, `public_key`
+ - Import `aliased` from `sqlalchemy.orm` (`NodeTag` already imported)
+ - Build correlated subquery via `aliased(NodeTag)` for `node_name` sort: `COALESCE(name_tag_subq, SourceNode.name, Advertisement.public_key)`
+ - Invalid `sort`/`order` falls back to `received_at DESC`
+
+- [ ] **3.2** Update `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
+ - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'time'`, `order = query.order || 'desc'`
+ - Pass `sort`/`order` to `apiGet('/api/v1/advertisements', { ..., sort, order })`
+ - Build `headerParams` from current filter params (search, observed_by, adopted_by, limit)
+ - Replace static ` | ` elements with `sortableTableHeader()` calls for Node (`node_name`), Public Key, Time columns
+ - Keep Observers column as plain ` | `
+
+## Phase 4: Messages — Backend + Frontend
+
+- [ ] **4.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/messages.py`
+ - Add `sort: Optional[str] = Query(default=None)` param
+ - Add `order: Optional[str] = Query(default=None)` param
+ - Define `VALID_SORT_COLUMNS` dict: `time`, `type`, `from`, `message`
+ - Invalid `sort`/`order` falls back to `received_at DESC`
+
+- [ ] **4.2** Update `src/meshcore_hub/web/static/js/spa/pages/messages.js`
+ - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'time'`, `order = query.order || 'desc'`
+ - Pass `sort`/`order` to `apiGet('/api/v1/messages', { ..., sort, order })`
+ - Build `headerParams` from current filter params (message_type, channel_idx, observed_by, limit)
+ - Replace static ` | ` elements with `sortableTableHeader()` calls for Type, Time, From, Message columns
+ - Keep Observers column as plain ` | `
+
+## Phase 5: API Tests
+
+- [ ] **5.1** Add `TestNodeSort` class to `tests/test_api/test_nodes.py`
+ - `test_sort_by_name_default` — no sort param returns alpha by display name
+ - `test_sort_by_name_asc` — `sort=name&order=asc`
+ - `test_sort_by_name_desc` — `sort=name&order=desc`
+ - `test_sort_by_public_key` — `sort=public_key`
+ - `test_sort_by_last_seen` — `sort=last_seen&order=asc`
+ - `test_sort_name_tag_priority` — name tag takes priority over `node.name`
+ - `test_sort_invalid_ignored` — invalid sort falls back to default
+ - `test_sort_nodes_with_null_name` — `name=NULL` sorts by public_key
+
+- [ ] **5.2** Add `TestAdvertisementSort` class to `tests/test_api/test_advertisements.py`
+ - `test_sort_by_time_default` — default sorts by `received_at DESC`
+ - `test_sort_by_time_asc` — `sort=time&order=asc`
+ - `test_sort_by_node_name` — `sort=node_name&order=asc` sorts by display name
+ - `test_sort_by_node_name_tag_priority` — name tag takes priority over SourceNode.name
+ - `test_sort_by_public_key` — `sort=public_key&order=asc`
+ - `test_sort_invalid_ignored` — invalid sort falls back to default
+
+- [ ] **5.3** Add `TestMessageSort` class to `tests/test_api/test_messages.py`
+ - `test_sort_by_time_default` — default sorts by `received_at DESC`
+ - `test_sort_by_type` — `sort=type&order=asc`
+ - `test_sort_by_from` — `sort=from&order=asc`
+ - `test_sort_by_message` — `sort=message&order=asc`
+ - `test_sort_invalid_ignored` — invalid sort falls back to default
+
+## Verification
+
+- [ ] Run `pytest tests/test_api/test_nodes.py -v`
+- [ ] Run `pytest tests/test_api/test_advertisements.py -v`
+- [ ] Run `pytest tests/test_api/test_messages.py -v`
+- [ ] Visual verification in browser:
+ - Nodes: default sort is alpha by name; Node header shows ▴ indicator on first load
+ - Click "Last Seen" → newest-first with ▾ indicator; click again → oldest-first ▴; click again → reset to default
+ - Advertisements: default sort is newest-first; sort by Node name; sort by Public Key
+ - Messages: default sort is newest-first; sort by Type, From, Message
+ - Observers column header on ads/messages is not clickable
+ - Auto-refresh preserves sort state across ticks
+ - Pagination preserves sort params in page links
+ - Filter change resets sort to default
+- [ ] Run `pre-commit run --all-files`
diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py
index f739d0f..c68526b 100644
--- a/src/meshcore_hub/api/routes/advertisements.py
+++ b/src/meshcore_hub/api/routes/advertisements.py
@@ -18,6 +18,8 @@ from meshcore_hub.common.schemas.messages import (
router = APIRouter()
+VALID_AD_SORT_COLUMNS = {"time", "node_name", "public_key"}
+
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
"""Extract name tag from a node's tags."""
@@ -54,6 +56,8 @@ async def list_advertisements(
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
until: Optional[datetime] = Query(None, description="End timestamp"),
+ sort: Optional[str] = Query(None, description="Sort column"),
+ order: Optional[str] = Query(None, description="Sort direction: asc or desc"),
limit: int = Query(50, ge=1, le=100, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> AdvertisementList:
@@ -115,8 +119,39 @@ async def list_advertisements(
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
+ # Resolve sort column and direction
+ sort = sort if sort in VALID_AD_SORT_COLUMNS else "time"
+ order = order if order in ("asc", "desc") else "desc"
+
+ SourceNodeNameTag = aliased(NodeTag)
+ name_tag_subq = (
+ select(SourceNodeNameTag.value)
+ .where(
+ SourceNodeNameTag.node_id == SourceNode.id,
+ SourceNodeNameTag.key == "name",
+ )
+ .correlate(SourceNode)
+ .scalar_subquery()
+ )
+
+ if sort == "node_name":
+ _col = func.coalesce(name_tag_subq, SourceNode.name, Advertisement.public_key)
+ query = query.order_by(_col.desc() if order == "desc" else _col.asc())
+ elif sort == "public_key":
+ query = query.order_by(
+ Advertisement.public_key.desc()
+ if order == "desc"
+ else Advertisement.public_key.asc()
+ )
+ else:
+ query = query.order_by(
+ Advertisement.received_at.desc()
+ if order == "desc"
+ else Advertisement.received_at.asc()
+ )
+
# Apply pagination
- query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit)
+ query = query.offset(offset).limit(limit)
# Execute
results = session.execute(query).all()
diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py
index d8b6af4..4ea4caa 100644
--- a/src/meshcore_hub/api/routes/messages.py
+++ b/src/meshcore_hub/api/routes/messages.py
@@ -15,6 +15,8 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead
router = APIRouter()
+VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"}
+
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
"""Extract name tag from a node's tags."""
@@ -39,6 +41,8 @@ async def list_messages(
since: Optional[datetime] = Query(None, description="Start timestamp"),
until: Optional[datetime] = Query(None, description="End timestamp"),
search: Optional[str] = Query(None, description="Search in message text"),
+ sort: Optional[str] = Query(None, description="Sort column"),
+ order: Optional[str] = Query(None, description="Sort direction: asc or desc"),
limit: int = Query(50, ge=1, le=100, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> MessageList:
@@ -79,8 +83,33 @@ async def list_messages(
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
+ # Resolve sort column and direction
+ sort = sort if sort in VALID_MSG_SORT_COLUMNS else "time"
+ order = order if order in ("asc", "desc") else "desc"
+
+ if sort == "type":
+ query = query.order_by(
+ Message.message_type.desc()
+ if order == "desc"
+ else Message.message_type.asc()
+ )
+ elif sort == "from":
+ query = query.order_by(
+ Message.pubkey_prefix.desc()
+ if order == "desc"
+ else Message.pubkey_prefix.asc()
+ )
+ elif sort == "message":
+ query = query.order_by(
+ Message.text.desc() if order == "desc" else Message.text.asc()
+ )
+ else:
+ query = query.order_by(
+ Message.received_at.desc() if order == "desc" else Message.received_at.asc()
+ )
+
# Apply pagination
- query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit)
+ query = query.offset(offset).limit(limit)
# Execute
results = session.execute(query).all()
diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py
index 4bd5b76..05ffc85 100644
--- a/src/meshcore_hub/api/routes/nodes.py
+++ b/src/meshcore_hub/api/routes/nodes.py
@@ -43,6 +43,9 @@ def _node_to_read(node: Node) -> NodeRead:
return node_read
+VALID_NODE_SORT_COLUMNS = {"name", "public_key", "last_seen"}
+
+
@router.get("", response_model=NodeList)
async def list_nodes(
_: RequireRead,
@@ -58,6 +61,8 @@ async def list_nodes(
observer: Optional[bool] = Query(
None, description="Filter to nodes that have observed events"
),
+ sort: Optional[str] = Query(None, description="Sort column"),
+ order: Optional[str] = Query(None, description="Sort direction: asc or desc"),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
@@ -196,8 +201,34 @@ async def list_nodes(
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
- # Apply pagination and ordering
- query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
+ # Resolve sort column and direction
+ sort = sort if sort in VALID_NODE_SORT_COLUMNS else "name"
+ order = order if order in ("asc", "desc") else ("asc" if sort == "name" else "desc")
+
+ name_tag_subq = (
+ select(NodeTag.value)
+ .where(NodeTag.node_id == Node.id, NodeTag.key == "name")
+ .correlate(Node)
+ .scalar_subquery()
+ )
+
+ if sort == "name":
+ _col = func.coalesce(name_tag_subq, Node.name, Node.public_key)
+ query = query.order_by(_col.desc() if order == "desc" else _col.asc())
+ elif sort == "public_key":
+ query = query.order_by(
+ Node.public_key.desc() if order == "desc" else Node.public_key.asc()
+ )
+ elif sort == "last_seen":
+ query = query.order_by(
+ Node.last_seen.desc() if order == "desc" else Node.last_seen.asc()
+ )
+ else:
+ _col = func.coalesce(name_tag_subq, Node.name, Node.public_key)
+ query = query.order_by(_col.desc() if order == "desc" else _col.asc())
+
+ # Apply pagination
+ query = query.offset(offset).limit(limit)
# Execute
nodes = session.execute(query).scalars().all()
diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js
index 1112ee6..39cd1ff 100644
--- a/src/meshcore_hub/web/static/js/spa/components.js
+++ b/src/meshcore_hub/web/static/js/spa/components.js
@@ -15,6 +15,49 @@ export { html, nothing, unsafeHTML };
export { render as litRender } from 'lit-html';
export { t } from './i18n.js';
+function buildSortUrl(basePath, params, nextSort, nextOrder) {
+ const sp = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null && value !== undefined && value !== '') {
+ if (Array.isArray(value)) {
+ value.forEach(item => sp.append(key, String(item)));
+ } else {
+ sp.set(key, String(value));
+ }
+ }
+ }
+ if (nextSort && nextOrder) {
+ sp.set('sort', nextSort);
+ sp.set('order', nextOrder);
+ }
+ const qs = sp.toString();
+ return qs ? `${basePath}?${qs}` : basePath;
+}
+
+export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) {
+ let indicator = '';
+ let nextOrder;
+
+ if (currentSort !== sortKey) {
+ nextOrder = 'asc';
+ } else if (currentOrder === 'asc') {
+ nextOrder = 'desc';
+ indicator = ' \u25B4';
+ } else {
+ nextOrder = 'asc';
+ indicator = ' \u25BE';
+ }
+
+ const url = buildSortUrl(basePath, params, sortKey, nextOrder);
+
+ return html` |
+ { e.preventDefault(); e.stopPropagation(); navigate(url); }}>
+ ${label}${indicator}
+
+ | `;
+}
+
/**
* Get app config from the embedded window object.
* @returns {Object} App configuration
diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js
index 1a6028a..043221b 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js
@@ -3,7 +3,8 @@ import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
warningBadge,
- pagination, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
+ pagination, sortableTableHeader,
+ renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -18,6 +19,8 @@ export async function render(container, params, router) {
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
+ const sort = query.sort || 'time';
+ const order = query.order || 'desc';
const config = getConfig();
const tz = config.timezone || '';
@@ -53,7 +56,7 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
- const apiParams = { limit, offset, search };
+ const apiParams = { limit, offset, search, sort, order };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [
@@ -181,7 +184,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
- search, observed_by, adopted_by, limit,
+ search, observed_by, adopted_by, limit, sort, order,
});
const filterFields = [
@@ -228,6 +231,12 @@ ${displayContent}`, container);
defaultOpen: isFilterOpen,
});
+ const headerParams = { search, observed_by, adopted_by, limit };
+ const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/advertisements', params: headerParams,
+ });
+
renderPage(html`${filterCard}
@@ -238,9 +247,9 @@ ${displayContent}`, container);
- | ${t('entities.node')} |
- ${t('common.public_key')} |
- ${t('common.time')} |
+ ${sortable(t('entities.node'), 'node_name')}
+ ${sortable(t('common.public_key'), 'public_key')}
+ ${sortable(t('common.time'), 'time')}
${t('common.observers')} |
diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js
index f026374..1fd3baa 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/messages.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js
@@ -4,7 +4,7 @@ import {
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
getChannelLabelsMap, resolveChannelLabel,
truncateKey, warningBadge,
- pagination, timezoneIndicator,
+ pagination, sortableTableHeader, timezoneIndicator,
renderFilterCard, autoSubmit, submitOnEnter,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
@@ -20,6 +20,8 @@ export async function render(container, params, router) {
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 50;
const offset = (page - 1) * limit;
+ const sort = query.sort || 'time';
+ const order = query.order || 'desc';
const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
@@ -202,7 +204,7 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
- const apiParams = { limit, offset, message_type, channel_idx };
+ const apiParams = { limit, offset, message_type, channel_idx, sort, order };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
const [data, nodesData] = await Promise.all([
apiGet('/api/v1/messages', apiParams),
@@ -314,7 +316,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/messages', {
- message_type, channel_idx, observed_by, limit,
+ message_type, channel_idx, observed_by, limit, sort, order,
});
const observerFilter = sortedNodes.length > 0
@@ -376,6 +378,12 @@ ${displayContent}`, container);
defaultOpen: isFilterOpen,
});
+ const headerParams = { message_type, channel_idx, observed_by, limit };
+ const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/messages', params: headerParams,
+ });
+
renderPage(html`${filterCard}
@@ -386,10 +394,10 @@ ${displayContent}`, container);
- | ${t('common.type')} |
- ${t('common.time')} |
- ${t('common.from')} |
- ${t('entities.message')} |
+ ${sortable(t('common.type'), 'type')}
+ ${sortable(t('common.time'), 'time')}
+ ${sortable(t('common.from'), 'from')}
+ ${sortable(t('entities.message'), 'message')}
${t('common.observers')} |
diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js
index 2113485..0c42f3a 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js
@@ -3,7 +3,7 @@ import {
html, litRender, nothing,
getConfig, formatDateTime, formatDateTimeShort,
warningBadge,
- pagination,
+ pagination, sortableTableHeader,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -16,6 +16,8 @@ export async function render(container, params, router) {
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
+ const sort = query.sort || 'name';
+ const order = query.order || 'asc';
const config = getConfig();
const tz = config.timezone || '';
@@ -51,7 +53,7 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
- const apiParams = { limit, offset, search, adv_type };
+ const apiParams = { limit, offset, search, adv_type, sort, order };
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [apiGet('/api/v1/nodes', apiParams)];
if (config.oidc_enabled) {
@@ -119,7 +121,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/nodes', {
- search, adv_type, adopted_by, limit,
+ search, adv_type, adopted_by, limit, sort, order,
});
const filterFields = [
@@ -176,6 +178,12 @@ ${displayContent}`, container);
defaultOpen: isFilterOpen,
});
+ const headerParams = { search, adv_type, adopted_by, limit };
+ const sortable = (label, sortKey) => sortableTableHeader(label, {
+ sortKey, currentSort: sort, currentOrder: order,
+ navigate, basePath: '/nodes', params: headerParams,
+ });
+
renderPage(html`${filterCard}
@@ -186,9 +194,9 @@ ${displayContent}`, container);
- | ${t('entities.node')} |
- ${t('common.public_key')} |
- ${t('common.last_seen')} |
+ ${sortable(t('entities.node'), 'name')}
+ ${sortable(t('common.public_key'), 'public_key')}
+ ${sortable(t('common.last_seen'), 'last_seen')}
diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py
index 94f044b..59414e0 100644
--- a/tests/test_api/test_advertisements.py
+++ b/tests/test_api/test_advertisements.py
@@ -292,3 +292,190 @@ class TestListAdvertisementsFilters:
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
+
+
+class TestAdvertisementSort:
+ """Tests for advertisement list sort parameters."""
+
+ def test_sort_by_time_default(self, client_no_auth, api_db_session):
+ """Default sort is received_at DESC."""
+ now = datetime.now(timezone.utc)
+ ad_old = Advertisement(
+ public_key="aa" * 16,
+ name="Old",
+ adv_type="CLIENT",
+ received_at=now - timedelta(hours=1),
+ )
+ ad_new = Advertisement(
+ public_key="bb" * 16,
+ name="New",
+ adv_type="CLIENT",
+ received_at=now,
+ )
+ api_db_session.add_all([ad_old, ad_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/advertisements")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "New"
+ assert items[1]["name"] == "Old"
+
+ def test_sort_by_time_asc(self, client_no_auth, api_db_session):
+ """sort=time&order=asc returns oldest first."""
+ now = datetime.now(timezone.utc)
+ ad_old = Advertisement(
+ public_key="aa" * 16,
+ name="Old",
+ adv_type="CLIENT",
+ received_at=now - timedelta(hours=1),
+ )
+ ad_new = Advertisement(
+ public_key="bb" * 16,
+ name="New",
+ adv_type="CLIENT",
+ received_at=now,
+ )
+ api_db_session.add_all([ad_old, ad_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/advertisements?sort=time&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Old"
+ assert items[1]["name"] == "New"
+
+ def test_sort_by_node_name(self, client_no_auth, api_db_session):
+ """sort=node_name sorts by display name (COALESCE)."""
+ from meshcore_hub.common.models import Node
+
+ now = datetime.now(timezone.utc)
+ node_b = Node(
+ public_key="aa" * 16,
+ name="Bravo",
+ first_seen=now,
+ )
+ node_a = Node(
+ public_key="bb" * 16,
+ name="Alpha",
+ first_seen=now,
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ ad_b = Advertisement(
+ public_key="aa" * 16,
+ name="AdBravo",
+ adv_type="CLIENT",
+ received_at=now,
+ node_id=node_b.id,
+ )
+ ad_a = Advertisement(
+ public_key="bb" * 16,
+ name="AdAlpha",
+ adv_type="CLIENT",
+ received_at=now,
+ node_id=node_a.id,
+ )
+ api_db_session.add_all([ad_b, ad_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["node_name"] == "Alpha"
+ assert items[1]["node_name"] == "Bravo"
+
+ def test_sort_by_node_name_tag_priority(self, client_no_auth, api_db_session):
+ """Name tag takes priority over SourceNode.name in sort."""
+ from meshcore_hub.common.models import Node, NodeTag
+
+ now = datetime.now(timezone.utc)
+ node_b = Node(
+ public_key="aa" * 16,
+ name="Alpha",
+ first_seen=now,
+ )
+ node_a = Node(
+ public_key="bb" * 16,
+ name="Bravo",
+ first_seen=now,
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra")
+ tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark")
+ api_db_session.add_all([tag_b, tag_a])
+ api_db_session.commit()
+
+ ad_b = Advertisement(
+ public_key="aa" * 16,
+ name="AdB",
+ adv_type="CLIENT",
+ received_at=now,
+ node_id=node_b.id,
+ )
+ ad_a = Advertisement(
+ public_key="bb" * 16,
+ name="AdA",
+ adv_type="CLIENT",
+ received_at=now,
+ node_id=node_a.id,
+ )
+ api_db_session.add_all([ad_b, ad_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["node_name"] == "Bravo"
+ assert items[1]["node_name"] == "Alpha"
+
+ def test_sort_by_public_key(self, client_no_auth, api_db_session):
+ """sort=public_key orders by public_key."""
+ now = datetime.now(timezone.utc)
+ ad_b = Advertisement(
+ public_key="bb" * 16,
+ name="Bravo",
+ adv_type="CLIENT",
+ received_at=now,
+ )
+ ad_a = Advertisement(
+ public_key="aa" * 16,
+ name="Alpha",
+ adv_type="CLIENT",
+ received_at=now,
+ )
+ api_db_session.add_all([ad_b, ad_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get(
+ "/api/v1/advertisements?sort=public_key&order=asc"
+ )
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["public_key"] == "aa" * 16
+
+ def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
+ """Invalid sort value falls back to default (time desc)."""
+ now = datetime.now(timezone.utc)
+ ad_old = Advertisement(
+ public_key="aa" * 16,
+ name="Old",
+ adv_type="CLIENT",
+ received_at=now - timedelta(hours=1),
+ )
+ ad_new = Advertisement(
+ public_key="bb" * 16,
+ name="New",
+ adv_type="CLIENT",
+ received_at=now,
+ )
+ api_db_session.add_all([ad_old, ad_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/advertisements?sort=invalid_column")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "New"
diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py
index 8240b6d..6be6af8 100644
--- a/tests/test_api/test_messages.py
+++ b/tests/test_api/test_messages.py
@@ -347,3 +347,121 @@ class TestListMessagesFilters:
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
+
+
+class TestMessageSort:
+ """Tests for message list sort parameters."""
+
+ def test_sort_by_time_default(self, client_no_auth, api_db_session):
+ """Default sort is received_at DESC."""
+ now = datetime.now(timezone.utc)
+ msg_old = Message(
+ message_type="direct",
+ pubkey_prefix="aa",
+ text="Old msg",
+ received_at=now - timedelta(hours=1),
+ )
+ msg_new = Message(
+ message_type="direct",
+ pubkey_prefix="bb",
+ text="New msg",
+ received_at=now,
+ )
+ api_db_session.add_all([msg_old, msg_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/messages")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["text"] == "New msg"
+ assert items[1]["text"] == "Old msg"
+
+ def test_sort_by_type(self, client_no_auth, api_db_session):
+ """sort=type&order=asc sorts by message_type."""
+ now = datetime.now(timezone.utc)
+ msg_ch = Message(
+ message_type="channel",
+ channel_idx=1,
+ text="Channel msg",
+ received_at=now,
+ )
+ msg_ct = Message(
+ message_type="contact",
+ text="Contact msg",
+ received_at=now,
+ )
+ api_db_session.add_all([msg_ch, msg_ct])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/messages?sort=type&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["message_type"] == "channel"
+ assert items[1]["message_type"] == "contact"
+
+ def test_sort_by_from(self, client_no_auth, api_db_session):
+ """sort=from&order=asc sorts by pubkey_prefix."""
+ now = datetime.now(timezone.utc)
+ msg_b = Message(
+ message_type="direct",
+ pubkey_prefix="bb_prefix",
+ text="From B",
+ received_at=now,
+ )
+ msg_a = Message(
+ message_type="direct",
+ pubkey_prefix="aa_prefix",
+ text="From A",
+ received_at=now,
+ )
+ api_db_session.add_all([msg_b, msg_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/messages?sort=from&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["text"] == "From A"
+ assert items[1]["text"] == "From B"
+
+ def test_sort_by_message(self, client_no_auth, api_db_session):
+ """sort=message&order=asc sorts by text."""
+ now = datetime.now(timezone.utc)
+ msg_b = Message(
+ message_type="direct",
+ text="Zebra message",
+ received_at=now,
+ )
+ msg_a = Message(
+ message_type="direct",
+ text="Alpha message",
+ received_at=now,
+ )
+ api_db_session.add_all([msg_b, msg_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/messages?sort=message&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["text"] == "Alpha message"
+ assert items[1]["text"] == "Zebra message"
+
+ def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
+ """Invalid sort value falls back to default (time desc)."""
+ now = datetime.now(timezone.utc)
+ msg_old = Message(
+ message_type="direct",
+ text="Old",
+ received_at=now - timedelta(hours=1),
+ )
+ msg_new = Message(
+ message_type="direct",
+ text="New",
+ received_at=now,
+ )
+ api_db_session.add_all([msg_old, msg_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/messages?sort=invalid_column")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["text"] == "New"
diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py
index 476d342..6d7580f 100644
--- a/tests/test_api/test_nodes.py
+++ b/tests/test_api/test_nodes.py
@@ -466,6 +466,234 @@ class TestNodeTags:
assert data["value"] == "admin-val"
+class TestNodeSort:
+ """Tests for node list sort parameters."""
+
+ def test_sort_by_name_default(self, client_no_auth, api_db_session):
+ """Default sort (no params) returns nodes alpha by display name."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ last_seen=datetime.now(timezone.utc),
+ )
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ last_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert len(items) == 2
+ assert items[0]["name"] == "Alpha"
+ assert items[1]["name"] == "Bravo"
+
+ def test_sort_by_name_asc(self, client_no_auth, api_db_session):
+ """Explicit sort=name&order=asc."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Alpha"
+
+ def test_sort_by_name_desc(self, client_no_auth, api_db_session):
+ """sort=name&order=desc returns Z-to-A."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_a, node_b])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=name&order=desc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Bravo"
+ assert items[1]["name"] == "Alpha"
+
+ def test_sort_by_public_key(self, client_no_auth, api_db_session):
+ """sort=public_key orders by public_key."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=public_key&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["public_key"] == "aa" * 32
+
+ def test_sort_by_last_seen(self, client_no_auth, api_db_session):
+ """sort=last_seen&order=asc returns oldest first."""
+ from datetime import datetime, timedelta, timezone
+
+ from meshcore_hub.common.models import Node
+
+ now = datetime.now(timezone.utc)
+ node_old = Node(
+ public_key="aa" * 32,
+ name="Old",
+ adv_type="CLIENT",
+ first_seen=now - timedelta(days=2),
+ last_seen=now - timedelta(days=1),
+ )
+ node_new = Node(
+ public_key="bb" * 32,
+ name="New",
+ adv_type="CLIENT",
+ first_seen=now,
+ last_seen=now,
+ )
+ api_db_session.add_all([node_old, node_new])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=last_seen&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Old"
+ assert items[1]["name"] == "New"
+
+ def test_sort_name_tag_priority(self, client_no_auth, api_db_session):
+ """Name tag takes priority over node.name in sort."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node, NodeTag
+
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra")
+ tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark")
+ api_db_session.add_all([tag_b, tag_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Bravo"
+ assert items[1]["name"] == "Alpha"
+
+ def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
+ """Invalid sort value falls back to default (name alpha)."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_b = Node(
+ public_key="bb" * 32,
+ name="Bravo",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_a = Node(
+ public_key="aa" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_b, node_a])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=invalid_column")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Alpha"
+
+ def test_sort_nodes_with_null_name(self, client_no_auth, api_db_session):
+ """Nodes with name=NULL sort by public_key via COALESCE fallback."""
+ from datetime import datetime, timezone
+
+ from meshcore_hub.common.models import Node
+
+ node_no_name = Node(
+ public_key="bb" * 32,
+ name=None,
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ node_named = Node(
+ public_key="aa" * 32,
+ name="Alpha",
+ adv_type="CLIENT",
+ first_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add_all([node_no_name, node_named])
+ api_db_session.commit()
+
+ response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items[0]["name"] == "Alpha"
+ assert items[1]["name"] is None
+
+
class TestTagValidation:
"""Unit tests for validate_and_coerce_tag_value."""