The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
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 (
|
||
<div
|
||
className="sans"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'baseline',
|
||
justifyContent: 'space-between',
|
||
gap: 16,
|
||
padding: '7px 0',
|
||
borderBottom: '1px solid var(--line)',
|
||
fontSize: '0.86rem',
|
||
}}
|
||
>
|
||
<span className="dim">{label}</span>
|
||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||
const declined = pending.status === 'rejected'
|
||
return (
|
||
<section
|
||
style={{
|
||
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||
borderRadius: 10,
|
||
padding: 16,
|
||
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||
}}
|
||
>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||
</h3>
|
||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||
{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 <strong>remove {pending.removedFacets?.length || 0} facet
|
||
</strong>
|
||
{(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.
|
||
</>
|
||
)}
|
||
</p>
|
||
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||
Approve and import
|
||
</button>
|
||
{!declined && (
|
||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||
Keep the current atlas
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
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 <Loading />
|
||
if (error && !status) return <ErrorState message={error} />
|
||
|
||
const counts = status?.counts || null
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<header>
|
||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||
Spawn atlas
|
||
</h2>
|
||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||
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.
|
||
</p>
|
||
</header>
|
||
|
||
{status?.pending && (
|
||
<PendingReview
|
||
pending={status.pending}
|
||
busy={busy}
|
||
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||
/>
|
||
)}
|
||
|
||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||
What is loaded
|
||
</h3>
|
||
<Row label="Imported">
|
||
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||
</Row>
|
||
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||
{counts && (
|
||
<>
|
||
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||
<Row label="Regions / landmarks">
|
||
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||
</Row>
|
||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||
</>
|
||
)}
|
||
<Row label="Tree readable">
|
||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||
</Row>
|
||
<Row label="Tree changed since import">
|
||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||
</Row>
|
||
</section>
|
||
|
||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||
ServUO tree
|
||
</h3>
|
||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||
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 <code>SERVUO_PATH</code> deploy default, so the
|
||
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||
</p>
|
||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input
|
||
className="input"
|
||
value={path}
|
||
onChange={(e) => setPath(e.target.value)}
|
||
placeholder="/srv/servuo"
|
||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||
/>
|
||
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||
Save path
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||
Re-import
|
||
</h3>
|
||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||
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.
|
||
</p>
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sq"
|
||
disabled={busy || !status?.configured}
|
||
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||
>
|
||
{busy ? 'Working…' : 'Import now'}
|
||
</button>
|
||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||
Re-import even if the tree is unchanged
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
{(msg || error) && (
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|