Add relay data import and export to the hosting panel

This commit is contained in:
Coracle-Bot 2026-09-12 13:28:40 +00:00 committed by hodlbod
parent 0460c94395
commit 8c81550695
6 changed files with 295 additions and 16 deletions

View file

@ -1508,6 +1508,18 @@ Acceptance:
- Payment history lists past invoices with amount and billing period, most
recent first.
### US-122 — Export and import a hosted relay's data
As a space owner, I want to take a copy of everything my relay holds and put
events back, so that I can keep a backup and move between hosts.
Acceptance:
- "Import / export data" in the relay actions menu opens a Relay data dialog,
whose "Download events" downloads the relay's events as `<subdomain>.jsonl`.
- Importing a file reports how many events were stored and names the lines the
relay refused.
## Notifications & navigation
### US-103 — See and clear unread indicators

View file

@ -452,7 +452,9 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
await context.route(`${HOSTING_ORIGIN}/**`, route => {
const request = route.request()
const method = request.method()
const body: HostingRecord = request.postDataJSON() ?? {}
// An import posts JSONL, which postDataJSON() throws on. Every other write posts json.
const isJson = request.headers()["content-type"] === "application/json"
const body: HostingRecord = (isJson && request.postDataJSON()) || {}
const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean)
const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}})
@ -528,6 +530,30 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
}
// The dump is whatever the fixture left on the record, served as the download itself rather
// than in a json envelope.
if (sub === "export") {
return route.fulfill({
contentType: "application/x-ndjson",
body: String(relay.events ?? ""),
})
}
// Storing events is the relay's own business, so the fake counts the lines and refuses the
// ones that don't look signed, which is the shape zooid reports problems in.
if (sub === "import") {
const lines = (request.postData() ?? "").split("\n").filter(line => line.trim())
const problems = lines.flatMap((line, index) =>
line.includes(`"sig"`) ? [] : [{line: index + 1, event_id: "", message: "invalid event"}],
)
return route.fulfill({
json: {
data: {imported: lines.length - problems.length, invalid: problems.length, problems},
},
})
}
const updated = {...relay, ...(method === "PUT" ? body : {})}
if (sub === "deactivate") {

View file

@ -1101,3 +1101,40 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
await expect(invoices.nth(1)).toContainText("$5.00")
await expect(invoices.nth(1)).toContainText(await period(paidPeriod))
})
test("US-122 export and import a hosted relay's data", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const {url} = scenario.space("space")
const dump = '{"id":"a","sig":"a"}\n{"id":"b","sig":"b"}\n'
const page = await as(users.alice, spacePath(url) + "/admin", {
hosting: {plans: PLANS, relays: [hostedRelay({events: dump})]},
})
await page.getByRole("button", {name: "Relay actions"}).click()
await menuItem(page, "Import / export data").click()
const modal = dialog(page, "Relay data")
const download = page.waitForEvent("download")
await modal.getByRole("button", {name: "Download events"}).click()
expect((await download).suggestedFilename()).toBe("space.jsonl")
await modal.locator("input[type=file]").setInputFiles({
name: "events.jsonl",
mimeType: "application/x-ndjson",
buffer: Buffer.from('{"id":"c","sig":"c"}\nnot an event\n'),
})
await modal.getByRole("button", {name: "Import events"}).click()
// The relay keeps what it could verify and says which line it refused
await expect(modal.getByText("Imported 1 event, skipped 1.")).toBeVisible()
await expect(modal.getByText("line 2: invalid event")).toBeVisible()
})

View file

@ -0,0 +1,154 @@
<script lang="ts">
import type {Maybe} from "@welshman/lib"
import {randomId} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Download from "@assets/icons/download.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import {downloadText} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {
exportRelayData,
importRelayData,
HostingError,
type HostedRelay,
type ImportResult,
} from "@app/hosting"
type Props = {
relay: HostedRelay
}
const {relay}: Props = $props()
const inputId = randomId()
const relayLabel = relay.info_name || relay.subdomain
const back = () => history.back()
const onFileChange = (event: Event) => {
file = (event.target as HTMLInputElement).files?.[0]
result = undefined
}
const download = async () => {
exporting = true
try {
await downloadText(`${relay.subdomain}.jsonl`, await exportRelayData(relay.id))
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to export relay data.",
})
} finally {
exporting = false
}
}
const upload = async () => {
if (file) {
importing = true
try {
result = await importRelayData(relay.id, file)
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to import relay data.",
})
} finally {
importing = false
}
}
}
let exporting = $state(false)
let importing = $state(false)
let file: Maybe<File> = $state()
let result: Maybe<ImportResult> = $state()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Relay data</ModalTitle>
<ModalSubtitle>
Everything {relayLabel} holds, as one event per line. Uploaded media is not included.
</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col items-start gap-2">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Export</h3>
<p class="text-sm text-content-muted">
Download a copy of every event on the relay, for a backup or a move to another host.
</p>
<Button class="button button-primary" onclick={download} disabled={exporting}>
{#if !exporting}
<Icon icon={Download} size={4} />
{/if}
<Spinner loading={exporting}>Download events</Spinner>
</Button>
</div>
<Divider />
<div class="flex flex-col items-start gap-2">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Import</h3>
<p class="text-sm text-content-muted">
Add the events in a file to the relay. Each one carries its own signature, so a tampered or
unsigned line is skipped and reported, and importing the same file twice changes nothing.
</p>
<input
id={inputId}
type="file"
accept=".jsonl,.ndjson,.json,application/x-ndjson,application/json"
onchange={onFileChange}
class="hidden" />
<div class="flex flex-wrap items-center gap-2">
<label for={inputId} class="button button-neutral cursor-pointer">
<Icon icon={Upload} size={4} />
{file ? "Choose another file" : "Choose a file"}
</label>
{#if file}
<span class="min-w-0 break-all text-sm text-content-muted">{file.name}</span>
{/if}
</div>
<Button class="button button-primary" onclick={upload} disabled={importing || !file}>
<Spinner loading={importing}>Import events</Spinner>
</Button>
</div>
{#if result}
<div class="flex flex-col gap-2 rounded-2xl border border-line bg-surface-less p-4">
<p class="text-sm">
Imported {result.imported}
{result.imported === 1 ? "event" : "events"}{result.invalid > 0
? `, skipped ${result.invalid}`
: ""}.
</p>
{#each result.problems?.slice(0, 5) || [] as problem (problem.line)}
<p class="break-all font-mono text-xs text-content-muted">
{problem.line ? `line ${problem.line}: ` : ""}{problem.message}
</p>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>

View file

@ -12,6 +12,7 @@
import Copy from "@assets/icons/copy.svg?dataurl"
import Refresh from "@assets/icons/refresh.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Database from "@assets/icons/database.svg?dataurl"
import {fly} from "@lib/transition"
import {ucFirst} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
@ -26,6 +27,7 @@
import ToggleInput from "@lib/components/ToggleInput.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
import PlanModal from "@app/components/hosting/PlanModal.svelte"
import {roomLists} from "@app/core"
import RelayForm from "@app/components/hosting/RelayForm.svelte"
@ -139,6 +141,10 @@
})
}
const openDataTransfer = () => {
pushModal(DataTransferModal, {relay: current})
}
const openCustomDomain = () => {
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
}
@ -268,6 +274,12 @@
Change plan
</Button>
</li>
<li>
<Button onclick={openDataTransfer}>
<Icon icon={Database} />
Import / export data
</Button>
</li>
<li>
{#if current.status === "active"}
<Button class="text-error" onclick={requestDeactivate}>

View file

@ -160,6 +160,19 @@ export type Activity = {
resource_id: string
}
// One line of an imported JSONL file the relay could not store.
export type ImportProblem = {
line: number
event_id: string
message: string
}
export type ImportResult = {
imported: number
invalid: number
problems?: ImportProblem[]
}
export class HostingError extends Error {
status: number
@ -201,29 +214,33 @@ const getAuthHeader = (): Promise<string> => {
type HostingResponse<T> = {data: T; error?: string}
export const hostingFetch = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: await getAuthHeader(),
}
if (body !== undefined) headers["Content-Type"] = "application/json"
const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
const hostingRequest = async (method: string, path: string, init: RequestInit = {}) =>
fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
...init,
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
headers: {
Accept: "application/json",
Authorization: await getAuthHeader(),
...init.headers,
},
})
const hostingJson = async <T>(response: Response): Promise<T> => {
const payload: Maybe<HostingResponse<T>> = await response.json().catch(() => undefined)
if (!response.ok || !payload) {
throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status)
}
if (response.ok && payload) return payload.data
return payload.data
throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status)
}
export const hostingFetch = async <T>(method: string, path: string, body?: unknown): Promise<T> =>
hostingJson<T>(
await hostingRequest(method, path, {
headers: body === undefined ? undefined : {"Content-Type": "application/json"},
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
export const listPlans = () => hostingFetch<Plan[]>("GET", "/plans")
export const createTenant = (returnUrl: string) =>
@ -273,6 +290,27 @@ export const listRelayMembers = (id: string) =>
export const listRelayActivity = (id: string) =>
hostingFetch<{activity: Activity[]}>("GET", `/relays/${id}/activity`)
// Every event the relay holds, as JSONL. Fetched rather than linked, since the
// nip 98 auth header can't ride on an <a href>.
export const exportRelayData = async (id: string) => {
const response = await hostingRequest("GET", `/relays/${id}/export`)
if (response.ok) return response.text()
// Only the failure path is a json envelope; success is the dump itself.
return hostingJson<string>(response)
}
// Events keep their own signatures, so the relay validates them itself and
// reports the lines it refused.
export const importRelayData = async (id: string, file: Blob) =>
hostingJson<ImportResult>(
await hostingRequest("POST", `/relays/${id}/import`, {
headers: {"Content-Type": "application/x-ndjson"},
body: file,
}),
)
export const getInvoice = (id: string) => hostingFetch<Invoice>("GET", `/invoices/${id}`)
export const listInvoiceItems = (invoiceId: string) =>