🚧 Add import
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Inbox } from 'lucide-vue-next'
|
||||
import { Download, Inbox, Upload } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import type { Device, MessagePage } from '../api'
|
||||
import type { Device, MessageItem, MessagePage } from '../api'
|
||||
import { formatTime } from '../format'
|
||||
import { showError } from '../store'
|
||||
import { canEdit, showError, toast } from '../store'
|
||||
import { loadPageSize } from '../prefs'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import Pager from '../components/Pager.vue'
|
||||
@@ -37,6 +37,116 @@ let textTimer: number | undefined
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
|
||||
// message selection for export. Ids are kept across page navigation and
|
||||
// filter changes (they are explicit rows the user checked); the export
|
||||
// endpoint silently skips ids that no longer exist, so a stale id is
|
||||
// harmless. Only the user clears the selection (Clear selection button or
|
||||
// unchecking).
|
||||
const selected = ref(new Set<number>())
|
||||
const exporting = ref(false)
|
||||
|
||||
// --- import (superuser+; the API enforces the role server-side) --------
|
||||
const importOpen = ref(false)
|
||||
const importText = ref('')
|
||||
const importing = ref(false)
|
||||
|
||||
// file input is hidden; the label button triggers it
|
||||
function onImportFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
importText.value = text
|
||||
toast('success', `loaded ${file.name} (${Math.round(file.size / 102.4) / 10} kB)`)
|
||||
})
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function doImport(): Promise<void> {
|
||||
if (importing.value) return
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(importText.value)
|
||||
} catch {
|
||||
toast('error', 'the text is not valid JSON')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await api<{ status: string; imported: number }>(
|
||||
'/api/messages/import',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
toast(
|
||||
'success',
|
||||
`imported ${result.imported} message${result.imported === 1 ? '' : 's'}`,
|
||||
)
|
||||
importOpen.value = false
|
||||
importText.value = ''
|
||||
offset.value = 0
|
||||
load()
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// every row on the current page selected?
|
||||
function allOnPageChecked(): boolean {
|
||||
const items = page.value?.items ?? []
|
||||
return items.length > 0 && items.every((m) => selected.value.has(m.id))
|
||||
}
|
||||
|
||||
// header checkbox: selects/deselects ONLY the current page's rows (other
|
||||
// pages' selections stay; "Clear selection" is the explicit full clear).
|
||||
function toggleSelectAll(): void {
|
||||
const items = page.value?.items ?? []
|
||||
if (!items.length) return
|
||||
const next = new Set(selected.value)
|
||||
if (allOnPageChecked()) {
|
||||
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: MessageItem, checked: boolean): void {
|
||||
const next = new Set(selected.value)
|
||||
if (checked) next.add(m.id)
|
||||
else next.delete(m.id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function exportSelected(): Promise<void> {
|
||||
if (!selected.value.size || exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
for (const id of selected.value) params.append('ids', String(id))
|
||||
const rows = await api<unknown[]>(`/api/messages/export?${params}`)
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const blob = new Blob([JSON.stringify(rows, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `messages-${stamp}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast('success', `exported ${rows.length} message(s)`)
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(limit.value))
|
||||
@@ -160,6 +270,57 @@ onMounted(async () => {
|
||||
<template>
|
||||
<h1>Messages</h1>
|
||||
|
||||
<div v-if="canEdit()" class="row" style="justify-content: flex-end">
|
||||
<button @click="importOpen = true">
|
||||
<Upload :size="15" />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<strong>Import messages</strong>
|
||||
<button class="ghost" style="padding: 0.1rem 0.4rem" @click="importOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom: 0.5rem">
|
||||
Paste a previously exported JSON array (or a single message object) or
|
||||
load a file. The database assigns fresh ids; exported ids are ignored.
|
||||
</p>
|
||||
<div class="row" style="margin-bottom: 0.5rem">
|
||||
<label class="ghost" style="cursor: pointer; padding: 0.45rem 0.95rem">
|
||||
Choose file…
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style="display: none"
|
||||
@change="onImportFile"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="importText"
|
||||
class="mono"
|
||||
rows="8"
|
||||
placeholder='[{ "imei": "...", "device": "...", "payload": { ... } }]'
|
||||
style="width: 100%; resize: vertical"
|
||||
></textarea>
|
||||
<div class="row" style="justify-content: flex-end; margin-top: 0.5rem">
|
||||
<button class="ghost" @click="importOpen = false">Cancel</button>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="importing || !importText.trim()"
|
||||
@click="doImport"
|
||||
>
|
||||
<Upload :size="15" />
|
||||
{{ importing ? 'Importing…' : 'Import' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="card row" @submit.prevent="applyFilters">
|
||||
<label class="muted">from</label>
|
||||
<input v-model="from" type="datetime-local" style="max-width: 14rem" />
|
||||
@@ -168,6 +329,19 @@ onMounted(async () => {
|
||||
<button class="primary" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div v-if="selected.size" class="card row" style="justify-content: space-between">
|
||||
<span class="muted">{{ selected.size }} message(s) selected</span>
|
||||
<div class="row">
|
||||
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="selected = new Set()">
|
||||
Clear selection
|
||||
</button>
|
||||
<button class="primary" :disabled="exporting" @click="exportSelected">
|
||||
<Download :size="15" />
|
||||
{{ exporting ? 'Exporting…' : 'Export JSON' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<template v-if="loading">
|
||||
<div class="skeleton line" />
|
||||
@@ -182,6 +356,14 @@ onMounted(async () => {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 1%">
|
||||
<input
|
||||
type="checkbox"
|
||||
title="Select/deselect all on this page"
|
||||
:checked="allOnPageChecked()"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<TableHeader
|
||||
label="Received"
|
||||
name="received_at"
|
||||
@@ -227,6 +409,14 @@ onMounted(async () => {
|
||||
</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.received_at) }}</td>
|
||||
<td class="mono">
|
||||
<router-link :to="`/devices/${m.imei}`">{{ m.imei }}</router-link>
|
||||
|
||||
Reference in New Issue
Block a user