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
|
- Payment history lists past invoices with amount and billing period, most
|
||||||
recent first.
|
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
|
## Notifications & navigation
|
||||||
|
|
||||||
### US-103 — See and clear unread indicators
|
### 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 => {
|
await context.route(`${HOSTING_ORIGIN}/**`, route => {
|
||||||
const request = route.request()
|
const request = route.request()
|
||||||
const method = request.method()
|
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 [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}`}})
|
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 ?? []}}})
|
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 : {})}
|
const updated = {...relay, ...(method === "PUT" ? body : {})}
|
||||||
|
|
||||||
if (sub === "deactivate") {
|
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("$5.00")
|
||||||
await expect(invoices.nth(1)).toContainText(await period(paidPeriod))
|
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 Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||||
import BillList from "@assets/icons/bill-list.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 {fly} from "@lib/transition"
|
||||||
import {ucFirst} from "@lib/util"
|
import {ucFirst} from "@lib/util"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.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 PlanModal from "@app/components/hosting/PlanModal.svelte"
|
||||||
import {roomLists} from "@app/core"
|
import {roomLists} from "@app/core"
|
||||||
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
||||||
|
|
@ -139,6 +141,10 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openDataTransfer = () => {
|
||||||
|
pushModal(DataTransferModal, {relay: current})
|
||||||
|
}
|
||||||
|
|
||||||
const openCustomDomain = () => {
|
const openCustomDomain = () => {
|
||||||
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
|
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +274,12 @@
|
||||||
Change plan
|
Change plan
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={openDataTransfer}>
|
||||||
|
<Icon icon={Database} />
|
||||||
|
Import / export data
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{#if current.status === "active"}
|
{#if current.status === "active"}
|
||||||
<Button class="text-error" onclick={requestDeactivate}>
|
<Button class="text-error" onclick={requestDeactivate}>
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,19 @@ export type Activity = {
|
||||||
resource_id: string
|
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 {
|
export class HostingError extends Error {
|
||||||
status: number
|
status: number
|
||||||
|
|
||||||
|
|
@ -201,29 +214,33 @@ const getAuthHeader = (): Promise<string> => {
|
||||||
|
|
||||||
type HostingResponse<T> = {data: T; error?: string}
|
type HostingResponse<T> = {data: T; error?: string}
|
||||||
|
|
||||||
export const hostingFetch = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
|
const hostingRequest = async (method: string, path: string, init: RequestInit = {}) =>
|
||||||
const headers: Record<string, string> = {
|
fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
|
||||||
|
...init,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: await getAuthHeader(),
|
Authorization: await getAuthHeader(),
|
||||||
}
|
...init.headers,
|
||||||
|
},
|
||||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
|
||||||
|
|
||||||
const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hostingJson = async <T>(response: Response): Promise<T> => {
|
||||||
const payload: Maybe<HostingResponse<T>> = await response.json().catch(() => undefined)
|
const payload: Maybe<HostingResponse<T>> = await response.json().catch(() => undefined)
|
||||||
|
|
||||||
if (!response.ok || !payload) {
|
if (response.ok && payload) return payload.data
|
||||||
throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 listPlans = () => hostingFetch<Plan[]>("GET", "/plans")
|
||||||
|
|
||||||
export const createTenant = (returnUrl: string) =>
|
export const createTenant = (returnUrl: string) =>
|
||||||
|
|
@ -273,6 +290,27 @@ export const listRelayMembers = (id: string) =>
|
||||||
export const listRelayActivity = (id: string) =>
|
export const listRelayActivity = (id: string) =>
|
||||||
hostingFetch<{activity: Activity[]}>("GET", `/relays/${id}/activity`)
|
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 getInvoice = (id: string) => hostingFetch<Invoice>("GET", `/invoices/${id}`)
|
||||||
|
|
||||||
export const listInvoiceItems = (invoiceId: string) =>
|
export const listInvoiceItems = (invoiceId: string) =>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue