mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-06 18:01:17 +02:00
feat: add clickable sort controls to list pages with alpha default for nodes
Add sort/order query parameters to Nodes, Advertisements, and Messages API endpoints. Nodes default to alpha-by-name (via COALESCE of name tag, node name, public key). Ads and Messages default to newest-first. Frontend adds sortableTableHeader() component with asc/desc toggle indicators. Sort state is preserved in URL params, surviving auto-refresh and pagination.
This commit is contained in:
@@ -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 `<th>` 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 `<th>` with an `<a>` 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`<th>
|
||||
<a href=${url} class="link link-hover inline-flex items-center gap-1 no-underline"
|
||||
@click=${(e) => { e.preventDefault(); navigate(url); }}>
|
||||
${label}<span class="text-xs opacity-50">${indicator}</span>
|
||||
</a>
|
||||
</th>`;
|
||||
}
|
||||
```
|
||||
|
||||
**Not sortable:** Columns without a sort key render as plain `<th>` 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 `<th>` 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:
|
||||
<thead><tr>
|
||||
${sortable(t('entities.node'), 'name')}
|
||||
${sortable(t('common.public_key'), 'public_key')}
|
||||
${sortable(t('common.last_seen'), 'last_seen')}
|
||||
</tr></thead>
|
||||
```
|
||||
|
||||
**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:
|
||||
<thead><tr>
|
||||
${sortable(t('entities.node'), 'node_name')}
|
||||
${sortable(t('common.public_key'), 'public_key')}
|
||||
${sortable(t('common.time'), 'time')}
|
||||
<th>${t('common.observers')}</th>
|
||||
</tr></thead>
|
||||
```
|
||||
|
||||
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:
|
||||
<thead><tr>
|
||||
${sortable(t('common.type'), 'type')}
|
||||
${sortable(t('common.time'), 'time')}
|
||||
${sortable(t('common.from'), 'from')}
|
||||
${sortable(t('entities.message'), 'message')}
|
||||
<th>${t('common.observers')}</th>
|
||||
</tr></thead>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 `<th>` with sortable headers |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Parse sort/order, pass to API, replace `<th>` with sortable headers |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Parse sort/order, pass to API, replace `<th>` 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
|
||||
@@ -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 `<th><a>` 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 `<th>` 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 `<th>` elements with `sortableTableHeader()` calls for Node (`node_name`), Public Key, Time columns
|
||||
- Keep Observers column as plain `<th>`
|
||||
|
||||
## 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 `<th>` elements with `sortableTableHeader()` calls for Type, Time, From, Message columns
|
||||
- Keep Observers column as plain `<th>`
|
||||
|
||||
## 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`
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`<th>
|
||||
<a href=${url} class="link link-hover inline-flex items-center gap-1 no-underline"
|
||||
@click=${(e) => { e.preventDefault(); e.stopPropagation(); navigate(url); }}>
|
||||
${label}<span class="text-xs opacity-50">${indicator}</span>
|
||||
</a>
|
||||
</th>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app config from the embedded window object.
|
||||
* @returns {Object} App configuration
|
||||
|
||||
@@ -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}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
@@ -238,9 +247,9 @@ ${displayContent}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.time')}</th>
|
||||
${sortable(t('entities.node'), 'node_name')}
|
||||
${sortable(t('common.public_key'), 'public_key')}
|
||||
${sortable(t('common.time'), 'time')}
|
||||
<th>${t('common.observers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
@@ -386,10 +394,10 @@ ${displayContent}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('common.type')}</th>
|
||||
<th>${t('common.time')}</th>
|
||||
<th>${t('common.from')}</th>
|
||||
<th>${t('entities.message')}</th>
|
||||
${sortable(t('common.type'), 'type')}
|
||||
${sortable(t('common.time'), 'time')}
|
||||
${sortable(t('common.from'), 'from')}
|
||||
${sortable(t('entities.message'), 'message')}
|
||||
<th>${t('common.observers')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
@@ -186,9 +194,9 @@ ${displayContent}`, container);
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('entities.node')}</th>
|
||||
<th>${t('common.public_key')}</th>
|
||||
<th>${t('common.last_seen')}</th>
|
||||
${sortable(t('entities.node'), 'name')}
|
||||
${sortable(t('common.public_key'), 'public_key')}
|
||||
${sortable(t('common.last_seen'), 'last_seen')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user