🚧 Add import

This commit is contained in:
Jonas Bengtsson
2026-09-06 21:32:05 +02:00
parent 67a06e1023
commit f2033ab44f
4 changed files with 671 additions and 10 deletions
+193 -3
View File
@@ -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>