feat: change default nodes sort to last_seen DESC and add mobile sort controls

- Change API and frontend default sort from name/asc to last_seen/desc
- Add mobileSortSelect() shared component for native select dropdown
- Add mobile sort select to nodes, advertisements, and messages pages
- Add i18n sort labels for all three list pages
- Update sort tests for new default with staggered timestamps
This commit is contained in:
Louis King
2026-05-05 19:28:28 +01:00
parent 73469b714f
commit 8dc6ccdad0
10 changed files with 519 additions and 38 deletions
+43 -1
View File
@@ -182,6 +182,7 @@ Toast/flash messages after successful operations:
| `snr_db` | SNR (dB) | Signal-to-noise ratio in decibels (table header) |
| `unnamed` | Unnamed | Fallback for unnamed items |
| `unnamed_node` | Unnamed Node | Fallback for unnamed nodes |
| `sort_by` | Sort by | Label before mobile sort dropdown |
**Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers.
@@ -280,9 +281,35 @@ Node detail page labels:
| `release_success` | Node released successfully | Flash message after releasing |
| `release_confirm` | Are you sure you want to release this node? | Confirmation dialog for release |
#### Sort Options (`nodes.sort`)
Mobile sort dropdown labels for the nodes list page:
| Key | English | Context |
|-----|---------|---------|
| `sort.last_seen_newest` | Last Seen (newest) | Default sort: most recently seen first |
| `sort.last_seen_oldest` | Last Seen (oldest) | Least recently seen first |
| `sort.name_az` | Name (AZ) | Alphabetical by display name |
| `sort.name_za` | Name (ZA) | Reverse alphabetical by display name |
| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending |
| `sort.key_desc` | Public Key (descending) | Sorted by public key descending |
### 10. `advertisements`
Currently empty - advertisements page uses common patterns.
Sort options for the advertisements list page:
#### Sort Options (`advertisements.sort`)
Mobile sort dropdown labels for the advertisements list page:
| Key | English | Context |
|-----|---------|---------|
| `sort.newest` | Time (newest) | Default sort: most recent first |
| `sort.oldest` | Time (oldest) | Oldest first |
| `sort.node_az` | Node (AZ) | Alphabetical by node display name |
| `sort.node_za` | Node (ZA) | Reverse alphabetical by node display name |
| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending |
| `sort.key_desc` | Public Key (descending) | Sorted by public key descending |
### 11. `messages`
@@ -295,6 +322,21 @@ Message type labels:
| `type_contact` | Contact | Contact message type |
| `type_public` | Public | Public message type |
#### Sort Options (`messages.sort`)
Mobile sort dropdown labels for the messages list page:
| Key | English | Context |
|-----|---------|---------|
| `sort.newest` | Time (newest) | Default sort: most recent first |
| `sort.oldest` | Time (oldest) | Oldest first |
| `sort.type_az` | Type (AZ) | Alphabetical by message type |
| `sort.type_za` | Type (ZA) | Reverse alphabetical by message type |
| `sort.from_az` | From (AZ) | Alphabetical by sender name |
| `sort.from_za` | From (ZA) | Reverse alphabetical by sender name |
| `sort.message_az` | Message (AZ) | Alphabetical by message text |
| `sort.message_za` | Message (ZA) | Reverse alphabetical by message text |
### 12. `map`
Map page content:
@@ -0,0 +1,264 @@
# Plan: Default Nodes Sort by Time + Mobile Sort Controls
**Date:** 2026-05-05
**Branch:** `feat/mobile-sort-time-default`
---
## Background
Two issues with the current sorting UX:
1. **Nodes default sort is alphabetical by name** (`sort=name, order=asc`). For a mesh network dashboard, sorting by most recently seen is more useful — operators want to see active nodes first.
2. **Mobile view has no sort controls.** The desktop table view has clickable column headers, but the mobile card view (`lg:hidden`) renders a separate card layout with no sort mechanism. Mobile users cannot change the sort order at all.
---
## Feature 1: Change Default Nodes Sort to Last Seen (Descending)
### 1A. API — `src/meshcore_hub/api/routes/nodes.py`
Change lines 205206:
```python
# Before
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")
# After
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "last_seen"
order = order if order in ("asc", "desc") else "desc"
```
The default when no `sort`/`order` params are provided is now `last_seen DESC` (newest first).
### 1B. Frontend — `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
Change lines 1920:
```javascript
// Before
const sort = query.sort || 'name';
const order = query.order || 'asc';
// After
const sort = query.sort || 'last_seen';
const order = query.order || 'desc';
```
This ensures the "Last Seen" column header shows the correct ▾ indicator on first load.
### 1C. Tests
Update existing sort tests in `tests/test_api/test_nodes.py` that assume `name` default:
- **`test_sort_by_name_default`** (line 472): Change name/docstring to `test_sort_by_last_seen_default`. Both nodes currently get identical `last_seen=datetime.now(timezone.utc)` timestamps — they must be given **staggered** timestamps (e.g., 1 hour apart) so the `last_seen DESC` default produces a deterministic order. Assert newest-first (`node_b` first if it has the later timestamp).
- **`test_sort_invalid_ignored`** (line 643): Update docstring from "falls back to default (name alpha)" to "**falls back to default (last_seen desc)**". Both nodes currently have `last_seen=NULL` (only `first_seen` is set), so the sort order would be non-deterministic. Give the nodes staggered `last_seen` timestamps and assert the newest-first ordering.
---
## Feature 2: Mobile Sort Select Dropdown
### Design
A compact native `<select>` dropdown shown only on mobile (below `lg` breakpoint), positioned between the stats row and the card list. Combines sort column and direction into a single control for minimal UI footprint.
```
┌──────────────────────────────┐
│ Nodes 🕐 30s │ ← page title + auto-refresh
│ 42 total │ ← stats badges
│ │
│ Sort: [Last Seen (newest) ▾] │ ← NEW: mobile sort select
│ │
│ ┌────────────────────────┐ │
│ │ Node ABC 2 min ago │ │ ← mobile cards
│ └────────────────────────┘ │
│ ┌────────────────────────┐ │
│ │ Node XYZ 5 min ago │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
```
### Why native `<select>`
| Factor | Native `<select>` | Pill chips | Dropdown menu |
|--------|-------------------|------------|---------------|
| Touch optimization | Native iOS/Android picker | Custom tap targets | Custom tap targets |
| UI footprint | 1 line | 12 lines | 1 line + overlay |
| Discoverability | All options visible in picker | Only visible pills | Hidden until opened |
| Consistency | Matches filter `<select>` already used | New pattern | New pattern |
| Accessibility | Built-in | Needs ARIA | Needs ARIA |
### 2A. Shared component — `src/meshcore_hub/web/static/js/spa/components.js`
Add a `mobileSortSelect()` function. It calls `buildSortUrl()` (line 18), which is a **module-scoped (non-exported) function** in the same file — accessible via closure scope, no export needed.
```javascript
export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) {
const currentValue = `${currentSort}:${currentOrder}`;
const sortOptions = options.map(opt =>
html`<option value=${opt.value} ?selected=${opt.value === currentValue}>${opt.label}</option>`
);
const onChange = (e) => {
const [sort, order] = e.target.value.split(':');
const url = buildSortUrl(basePath, params, sort, order);
navigate(url);
};
return html`<div class="lg:hidden mb-3">
<div class="flex items-center gap-2">
<span class="text-xs opacity-60">${t('common.sort_by')}</span>
<select class="select select-sm select-bordered flex-1"
@change=${onChange}>
${sortOptions}
</select>
</div>
</div>`;
}
```
**Parameters:**
- `currentSort` / `currentOrder`: current sort state from URL
- `navigate`: SPA router navigate function
- `basePath`: e.g., `'/nodes'`
- `params`: filter params to preserve
- `options`: array of `{ value: 'sort:order', label: 'Display Name' }` objects
### 2B. Nodes page — `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
Add the mobile sort select between the stats row and the card list:
```javascript
import { mobileSortSelect } from '../components.js';
// Define sort options
const sortOptions = [
{ value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') },
{ value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') },
{ value: 'name:asc', label: t('nodes.sort.name_az') },
{ value: 'name:desc', label: t('nodes.sort.name_za') },
{ value: 'public_key:asc', label: t('nodes.sort.key_asc') },
{ value: 'public_key:desc', label: t('nodes.sort.key_desc') },
];
// In the render, between stats badges and mobile cards:
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/nodes',
params: headerParams, options: sortOptions,
})}
```
### 2C. Advertisements page — `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
Same pattern, with ad-specific sort options:
```javascript
const sortOptions = [
{ value: 'time:desc', label: t('advertisements.sort.newest') },
{ value: 'time:asc', label: t('advertisements.sort.oldest') },
{ value: 'node_name:asc', label: t('advertisements.sort.node_az') },
{ value: 'node_name:desc', label: t('advertisements.sort.node_za') },
{ value: 'public_key:asc', label: t('advertisements.sort.key_asc') },
{ value: 'public_key:desc', label: t('advertisements.sort.key_desc') },
];
```
### 2D. Messages page — `src/meshcore_hub/web/static/js/spa/pages/messages.js`
Same pattern, with message-specific sort options:
```javascript
const sortOptions = [
{ value: 'time:desc', label: t('messages.sort.newest') },
{ value: 'time:asc', label: t('messages.sort.oldest') },
{ value: 'type:asc', label: t('messages.sort.type_az') },
{ value: 'type:desc', label: t('messages.sort.type_za') },
{ value: 'from:asc', label: t('messages.sort.from_az') },
{ value: 'from:desc', label: t('messages.sort.from_za') },
{ value: 'message:asc', label: t('messages.sort.message_az') },
{ value: 'message:desc', label: t('messages.sort.message_za') },
];
```
---
## i18n Changes
### `src/meshcore_hub/web/static/locales/en.json`
Add new keys:
```json
{
"common": {
"sort_by": "Sort by"
},
"nodes": {
"sort": {
"last_seen_newest": "Last Seen (newest)",
"last_seen_oldest": "Last Seen (oldest)",
"name_az": "Name (A\u2013Z)",
"name_za": "Name (Z\u2013A)",
"key_asc": "Public Key (ascending)",
"key_desc": "Public Key (descending)"
}
},
"advertisements": {
"sort": {
"newest": "Time (newest)",
"oldest": "Time (oldest)",
"node_az": "Node (A\u2013Z)",
"node_za": "Node (Z\u2013A)",
"key_asc": "Public Key (ascending)",
"key_desc": "Public Key (descending)"
}
},
"messages": {
"sort": {
"newest": "Time (newest)",
"oldest": "Time (oldest)",
"type_az": "Type (A\u2013Z)",
"type_za": "Type (Z\u2013A)",
"from_az": "From (A\u2013Z)",
"from_za": "From (Z\u2013A)",
"message_az": "Message (A\u2013Z)",
"message_za": "Message (Z\u2013A)"
}
}
}
```
---
## Files Changed
| File | Change |
|------|--------|
| `src/meshcore_hub/api/routes/nodes.py` | Change default sort from `name ASC` to `last_seen DESC` |
| `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Change frontend default sort; add mobile sort select |
| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Add mobile sort select |
| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Add mobile sort select |
| `src/meshcore_hub/web/static/js/spa/components.js` | Add `mobileSortSelect()` export |
| `src/meshcore_hub/web/static/locales/en.json` | Add sort option translation keys |
| `docs/i18n.md` | Document new translation keys |
| `tests/test_api/test_nodes.py` | Update default sort test expectations |
---
## Sequence
1. Change default sort in API (`nodes.py`)
2. Change frontend default in `nodes.js`
3. Add `mobileSortSelect()` to `components.js`
4. Add mobile sort select to `nodes.js`
5. Add mobile sort select to `advertisements.js`
6. Add mobile sort select to `messages.js`
7. Add i18n keys to `en.json` and update `docs/i18n.md`
8. Update API tests
9. Run `pytest tests/test_api/`
10. Run `pre-commit run --all-files`
@@ -0,0 +1,74 @@
# Tasks: Default Nodes Sort by Time + Mobile Sort Controls
**Plan:** [plan.md](plan.md)
**Branch:** `feat/mobile-sort-time-default`
---
## Phase 1: Change Default Nodes Sort to Last Seen (Descending)
- [ ] **1.1** Update API default sort in `src/meshcore_hub/api/routes/nodes.py` (lines 205206)
- Change `else "name"``else "last_seen"`
- Change `else ("asc" if sort == "name" else "desc")``else "desc"`
- [ ] **1.2** Update frontend default sort in `src/meshcore_hub/web/static/js/spa/pages/nodes.js` (lines 1920)
- Change `query.sort || 'name'``query.sort || 'last_seen'`
- Change `query.order || 'asc'``query.order || 'desc'`
- [ ] **1.3** Update existing tests in `tests/test_api/test_nodes.py`
- **`test_sort_by_name_default`** (line 472): Rename to `test_sort_by_last_seen_default`
- Give the two test nodes **staggered** `last_seen` timestamps (e.g., 1 hour apart) instead of identical `datetime.now(timezone.utc)`
- Assert newest-first order (`node_b` first if it has the later timestamp)
- **`test_sort_invalid_ignored`** (line 643): Update docstring to "falls back to default (last_seen desc)"
- Give the two test nodes staggered `last_seen` timestamps and assert newest-first ordering
## Phase 2: Shared Mobile Sort Component
- [ ] **2.1** Add `mobileSortSelect()` export in `src/meshcore_hub/web/static/js/spa/components.js`
- Parameters: `{ currentSort, currentOrder, navigate, basePath, params, options }`
- Renders `lg:hidden` wrapper with label + native `<select>`
- Calls `buildSortUrl()` (module-scoped, line 18) via closure on change
- Splits `option.value` on `:` to extract sort/order, navigates via SPA router
- Mark currently-selected option with `?selected`
## Phase 3: Mobile Sort Select on Each List Page
- [ ] **3.1** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
- Import `mobileSortSelect` from `../components.js`
- Define sort options: `last_seen:desc`, `last_seen:asc`, `name:asc`, `name:desc`, `public_key:asc`, `public_key:desc`
- Insert `${mobileSortSelect(...)}` between stats badges and mobile card list
- Pass `headerParams` to preserve filter state
- [ ] **3.2** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
- Import `mobileSortSelect` from `../components.js`
- Define sort options: `time:desc`, `time:asc`, `node_name:asc`, `node_name:desc`, `public_key:asc`, `public_key:desc`
- Insert between stats badges and mobile card list
- [ ] **3.3** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/messages.js`
- Import `mobileSortSelect` from `../components.js`
- Define sort options: `time:desc`, `time:asc`, `type:asc`, `type:desc`, `from:asc`, `from:desc`, `message:asc`, `message:desc`
- Insert between stats badges and mobile card list
## Phase 4: i18n
- [ ] **4.1** Add sort translation keys to `src/meshcore_hub/web/static/locales/en.json`
- Add `common.sort_by`: "Sort by"
- Add `nodes.sort.*` (6 keys): `last_seen_newest`, `last_seen_oldest`, `name_az`, `name_za`, `key_asc`, `key_desc`
- Add `advertisements.sort.*` (6 keys): `newest`, `oldest`, `node_az`, `node_za`, `key_asc`, `key_desc`
- Add `messages.sort.*` (8 keys): `newest`, `oldest`, `type_az`, `type_za`, `from_az`, `from_za`, `message_az`, `message_za`
- [ ] **4.2** Document new keys in `docs/i18n.md`
## Verification
- [ ] Run `pytest tests/test_api/test_nodes.py -v`
- [ ] Run `pytest tests/test_common/test_i18n.py -v`
- [ ] Run `pre-commit run --all-files`
- [ ] Visual verification in browser:
- Nodes: default sort shows newest-first; Last Seen column header shows ▾ on first load
- Nodes mobile: sort select visible below `lg` breakpoint; changing it updates the card order
- Advertisements mobile: sort select with time/node/key options
- Messages mobile: sort select with time/type/from/message options
- Desktop view: no sort select visible (hidden at `lg:` breakpoint)
- Auto-refresh preserves sort state across ticks
- Filter change preserves sort state
+2 -2
View File
@@ -202,8 +202,8 @@ async def list_nodes(
total = session.execute(count_query).scalar() or 0
# 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")
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "last_seen"
order = order if order in ("asc", "desc") else "desc"
name_tag_subq = (
select(NodeTag.value)
@@ -58,6 +58,30 @@ export function sortableTableHeader(label, { sortKey, currentSort, currentOrder,
</th>`;
}
export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) {
const currentValue = `${currentSort}:${currentOrder}`;
const sortOptions = options.map(opt =>
html`<option value=${opt.value} ?selected=${opt.value === currentValue}>${opt.label}</option>`
);
const onChange = (e) => {
const [sort, order] = e.target.value.split(':');
const url = buildSortUrl(basePath, params, sort, order);
navigate(url);
};
return html`<div class="lg:hidden mb-5">
<div class="flex items-center gap-2">
<span class="text-xs opacity-60">${t('common.sort_by')}</span>
<select class="select select-sm select-bordered flex-1"
@change=${onChange}>
${sortOptions}
</select>
</div>
</div>`;
}
/**
* Get app config from the embedded window object.
* @returns {Object} App configuration
@@ -3,7 +3,7 @@ import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
warningBadge,
pagination, sortableTableHeader,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
@@ -239,6 +239,20 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/advertisements',
params: headerParams,
options: [
{ value: 'time:desc', label: t('advertisements.sort.newest') },
{ value: 'time:asc', label: t('advertisements.sort.oldest') },
{ value: 'node_name:asc', label: t('advertisements.sort.node_az') },
{ value: 'node_name:desc', label: t('advertisements.sort.node_za') },
{ value: 'public_key:asc', label: t('advertisements.sort.key_asc') },
{ value: 'public_key:desc', label: t('advertisements.sort.key_desc') },
],
})}
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
@@ -4,7 +4,7 @@ import {
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
getChannelLabelsMap, resolveChannelLabel,
truncateKey, warningBadge,
pagination, sortableTableHeader, timezoneIndicator,
pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator,
renderFilterCard, autoSubmit, submitOnEnter,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
@@ -386,6 +386,22 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/messages',
params: headerParams,
options: [
{ value: 'time:desc', label: t('messages.sort.newest') },
{ value: 'time:asc', label: t('messages.sort.oldest') },
{ value: 'type:asc', label: t('messages.sort.type_az') },
{ value: 'type:desc', label: t('messages.sort.type_za') },
{ value: 'from:asc', label: t('messages.sort.from_az') },
{ value: 'from:desc', label: t('messages.sort.from_za') },
{ value: 'message:asc', label: t('messages.sort.message_az') },
{ value: 'message:desc', label: t('messages.sort.message_za') },
],
})}
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
@@ -3,7 +3,7 @@ import {
html, litRender, nothing,
getConfig, formatDateTime, formatDateTimeShort,
warningBadge,
pagination, sortableTableHeader,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -16,8 +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 sort = query.sort || 'last_seen';
const order = query.order || 'desc';
const config = getConfig();
const tz = config.timezone || '';
@@ -186,6 +186,20 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/nodes',
params: headerParams,
options: [
{ value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') },
{ value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') },
{ value: 'name:asc', label: t('nodes.sort.name_az') },
{ value: 'name:desc', label: t('nodes.sort.name_za') },
{ value: 'public_key:asc', label: t('nodes.sort.key_asc') },
{ value: 'public_key:desc', label: t('nodes.sort.key_desc') },
],
})}
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
+32 -4
View File
@@ -105,7 +105,8 @@
"unnamed": "Unnamed",
"unnamed_node": "Unnamed Node",
"validation_invalid_number": "Value must be a valid number",
"validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0"
"validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0",
"sort_by": "Sort by"
},
"links": {
"website": "Website",
@@ -162,14 +163,41 @@
"not_adopted": "This node has not been adopted by any operator.",
"adopt_success": "Node adopted successfully",
"release_success": "Node released successfully",
"release_confirm": "Are you sure you want to release this node?"
"release_confirm": "Are you sure you want to release this node?",
"sort": {
"last_seen_newest": "Last Seen (newest)",
"last_seen_oldest": "Last Seen (oldest)",
"name_az": "Name (A\u2013Z)",
"name_za": "Name (Z\u2013A)",
"key_asc": "Public Key (ascending)",
"key_desc": "Public Key (descending)"
}
},
"advertisements": {
"sort": {
"newest": "Time (newest)",
"oldest": "Time (oldest)",
"node_az": "Node (A\u2013Z)",
"node_za": "Node (Z\u2013A)",
"key_asc": "Public Key (ascending)",
"key_desc": "Public Key (descending)"
}
},
"advertisements": {},
"messages": {
"type_direct": "Direct",
"type_channel": "Channel",
"type_contact": "Contact",
"type_public": "Public"
"type_public": "Public",
"sort": {
"newest": "Time (newest)",
"oldest": "Time (oldest)",
"type_az": "Type (A\u2013Z)",
"type_za": "Type (Z\u2013A)",
"from_az": "From (A\u2013Z)",
"from_za": "From (Z\u2013A)",
"message_az": "Message (A\u2013Z)",
"message_za": "Message (Z\u2013A)"
}
},
"map": {
"show_labels": "Show Labels",
+31 -26
View File
@@ -469,35 +469,36 @@ class TestNodeTags:
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
def test_sort_by_last_seen_default(self, client_no_auth, api_db_session):
"""Default sort (no params) returns nodes by last_seen descending."""
from datetime import datetime, timezone, timedelta
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),
)
now = 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),
first_seen=now,
last_seen=now,
)
api_db_session.add_all([node_b, node_a])
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=now,
last_seen=now + timedelta(hours=1),
)
api_db_session.add_all([node_a, node_b])
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"
assert items[0]["name"] == "Bravo"
assert items[1]["name"] == "Alpha"
def test_sort_by_name_asc(self, client_no_auth, api_db_session):
"""Explicit sort=name&order=asc."""
@@ -641,30 +642,34 @@ class TestNodeSort:
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
"""Invalid sort value falls back to default (last_seen desc)."""
from datetime import datetime, timezone, timedelta
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),
)
now = datetime.now(timezone.utc)
node_a = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
first_seen=now,
last_seen=now,
)
api_db_session.add_all([node_b, node_a])
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=now,
last_seen=now + timedelta(hours=1),
)
api_db_session.add_all([node_a, node_b])
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"
assert items[0]["name"] == "Bravo"
assert items[1]["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."""