🚧 Add message delete

This commit is contained in:
Jonas Bengtsson
2026-09-06 22:35:24 +02:00
parent 64847b9ca1
commit f3fc394c0c
7 changed files with 797 additions and 3 deletions
+213
View File
@@ -0,0 +1,213 @@
<script setup lang="ts">
// Retention dialog for the Messages view: lists soft-deleted messages
// (moved to retention by "Delete"), newest deletions first, with their own
// selection checkboxes. Superuser+ users can Restore selected rows back
// into mqtt_messages (with their original ids). Read-only viewers can see
// the list but get no restore controls (the API 403s regardless).
import { ref, watch } from 'vue'
import { ArchiveRestore, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import { canEdit, showError, toast } from '../store'
import { formatTime } from '../format'
import Pager from './Pager.vue'
export interface RetainedMessage {
id: number
received_at: string
topic: string
imei: string
device: string
payload: Record<string, unknown>
payload_time: string | null
deleted_at: string
deleted_by: string | null
expires_at: string
}
interface RetentionPage {
total: number
limit: number
offset: number
items: RetainedMessage[]
}
const props = defineProps<{
open: boolean
pageSize: number
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'restored', count: number): void
}>()
const page = ref<RetentionPage | null>(null)
const loading = ref(false)
const offset = ref(0)
const selected = ref(new Set<number>())
const restoring = ref(false)
function load(): void {
loading.value = true
api<RetentionPage>(`/api/messages/retention?limit=${props.pageSize}&offset=${offset.value}`)
.then((body) => {
page.value = body
})
.catch((e) => showError(e))
.finally(() => {
loading.value = false
})
}
// parent opens/closes via the open prop; (re)opening resets the selection
// and reloads the first page (retention contents change constantly).
watch(
() => props.open,
(open, wasOpen) => {
if (open && !wasOpen) {
offset.value = 0
selected.value = new Set()
load()
}
},
)
function allChecked(): boolean {
const items = page.value?.items ?? []
return items.length > 0 && items.every((m) => selected.value.has(m.id))
}
function toggleAll(): void {
const items = page.value?.items ?? []
if (!items.length) return
const next = new Set(selected.value)
if (allChecked()) {
for (const m of items) next.delete(m.id)
} else {
for (const m of items) next.add(m.id)
}
selected.value = next
}
function toggleRow(m: RetainedMessage, checked: boolean): void {
const next = new Set(selected.value)
if (checked) next.add(m.id)
else next.delete(m.id)
selected.value = next
}
async function restoreSelected(): Promise<void> {
if (!selected.value.size || restoring.value) return
restoring.value = true
try {
const result = await api<{ status: string; restored: number }>(
'/api/messages/retention/restore',
{
method: 'POST',
body: JSON.stringify({ ids: [...selected.value] }),
},
)
toast('success', `restored ${result.restored} message${result.restored === 1 ? '' : 's'}`)
emit('restored', result.restored)
offset.value = 0
selected.value = new Set()
load()
} catch (e) {
showError(e)
} finally {
restoring.value = false
}
}
</script>
<template>
<div v-if="open" class="card">
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
<div>
<strong>Retained messages</strong>
<span class="muted" style="margin-left: 0.5rem">
soft-deleted; auto-purged 30 days after deletion; restore returns them
with their original ids
</span>
</div>
<button class="ghost" style="padding: 0.1rem 0.4rem" @click="emit('close')">✕</button>
</div>
<div v-if="loading" class="skeleton line" />
<div v-else-if="page && !page.items.length" class="empty">
<Trash2 :size="28" />
<span>Nothing is in retention.</span>
</div>
<template v-if="page && page.items.length">
<table>
<thead>
<tr>
<th style="width: 1%">
<input
type="checkbox"
title="Select/deselect all on this page"
:checked="allChecked()"
@change="toggleAll"
/>
</th>
<th>Deleted</th>
<th>Received</th>
<th>IMEI</th>
<th>Device</th>
<th>Purged</th>
<th>Deleted by</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr v-for="m in page.items" :key="m.id">
<td>
<input
type="checkbox"
title="Select this message"
:checked="selected.has(m.id)"
@change="toggleRow(m, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td class="mono">{{ formatTime(m.deleted_at) }}</td>
<td class="mono">{{ formatTime(m.received_at) }}</td>
<td class="mono">
<router-link :to="`/devices/${m.imei}`">{{ m.imei }}</router-link>
</td>
<td>{{ m.device }}</td>
<td class="mono">{{ formatTime(m.expires_at) }}</td>
<td>{{ m.deleted_by ?? '—' }}</td>
<td>
<details>
<summary class="muted">payload</summary>
<pre class="json">{{ JSON.stringify(m.payload, null, 2) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
<div class="row" style="justify-content: space-between; margin-top: 0.5rem">
<span v-if="canEdit() && selected.size" class="muted">
{{ selected.size }} selected
</span>
<span v-else></span>
<button
v-if="canEdit()"
class="primary"
:disabled="restoring || !selected.size"
@click="restoreSelected"
>
<ArchiveRestore :size="15" />
{{ restoring ? 'Restoring…' : 'Restore selected' }}
</button>
</div>
<Pager
:offset="offset"
:limit="pageSize"
:total="page?.total ?? 0"
:loading="loading"
@update:offset="(v) => { offset = v; load() }"
/>
</template>
</div>
</template>
+77 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Download, Inbox, Upload } from 'lucide-vue-next'
import { ArchiveRestore, Download, Inbox, Trash2, Upload } from 'lucide-vue-next'
import { api } from '../api'
import type { Device, MessageItem, MessagePage } from '../api'
import { formatTime } from '../format'
@@ -8,6 +8,7 @@ import { canEdit, showError, toast } from '../store'
import { loadPageSize } from '../prefs'
import TableHeader from '../components/TableHeader.vue'
import Pager from '../components/Pager.vue'
import RetentionDialog from '../components/RetentionDialog.vue'
const page = ref<MessagePage | null>(null)
const loading = ref(false)
@@ -119,6 +120,58 @@ function toggleRow(m: MessageItem, checked: boolean): void {
selected.value = next
}
// --- retention (soft delete) ---------------------------------------------
// "Delete" moves the selected messages to retention (same id, restorable
// for RETENTION_DAYS); the banner shows the current retained count and
// opens the RetentionDialog.
const retentionCount = ref(0)
const retentionOpen = ref(false)
const deleting = ref(false)
async function loadRetentionCount(): Promise<void> {
try {
const body = await api<{ count: number }>('/api/messages/retention/count')
retentionCount.value = body.count
} catch {
// the banner simply stays hidden when the count is unknown
}
}
async function deleteSelected(): Promise<void> {
if (!selected.value.size || deleting.value) return
if (!confirm(`Move ${selected.value.size} message${selected.value.size === 1 ? '' : 's'} to retention? They are restorable there for 30 days.`)) {
return
}
deleting.value = true
try {
const result = await api<{ status: string; deleted: number }>(
'/api/messages/delete',
{ method: 'POST', body: JSON.stringify({ ids: [...selected.value] }) },
)
toast('success', `moved ${result.deleted} message${result.deleted === 1 ? '' : 's'} to retention`)
selected.value = new Set()
// if the current page became empty and we are not on the first page,
// step back one page (the total shrank)
const items = page.value?.items ?? []
if (offset.value > 0 && items.length && result.deleted >= items.length) {
offset.value = Math.max(0, offset.value - limit.value)
}
load()
loadRetentionCount()
} catch (e) {
showError(e)
} finally {
deleting.value = false
}
}
function onRestored(count: number): void {
// messages came back: refresh both lists
load()
loadRetentionCount()
if (count) toast('success', `${count} message${count === 1 ? '' : 's'} back in the list`)
}
// Export the selected messages RAW (as stored: no settings merge, no
// attributes) as a JSON file download. The ids are sent as repeatable
// query params; the response is a JSON array in the caller's order.
@@ -251,6 +304,7 @@ function onLimit(newLimit: number): void {
onMounted(async () => {
load()
loadRetentionCount()
try {
devices.value = await api<Device[]>('/api/devices')
} catch {
@@ -277,6 +331,19 @@ onMounted(async () => {
</button>
</div>
<div v-if="retentionCount > 0" class="card row" style="justify-content: space-between">
<span class="muted">
<ArchiveRestore :size="15" />
{{ retentionCount }} message{{ retentionCount === 1 ? '' : 's' }} in retention
(auto-purged after 30 days)
</span>
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="retentionOpen = true">
View / restore…
</button>
</div>
<RetentionDialog :open="retentionOpen" :page-size="limit" @close="retentionOpen = false" @restored="onRestored" />
<div v-if="importOpen" class="card row" style="align-items: flex-start">
<div style="flex: 1; min-width: 0">
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
@@ -339,6 +406,15 @@ onMounted(async () => {
<Download :size="15" />
{{ exporting ? 'Exporting…' : 'Export JSON' }}
</button>
<button
v-if="canEdit()"
class="danger"
:disabled="deleting"
@click="deleteSelected"
>
<Trash2 :size="15" />
{{ deleting ? 'Moving…' : 'Delete' }}
</button>
</div>
</div>