🚧 Add message delete
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user