import { useCallback, useEffect, useState } from 'react' import api from '../../api.js' import { ErrorState, Loading } from '../../core.js' // ── Admin · Spawn atlas ───────────────────────────────────────────────────── // // The atlas re-derives itself from the shard's ServUO tree on every boot, so // this panel exists for the three things a restart cannot do: // // • point it at a different tree, // • apply a map change without restarting, and // • answer a refresh that was parsed but deliberately NOT applied because it // would remove a facet. // // That last one is the reason the panel is worth building. Losing a facet looks // exactly like a half-copied or mid-update tree, and boot cannot tell them // apart — so it stages the decision for a human instead of guessing. Until // someone decides here, the site keeps serving the atlas it already had. // A refresh reports its outcome rather than throwing (the boot path must never // be stopped by a bad tree), so these are answers, not errors — the panel says // what happened in the shard's terms instead of showing a failure box. const OUTCOME = { imported: (r) => `Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`, unchanged: (r) => r.reason === 'refresh previously rejected' ? 'Unchanged — this exact tree was already reviewed and declined.' : 'Unchanged — the tree matches what is already loaded.', needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.', unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`, skipped: () => 'No ServUO path is configured, so there is nothing to import.', failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`, rejected: () => 'Declined. It will not be offered again until the tree changes.', } const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result) function Row({ label, children }) { return (
{label} {children}
) } function PendingReview({ pending, busy, onApprove, onReject }) { const declined = pending.status === 'rejected' return (

{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}

{declined ? ( <> This tree was reviewed and declined, so it is not offered again until the files change. Approving now applies it anyway. ) : ( <> The tree parses cleanly but would remove {pending.removedFacets?.length || 0} facet {(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That is what a half-copied or mid-update tree looks like as well as a real map change, so it was not applied. Approving re-parses the tree as it is right now — if you have since fixed the mount, what lands is the corrected import. )}

{(pending.removedFacets || []).join(', ') || '—'} {(pending.addedFacets || []).join(', ') || '—'} {pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}
{!declined && ( )}
) } export default function SpawnAtlas() { const [status, setStatus] = useState(null) const [path, setPath] = useState('') const [force, setForce] = useState(false) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [msg, setMsg] = useState('') const load = useCallback(async () => { setLoading(true) setError('') try { const data = await api.admin.atlas.status() setStatus(data) setPath(data.path || '') } catch (err) { setError(err.message || 'Could not load atlas status.') } finally { setLoading(false) } }, []) useEffect(() => { load() }, [load]) // Every mutating action shares this: run it, report what it said, then reload // status so the panel reflects the world rather than what we assumed happened. async function run(action, fn) { setBusy(true) setMsg('') setError('') try { const result = await fn() setMsg(describe(result)) const fresh = await api.admin.atlas.status() setStatus(fresh) setPath(fresh.path || '') } catch (err) { setError(err.message || `Could not ${action}.`) } finally { setBusy(false) } } async function savePath() { setBusy(true) setMsg('') setError('') try { const fresh = await api.admin.atlas.setPath(path.trim()) setStatus(fresh) setPath(fresh.path || '') setMsg( fresh.path === '' ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.' : fresh.treeReadable ? 'Saved. The tree is readable — import when you are ready.' : 'Saved, but the tree could not be read from here. Check the mount and permissions.', ) } catch (err) { setError(err.message || 'Could not save the path.') } finally { setBusy(false) } } if (loading) return if (error && !status) return const counts = status?.counts || null return (

Spawn atlas

The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files. It refreshes itself on every server start; everything here is for the times you don’t want to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not shard state, and stays complete while the shard is down.

{status?.pending && ( run('approve the refresh', () => api.admin.atlas.approve())} onReject={() => run('decline the refresh', () => api.admin.atlas.reject())} /> )}

What is loaded

{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'} {status?.facets?.length ? status.facets.join(', ') : '—'} {counts && ( <> {counts.points?.toLocaleString() ?? '—'} {counts.creatures?.toLocaleString() ?? '—'} {`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`} {counts.champions?.toLocaleString() ?? '—'} )} {!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'} {status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}

ServUO tree

Where the website reads the shard’s spawn files from — the same host, a bind mount or a shared volume. This setting wins over the SERVUO_PATH deploy default, so the mount can move without a redeploy. Leave it blank to turn the atlas off.

setPath(e.target.value)} placeholder="/srv/servuo" style={{ flex: '1 1 320px', minWidth: 0 }} />

Re-import

Applies a map change without restarting. An unchanged tree costs nothing — the source files are hashed first and skipped when they match. A refresh that would remove a facet still comes back here for approval rather than being applied.

{(msg || error) && (
{msg && {msg}} {error && {error}}
)}
) }