diff --git a/README.md b/README.md index bf1065b..ecddc4b 100644 --- a/README.md +++ b/README.md @@ -241,10 +241,10 @@ Example response: - [x] Hide features from users not able to use them based on authorization. (→ auth-management links hidden for read-only `user` role; route guard blocks direct URL access; the API enforces 403 server-side regardless) - [x] Fix dashboard view, message expanding outside table. Make it look like the table in messages view. (→ payload cell now uses the same collapsed `
` + pretty-printed JSON + attributes block as the Messages view) - [x] Update tables displaying messages so they also contain a column with the imei, and not only device. (→ IMEI column added to Dashboard + Messages; per the IMEI-first sweep, **links moved to the IMEI** — the device name is plain text everywhere, on the Devices list too) -- [ ] Add possability to sort and filter on table headers. +- [x] Add possbility to sort and filter on table headers. Filtering should be able to be performed with free text and by selecting/deselecting values that occures or might occure in each column. (→ click headers to sort (asc → desc → default); funnel icons open per-column filters with free-text search and value checkboxes. Devices/Users filter client-side; **Messages filters server-side** via `sort`, repeatable `imei`/`device`, and `imei_like`/`device_like` params — facet values come from the full device list, so filtering is correct across all pages) - [x] Remove clickable device links in tables for users that is not allowed to see the device view, or does not have enought authorization to be able to change anything. CLicking as a user ends up on a page with only some text. (→ resolved by the IMEI-first sweep: names are no longer links for anyone; the IMEI link leads to a legitimate read-only view for every role) - [ ] Change datetime localization. How is it handled now, from browser or static? I'm in sweden and not following swedish rules on layout. -- [ ] Make it possible to manipulate a messeges payload. +- [ ] Make it possible to manipulate a messages payload. - [ ] Make it possible, for superusers and admins, to manually inject new messages using json from an "insert message" view. It should allow array of json objects as well as a single message json object. - [ ] Change SESSION_TTL_DAYS to SESSION_TTL_MINUTES. Make SESSION_TTL_MINUTES being read from environmental variable. Default to 5 minutes. - [ ] Add user information. Name, mail address diff --git a/api/app.py b/api/app.py index 5a5957a..68fc6af 100644 --- a/api/app.py +++ b/api/app.py @@ -1474,8 +1474,26 @@ def create_app( @app.get("/messages", tags=["data"], dependencies=[Depends(authenticate)]) def list_messages( - imei: str | None = Query(None, description="Filter by IMEI (15 digits)"), - device: str | None = Query(None, description="Filter by device name"), + imei: list[str] | None = Query( + None, description="Filter by IMEI (repeatable for a multi-select)" + ), + device: list[str] | None = Query( + None, description="Filter by device name (repeatable for a multi-select)" + ), + imei_like: str | None = Query( + None, description="Case-insensitive contains filter on IMEI" + ), + device_like: str | None = Query( + None, description="Case-insensitive contains filter on device name" + ), + sort: str | None = Query( + None, + description=( + "Sort column: id, received_at, imei, device or payload_time; " + "prefix with - for descending (e.g. -received_at). Default: " + "payload_time DESC when framing on it, else id DESC (newest first)." + ), + ), from_time: datetime | None = Query( None, alias="from", @@ -1532,12 +1550,23 @@ def create_app( # the query joins imei_map for the read-time settings merge. conditions: list[sql.Composable] = [] params: list[Any] = [] + # Repeatable exact-match filters: ?imei=a&imei=b -> = ANY(...). + # A single value keeps working (list of one). if imei: - conditions.append(sql.SQL("m.imei = %s")) + conditions.append(sql.SQL("m.imei = ANY(%s)")) params.append(imei) if device: - conditions.append(sql.SQL("m.device = %s")) + conditions.append(sql.SQL("m.device = ANY(%s)")) params.append(device) + # Free-text contains filters for the column filter boxes. The whole + # pattern is ONE parameter - psycopg forbids literal % inside SQL + # strings (placeholder parsing). + if imei_like: + conditions.append(sql.SQL("m.imei ILIKE %s")) + params.append(f"%{imei_like}%") + if device_like: + conditions.append(sql.SQL("m.device ILIKE %s")) + params.append(f"%{device_like}%") if from_time is not None: conditions.append(sql.SQL("m.received_at >= %s")) params.append(_as_utc(from_time)) @@ -1555,9 +1584,35 @@ def create_app( if conditions: where = sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions) - # Order by device timestamp when framing on it (NULLS LAST matches the - # idx_mqtt_messages_ptime index); otherwise newest-first by insert id. - if payload_from_time is not None or payload_to_time is not None: + # Sort: explicit ?sort= wins; default stays "payload_time DESC when + # framing on it, else id DESC". The whitelist maps to pre-built sql + # fragments - user input is never interpolated into the ORDER BY. + SORTABLE = { + "id": sql.SQL("m.id"), + "received_at": sql.SQL("m.received_at"), + "imei": sql.SQL("m.imei"), + "device": sql.SQL("m.device"), + "payload_time": sql.SQL("m.payload_time"), + } + if sort is not None: + desc = sort.startswith("-") + name = sort[1:] if desc else sort + column = SORTABLE.get(name) + if column is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"unknown sort column {sort!r}; available: " + + ", ".join(SORTABLE) + ), + ) + order_by = ( + sql.SQL(" ORDER BY ") + + column + + (sql.SQL(" DESC") if desc else sql.SQL("")) + + sql.SQL(" NULLS LAST LIMIT %s OFFSET %s") + ) + elif payload_from_time is not None or payload_to_time is not None: order_by = sql.SQL(" ORDER BY m.payload_time DESC NULLS LAST LIMIT %s OFFSET %s") else: order_by = sql.SQL(" ORDER BY m.id DESC LIMIT %s OFFSET %s") diff --git a/frontend/src/components/TableHeader.vue b/frontend/src/components/TableHeader.vue new file mode 100644 index 0000000..be8e81c --- /dev/null +++ b/frontend/src/components/TableHeader.vue @@ -0,0 +1,146 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/composables/useTable.ts b/frontend/src/composables/useTable.ts new file mode 100644 index 0000000..5e26f7f --- /dev/null +++ b/frontend/src/composables/useTable.ts @@ -0,0 +1,112 @@ +// Client-side table sorting + filtering, shared by the fully-loaded tables +// (Devices, Users). The paginated Messages view talks to the API instead — +// only the popover/header components are shared with it. +// +// A column is configured with: +// get: row -> value +// sort: 'string' | 'number' (default 'string') +// filter: 'text' | 'facet' | 'text+facet' (default: no filtering) + +import { computed, reactive, ref, type Ref } from 'vue' + +export type FilterKind = 'text' | 'facet' | 'text+facet' + +export interface ColumnConfig { + get: (row: T) => string | number + sort?: 'string' | 'number' + filter?: FilterKind +} + +export interface ColumnState { + text: string + checked: Set + open: boolean +} + +export function useTable(rows: Ref, columns: Record>) { + const state = reactive>({}) + const sortKey = ref(null) + const sortDir = ref<'asc' | 'desc'>('asc') + + function col(name: string): ColumnState { + if (!state[name]) state[name] = { text: '', checked: new Set(), open: false } + return state[name] + } + + function toggleSort(name: string): void { + if (sortKey.value === null || sortKey.value !== name) { + sortKey.value = name + sortDir.value = 'asc' + } else if (sortDir.value === 'asc') { + sortDir.value = 'desc' + } else { + sortKey.value = null // third click: back to default order + } + } + + function distinctValues(name: string): { value: string; count: number }[] { + const cfg = columns[name] + const counts = new Map() + for (const row of rows.value) { + const v = String(cfg.get(row)) + counts.set(v, (counts.get(v) ?? 0) + 1) + } + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => a.value.localeCompare(b.value)) + } + + function passesFilter(name: string, value: string): boolean { + const cfg = columns[name] + const st = state[name] + if (!cfg.filter || !st) return true + if (st.text && !value.toLowerCase().includes(st.text.toLowerCase())) return false + if (st.checked.size > 0 && !st.checked.has(value)) return false + return true + } + + const sortedFilteredRows = computed(() => { + let out = rows.value.filter((row) => { + for (const name of Object.keys(columns)) { + const cfg = columns[name] + if (!cfg.filter) continue + if (!passesFilter(name, String(cfg.get(row)))) return false + } + return true + }) + if (sortKey.value !== null) { + const cfg = columns[sortKey.value] + const dir = sortDir.value === 'asc' ? 1 : -1 + out = [...out].sort((a, b) => { + const va = cfg.get(a) + const vb = cfg.get(b) + if (cfg.sort === 'number') return ((va as number) - (vb as number)) * dir + return String(va).localeCompare(String(vb)) * dir + }) + } + return out + }) + + function clearColumn(name: string): void { + const st = state[name] + if (!st) return + st.text = '' + st.checked = new Set() + } + + function activeFilterCount(): number { + return Object.values(state).filter((st) => st.text !== '' || st.checked.size > 0).length + } + + return { + col, + state, + sortKey, + sortDir, + toggleSort, + distinctValues, + sortedFilteredRows, + clearColumn, + activeFilterCount, + } +} \ No newline at end of file diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue index 59defce..a3572ef 100644 --- a/frontend/src/views/Devices.vue +++ b/frontend/src/views/Devices.vue @@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue' import { Cpu, Plus, Trash2 } from 'lucide-vue-next' import { api } from '../api' import type { Device } from '../api' +import { useTable } from '../composables/useTable' +import TableHeader from '../components/TableHeader.vue' import { canEdit, showError, toast } from '../store' const devices = ref([]) @@ -10,6 +12,23 @@ const loaded = ref(false) const newImei = ref('') const newName = ref('') +const table = useTable(devices, { + imei: { get: (d) => d.imei, filter: 'text' }, + device: { get: (d) => d.device, filter: 'text+facet' }, +}) + +function onFilterText(name: string, text: string) { + table.col(name).text = text +} +function onFilterCheck(name: string, value: string, checked: boolean) { + const st = table.col(name) + if (checked) st.checked.add(value) + else st.checked.delete(value) +} +function onToggleOpen(name: string) { + table.col(name).open = !table.col(name).open +} + async function load() { try { devices.value = await api('/api/devices') @@ -69,13 +88,40 @@ async function removeDevice(d: Device) { - - + + - + diff --git a/frontend/src/views/Messages.vue b/frontend/src/views/Messages.vue index 1bfe504..1e237b0 100644 --- a/frontend/src/views/Messages.vue +++ b/frontend/src/views/Messages.vue @@ -2,18 +2,29 @@ import { onMounted, ref } from 'vue' import { Inbox } from 'lucide-vue-next' import { api } from '../api' -import type { MessagePage } from '../api' +import type { Device, MessagePage } from '../api' import { formatTime } from '../format' import { showError } from '../store' +import TableHeader from '../components/TableHeader.vue' const page = ref(null) const loading = ref(false) const offset = ref(0) const limit = 25 -// filters -const imei = ref('') -const device = ref('') +// the full device population for the facet lists (not just the loaded page) +const devices = ref([]) + +// server-side sort + filters (mirrored into query params on load) +const sort = ref('') +const imeiChecked = ref(new Set()) +const deviceChecked = ref(new Set()) +const imeiText = ref('') +const deviceText = ref('') +const popOpen = ref(null) +let textTimer: number | undefined + +// date filters (the Received column's filter) const from = ref('') const to = ref('') @@ -21,8 +32,11 @@ function buildQuery(): string { const params = new URLSearchParams() params.set('limit', String(limit)) params.set('offset', String(offset.value)) - if (imei.value.trim()) params.set('imei', imei.value.trim()) - if (device.value.trim()) params.set('device', device.value.trim()) + if (sort.value) params.set('sort', sort.value) + for (const v of imeiChecked.value) params.append('imei', v) + for (const v of deviceChecked.value) params.append('device', v) + if (imeiText.value.trim()) params.set('imei_like', imeiText.value.trim()) + if (deviceText.value.trim()) params.set('device_like', deviceText.value.trim()) if (from.value) params.set('from', new Date(from.value).toISOString()) if (to.value) params.set('to', new Date(to.value).toISOString()) return `/api/messages?${params.toString()}` @@ -44,6 +58,43 @@ function applyFilters() { load() } +function toggleSort(name: string) { + // cycle asc -> desc -> default for the given column key + const current = sort.value + if (current !== name && current !== `-${name}`) sort.value = name + else if (current === name) sort.value = `-${name}` + else sort.value = '' + applyFilters() +} + +function sortState(name: string): '' | 'asc' | 'desc' { + if (sort.value === name) return 'asc' + if (sort.value === `-${name}`) return 'desc' + return '' +} + +function facetValues(kind: 'imei' | 'device'): { value: string; count: number }[] { + // values from the full device list ("might occur"), not just the loaded page + if (kind === 'imei') return devices.value.map((d) => ({ value: d.imei, count: 1 })) + return devices.value.map((d) => ({ value: d.device, count: 1 })) +} + +function onCheck(kind: 'imei' | 'device', value: string, checked: boolean) { + const target = kind === 'imei' ? imeiChecked : deviceChecked + if (checked) target.value.add(value) + else target.value.delete(value) + applyFilters() +} + +function onText(kind: 'imei' | 'device', text: string) { + if (kind === 'imei') imeiText.value = text + else deviceText.value = text + // server-side filtering: reload shortly after typing stops (the + // client-side tables filter reactively; this one needs a query) + window.clearTimeout(textTimer) + textTimer = window.setTimeout(applyFilters, 400) +} + function prev() { offset.value = Math.max(0, offset.value - limit) load() @@ -54,20 +105,25 @@ function next() { load() } -onMounted(load) +onMounted(async () => { + load() + try { + devices.value = await api('/api/devices') + } catch { + // facet lists just stay empty; the text filters still work + } +})
IMEIDevice
{{ d.imei }}