Add relay data import and export to the hosting panel
This commit is contained in:
parent
0460c94395
commit
8c81550695
6 changed files with 295 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
154
src/app/components/hosting/DataTransferModal.svelte
Normal file
154
src/app/components/hosting/DataTransferModal.svelte
Normal 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>
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue