Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.
The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.
Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.
The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:
* The body import diffed its manifest against every family's rows. Phase 5 put
item and land art in the same table, and a body manifest never mentions
them, so all 313 item pictures were staged for deletion with a sentence
saying the shard had stopped offering them.
* An approved vanish unlinked the sprite and kept the row. The catalogue went
on counting a picture that was gone, the atlas could point a creature page at
a missing file, and the next forced import offered the same key for review
again -- reporting "nothing was changed" about a file it had deleted.
Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.
Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.
Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
291 lines
13 KiB
JavaScript
291 lines
13 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import api from '../../api.js'
|
||
import { ErrorState, Loading } from '../../core.js'
|
||
import Row from '../../components/DetailRow.jsx'
|
||
|
||
// ── 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 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.source === 'bridge'
|
||
? 'Saved, but not in use: this site reads the atlas from the linked shard. The path takes'
|
||
+ ' over only if uo-link is disabled.'
|
||
: 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.
|
||
Where those files come from depends on whether a shard is linked: with uo-link configured
|
||
the shard serves them over the bridge and importing is something you do here, when a map
|
||
changes. Without one, the site reads a local tree and re-imports itself on every server
|
||
start. Either way the atlas is shard <em>content</em> rather than shard state, so what is
|
||
loaded keeps serving in full 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="Source">
|
||
{status?.source === 'bridge'
|
||
? 'The shard, over uo-link'
|
||
: status?.configured
|
||
? status.path
|
||
: 'None — no shard linked and no path set'}
|
||
</Row>
|
||
<Row label="Source readable">
|
||
{!status?.configured
|
||
? 'No source'
|
||
: status.treeReadable
|
||
? 'Yes'
|
||
: status.source === 'bridge'
|
||
? 'No — the shard did not answer, or Bridge.TreeEnabled is off'
|
||
: 'No'}
|
||
</Row>
|
||
<Row label="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 }}>
|
||
A local ServUO tree the website can read directly — 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.
|
||
{status?.source === 'bridge'
|
||
? ' It is not in use right now: this site has a shard linked, and the shard serves its' +
|
||
' own files over the bridge. Unlink or disable uo-link to fall back to a path.'
|
||
: ' 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 — and on a linked shard it is the only thing that
|
||
does, because boot deliberately never calls the shard for this. An unchanged source costs
|
||
almost nothing: the file list and its hashes are read first (about 32 KB over the bridge)
|
||
and no file is transferred 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>
|
||
)
|
||
}
|