feat(assets): the panel that operates the client-file imports (Phase 8)
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
This commit is contained in:
@@ -164,6 +164,38 @@ export const admin = {
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// The Asset Bridge (docs/link/v8.md §6, §14 — protocol 8 phase 8). Client
|
||||
// artwork and the cliloc table both come off the operator's own UO client, over
|
||||
// the same bridge, and boot deliberately never asks the shard for either — so
|
||||
// these calls are the only thing that imports them, and the panel that makes
|
||||
// them is where an operator goes after patching their client.
|
||||
//
|
||||
// `update` and `reimport` are §6's two stages rather than one call with a flag,
|
||||
// because they cost wildly different things: an Update that finds the client
|
||||
// files unchanged transfers nothing, and a re-import fetches every sprite in
|
||||
// the catalogue. A checkbox spells that difference the same size as the button.
|
||||
assets: {
|
||||
status: () => req('/admin/shard/assets'),
|
||||
update: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { approve } }),
|
||||
reimport: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { force: true, approve } }),
|
||||
// Item and land pictures, which arrive one at a time because a page asked for
|
||||
// one. The pass runs on its own timer; this is for the operator who has just
|
||||
// patched a client and would rather not wait for the interval.
|
||||
warm: (force = false) => req('/admin/shard/assets/warm', { method: 'POST', body: { force } }),
|
||||
},
|
||||
|
||||
clilocs: {
|
||||
status: () => req('/admin/shard/clilocs'),
|
||||
import: (opts = {}) =>
|
||||
req('/admin/shard/clilocs/import', {
|
||||
method: 'POST',
|
||||
body: { force: !!opts.force, approve: !!opts.approve },
|
||||
}),
|
||||
setPath: (path) => req('/admin/shard/clilocs/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// In-game staff operations: write plane + support queue (admin/moderator).
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
|
||||
31
client/src/components/DetailRow.jsx
Normal file
31
client/src/components/DetailRow.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// ── A label/value line in an admin detail panel ────────────────────────────
|
||||
//
|
||||
// Extracted from `SpawnAtlas.jsx` in phase 8, when the Client Files panel needed
|
||||
// the same thing for the third time. Two copies of twenty lines is a coincidence;
|
||||
// three is a component, and the reason to make it one here rather than later is
|
||||
// that these lines are read side by side — an operator moves between Spawn Atlas
|
||||
// and Client Files doing one job, and a panel whose rows are a few pixels off
|
||||
// from its neighbour's looks like a different part of the product.
|
||||
//
|
||||
// Deliberately not styled through a class: this module ships as a prebuilt chunk
|
||||
// into core's SPA and owns no stylesheet there (MODULE_API.md §3.2), so its own
|
||||
// layout is inline and only core's theme VARIABLES are borrowed.
|
||||
export default function DetailRow({ 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>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/ShardOps.jsx'
|
||||
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
||||
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
||||
import ClientFiles from './routes/admin/ClientFiles.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
@@ -93,12 +94,14 @@ registry.registerRoutes(ID, {
|
||||
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
||||
],
|
||||
admin: [
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, and
|
||||
// the atlas import. No `gate` on the other three because AdminLayout already
|
||||
// requires staff and these carry their own role rows below.
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, the
|
||||
// atlas import and the client-file imports. No `gate` on these four because
|
||||
// AdminLayout already requires staff and they carry their own role rows
|
||||
// below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'files', element: <ClientFiles /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
@@ -150,6 +153,7 @@ registry.registerNav(ID, {
|
||||
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Client Files', to: '/admin/uo/files', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
// No group: a trailing untitled group of its own, below core's Account row
|
||||
// rather than beside it (§3.3). One position lower than it sits today, and
|
||||
// the alternative — letting a module into core's furniture groups — is worse.
|
||||
|
||||
628
client/src/routes/admin/ClientFiles.jsx
Normal file
628
client/src/routes/admin/ClientFiles.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
import Row from '../../components/DetailRow.jsx'
|
||||
import { CreaturePortrait } from '../public/Atlas.jsx'
|
||||
|
||||
// ── Admin · Client files ────────────────────────────────────────────────────
|
||||
//
|
||||
// Everything on this site that comes out of the operator's own UO client, and
|
||||
// the buttons that bring it in (docs/link/v8.md §6, §14 — the Asset Bridge,
|
||||
// phase 8).
|
||||
//
|
||||
// Three things, one page, because they are one job. Creature portraits, item and
|
||||
// land pictures, and the cliloc table all live in files inside a UO client
|
||||
// install; the shard decodes them and hands them over the bridge; and every one
|
||||
// of them changes at the same moment, when the operator patches that client. An
|
||||
// operator who has just done that has exactly one place to come.
|
||||
//
|
||||
// **Boot never asks the shard for any of it** (org lead, phase 2 and again in
|
||||
// phase 7). A client patch is an event the operator knows about and the website
|
||||
// does not, and a site that re-read 343 MB of client files on every restart to
|
||||
// discover nothing had changed would be paying for the rare case forever. The
|
||||
// consequence is the reason this panel exists at all: these buttons are the ONLY
|
||||
// thing that imports. Nothing here happens on its own except the item-art warm
|
||||
// pass, which is lazy by design and only fetches what a page has already asked
|
||||
// for.
|
||||
//
|
||||
// **Nothing on this page throws for an operator-visible problem.** A shard that
|
||||
// is down, an asset plane switched off, a Linux host with no libgdiplus, a client
|
||||
// with no cliloc file — each is a reported state with a reason naming what to
|
||||
// fix. A red box that says "500" would be the one thing an operator cannot act
|
||||
// on, and every one of these states is ordinary.
|
||||
|
||||
// ── outcomes ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An import reports its result rather than throwing, so these are answers, not
|
||||
// errors. They are written in the operator's terms — what happened to their
|
||||
// site — rather than in the protocol's.
|
||||
|
||||
const ASSET_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.written?.toLocaleString() ?? 0} picture(s) written, ` +
|
||||
`${r.assets?.toLocaleString() ?? 0} in the catalogue, ` +
|
||||
`${r.bodies?.resolved?.toLocaleString() ?? 0} creature(s) matched to a body.`,
|
||||
unchanged: () =>
|
||||
'Unchanged — the shard’s client files match what was imported, so nothing was transferred.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.vanishedCount?.toLocaleString() ?? 0} picture(s) this site holds are no` +
|
||||
' longer offered by the shard.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there are no client files to read.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
// The warm pass speaks the same vocabulary as the body import deliberately
|
||||
// (`skipped` / `unavailable` / `unchanged` / `imported` / `failed`), but its
|
||||
// numbers mean something different: it is bounded, so "imported" routinely
|
||||
// leaves work behind and saying so is the difference between a button that looks
|
||||
// broken and one that is doing what it promised.
|
||||
const WARM_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Fetched ${r.written?.toLocaleString() ?? 0} picture(s)` +
|
||||
(r.remaining ? `; ${r.remaining.toLocaleString()} still waiting — press again.` : '.'),
|
||||
unchanged: () => 'Nothing waiting — every picture a page has asked for is already here.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there is nothing to fetch.',
|
||||
failed: (r) => `That did not work: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const CLILOC_OUTCOME = {
|
||||
imported: (r) => `Imported — ${r.count?.toLocaleString() ?? 0} names loaded.`,
|
||||
unchanged: () => 'Unchanged — the source matches the table that is already loaded.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.missingSources?.length ?? 0} overlay file(s) that were loaded last time` +
|
||||
' are missing.',
|
||||
unavailable: (r) => `The source could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: (r) => r.reason || 'There is no cliloc source configured.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const describe = (table, result) =>
|
||||
(table[result?.status] || (() => `Result: ${result?.status}`))(result || {})
|
||||
|
||||
const num = (n) => (n == null ? '—' : Number(n).toLocaleString())
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : 'Never')
|
||||
|
||||
// ── the vanished-key review (§6) ───────────────────────────────────────────
|
||||
//
|
||||
// A key the site holds that the shard no longer offers is refused rather than
|
||||
// applied, because an unmounted client volume and a deliberate client downgrade
|
||||
// are the same thing from the server and the wrong guess deletes artwork.
|
||||
//
|
||||
// It is held in this component's state and not in a table, deliberately (org
|
||||
// lead, 2026-09-14). The atlas persists its equivalent because BOOT re-parses the
|
||||
// tree and would otherwise re-prompt on every restart forever; an asset import
|
||||
// only ever happens because somebody pressed a button on this page, so the
|
||||
// review is in front of the person who caused it, by construction. Declining is
|
||||
// therefore not a decision to remember — it is simply not pressing the other
|
||||
// button.
|
||||
//
|
||||
// The pictures matter. `body/820/a23` names nothing a human recognises; the horse
|
||||
// it is a picture of does, and "is it right that these disappear?" is not a
|
||||
// question anyone can answer from a list of keys.
|
||||
function VanishedReview({ review, busy, onApprove, onDismiss }) {
|
||||
const rows = review.result.vanished || []
|
||||
const total = review.result.vanishedCount ?? rows.length
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
An import is waiting for you
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The shard no longer offers <strong>{num(total)}</strong> picture{total === 1 ? '' : 's'} this
|
||||
site is currently serving, so nothing was changed. That is what a client volume that failed
|
||||
to mount looks like as well as a deliberate client downgrade, and only you can tell them
|
||||
apart. Approving re-reads the shard as it is right now — if the mount was the problem and you
|
||||
have since fixed it, what lands is the corrected import, not a deletion.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} style={{ width: 96, textAlign: 'center' }}>
|
||||
<CreaturePortrait art={row.file} name={row.key} size={48} />
|
||||
<div
|
||||
className="sans dim"
|
||||
style={{ fontSize: '0.7rem', wordBreak: 'break-all', marginTop: 2 }}
|
||||
title={row.key}
|
||||
>
|
||||
{row.key}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{total > rows.length && (
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem' }}>
|
||||
Showing the first {num(rows.length)} of {num(total)}.
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onDismiss}>
|
||||
Keep the pictures I have
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// What the last import did. Core's activity log records the same action, but it
|
||||
// is one unfiltered list of every admin action on the site — so the answer to
|
||||
// "did last week's import actually do anything" is here, beside the button that
|
||||
// caused it, rather than twenty pages into a log.
|
||||
function LastImport({ last, at }) {
|
||||
if (!last) {
|
||||
return <Row label="Last import">{at ? when(at) : 'No import recorded yet'}</Row>
|
||||
}
|
||||
|
||||
const tally = last.bodies || {}
|
||||
const unmatched = [
|
||||
tally.unknown ? `${num(tally.unknown)} unknown to the shard` : '',
|
||||
tally.notCreature ? `${num(tally.notCreature)} not a creature` : '',
|
||||
tally.failed ? `${num(tally.failed)} failed` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row label="Last import">
|
||||
{`${when(last.at || at)}${last.by ? ` · ${last.by}` : ''}${last.force ? ' · full re-import' : ''}`}
|
||||
</Row>
|
||||
<Row label="Pictures written">
|
||||
{`${num(last.written)} written, ${num(last.fetched)} fetched`}
|
||||
{last.removed ? `, ${num(last.removed)} removed` : ''}
|
||||
</Row>
|
||||
{unmatched.length > 0 && (
|
||||
// Only the creatures that did NOT match, because how many did is the row
|
||||
// above this block and a number that means "now" should not also appear
|
||||
// as a number that means "at that import". What is left is the part an
|
||||
// operator can act on: `unknown` is a spawn file naming a type this
|
||||
// shard's scripts do not define, which is real drift.
|
||||
<Row label="Could not be matched">{unmatched.join(', ')}</Row>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClientFiles() {
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [clilocs, setClilocs] = useState(null)
|
||||
const [clilocPath, setClilocPath] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// One message per section: three panels that can each speak means an operator
|
||||
// must never have to work out which button a sentence belongs to.
|
||||
const [msg, setMsg] = useState({})
|
||||
// The in-session reviews, keyed by which plane raised them.
|
||||
const [review, setReview] = useState({})
|
||||
|
||||
// `quiet` re-reads without flipping `loading`, and that distinction is the
|
||||
// whole difference between a usable panel and a maddening one: `loading`
|
||||
// replaces the page with a spinner, so refreshing that way after an action
|
||||
// unmounts everything, throws the operator back to the top of a long page, and
|
||||
// takes the sentence saying what just happened with it — at the bottom of the
|
||||
// cliloc section, that means pressing Update appears to do nothing at all.
|
||||
const load = useCallback(async ({ quiet = false } = {}) => {
|
||||
if (!quiet) setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Both statuses call the shard, and neither one failing should cost the
|
||||
// other its panel: an operator whose cliloc file is missing still needs to
|
||||
// see what the asset import says.
|
||||
const [a, c] = await Promise.all([
|
||||
api.admin.assets.status().catch((err) => ({ error: err.message })),
|
||||
api.admin.clilocs.status().catch((err) => ({ error: err.message })),
|
||||
])
|
||||
setAssets(a)
|
||||
setClilocs(c)
|
||||
setClilocPath(c?.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load the client-file status.')
|
||||
} finally {
|
||||
if (!quiet) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Every action shares this: run it, say what it said, then re-read status so
|
||||
// the panel reflects the world rather than what we assumed happened.
|
||||
async function run(section, table, fn) {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, [section]: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg((m) => ({ ...m, [section]: describe(table, result) }))
|
||||
// Set or cleared from the SAME answer, in one place. Clearing separately
|
||||
// left the review standing after an approve that had already applied — a
|
||||
// banner asking for a decision that was made ten seconds ago, on pictures
|
||||
// that are already gone.
|
||||
setReview((r) => ({
|
||||
...r,
|
||||
[section]: result?.status === 'needsReview' ? { result, run: fn } : null,
|
||||
}))
|
||||
await load({ quiet: true })
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClilocPath() {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, clilocs: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.clilocs.setPath(clilocPath.trim())
|
||||
setClilocs(fresh)
|
||||
setClilocPath(fresh.path || '')
|
||||
setMsg((m) => ({
|
||||
...m,
|
||||
clilocs:
|
||||
fresh.source === 'bridge'
|
||||
? 'Saved. The base table still comes from the shard — this selects where custom/ overlay' +
|
||||
' files are read from.'
|
||||
: fresh.path === ''
|
||||
? 'Path cleared. The loaded table keeps serving; nothing new will be read.'
|
||||
: fresh.fileReadable
|
||||
? 'Saved. The file is readable — import when you are ready.'
|
||||
: 'Saved, but the file 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 && !assets && !clilocs) return <ErrorState message={error} />
|
||||
|
||||
const loaded = assets?.loaded || null
|
||||
const shard = assets?.shard || null
|
||||
const families = shard?.families || []
|
||||
// Reported by the server rather than inferred from `shard` being null — which
|
||||
// is also what a linked shard that is simply DOWN looks like, and those two
|
||||
// want opposite things from this page: one needs its buttons disabled, the
|
||||
// other needs them available so the operator can retry.
|
||||
const linked = Boolean(assets?.linked)
|
||||
const imagingBroken = shard?.imaging && shard.imaging.ok === false
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Client files
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Creature portraits, item pictures and the names your shard’s items and titles are stored
|
||||
under all come out of the UO client on the shard host. The shard reads and decodes them
|
||||
itself and hands them over uo-link — nothing is converted on a desktop and nothing is
|
||||
uploaded. They change when you patch that client, which is something only you know about,
|
||||
so <strong>these buttons are the only thing that imports them</strong>: nothing here
|
||||
happens on a restart.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{(assets?.error || clilocs?.error) && (
|
||||
<section
|
||||
style={{ border: '1px solid #d98b84', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>Part of this page could not be read.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets?.error || clilocs?.error} — the counts below may be missing. Both status calls
|
||||
are written never to fail for an ordinary problem (a shard that is down is an ANSWER
|
||||
here), so this one is worth the server log.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{assets?.reason && !shard && (
|
||||
<section
|
||||
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>The shard is not answering for client files.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets.reason}
|
||||
{assets.code === 'DISABLED' &&
|
||||
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
|
||||
</p>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
What is already imported keeps serving; only new imports are affected.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{imagingBroken && (
|
||||
<section
|
||||
style={{ border: '1px solid #c58f4a', borderRadius: 10, padding: 16, background: 'rgba(197,143,74,0.08)' }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>The shard host cannot render images.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{shard.imaging.reason ||
|
||||
'A Linux shard host needs libgdiplus before it can decode a single sprite.'}{' '}
|
||||
Names (the cliloc table) are unaffected and can still be imported — they have no pixels
|
||||
in them.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{review.assets && (
|
||||
<VanishedReview
|
||||
review={review.assets}
|
||||
busy={busy}
|
||||
onApprove={() => run('assets', ASSET_OUTCOME, () => review.assets.run(true))}
|
||||
onDismiss={() => setReview((r) => ({ ...r, assets: null }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── creature portraits ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Creature portraits
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
One picture per creature body, imported as a set and shown on the bestiary. Creatures the
|
||||
client has no artwork for are normal and stay as text — a stock client has none for most
|
||||
ghost and gargoyle bodies. Portraits you drew yourself and named in{' '}
|
||||
<code>spawnAtlas.art.json</code> always win over an imported one.
|
||||
</p>
|
||||
<Row label="Pictures held">{`${num(loaded?.stored)} of ${num(loaded?.assets)} catalogued`}</Row>
|
||||
<Row label="Creatures matched">{`${num(loaded?.resolved)} of ${num(loaded?.creatures)}`}</Row>
|
||||
<LastImport last={loaded?.last} at={loaded?.importedAt} />
|
||||
<Row label="Client files changed since">
|
||||
{assets?.drift == null
|
||||
? '—'
|
||||
: assets.drift
|
||||
? 'Yes — an update would pick it up'
|
||||
: 'No'}
|
||||
</Row>
|
||||
{shard?.hashing && (
|
||||
<Row label="Shard is hashing">
|
||||
Yes — it is still fingerprinting its client files in the background. Drift may read as
|
||||
“yes” until it finishes.
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Extractor version">
|
||||
{/* "—" for a version nobody has imported yet reads as a missing value;
|
||||
it is an answer, and the shard's own version is the useful half of
|
||||
the sentence on exactly that install. */}
|
||||
{(loaded?.extractorVersion == null ? 'None' : num(loaded.extractorVersion)) +
|
||||
' imported' +
|
||||
(shard?.extractorVersion == null ? '' : ` · ${num(shard.extractorVersion)} on the shard`)}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.update(approve))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.reimport(approve))}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
<strong>Update</strong> checks the shard’s client files first and transfers only the
|
||||
pictures that actually changed — when nothing has, it costs one small round trip.{' '}
|
||||
<strong>Re-import everything</strong> fetches the whole catalogue again; use it after
|
||||
restoring a backup or losing the uploads volume, where the database still remembers
|
||||
pictures that are no longer on disk.
|
||||
</p>
|
||||
{msg.assets && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.assets}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── item and land pictures ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and land pictures
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The pictures beside marketplace listings and on character sheets. These are never imported
|
||||
as a set — there are tens of thousands of item graphics, times every dye colour — so they
|
||||
arrive one at a time, shortly after a page asks for one, and refresh themselves after a
|
||||
client patch. This is here for the two moments waiting is the wrong answer: you have just
|
||||
linked a shard, or you have just patched a client and would rather not wait.
|
||||
</p>
|
||||
<Row label="Item pictures held">{num(loaded?.items)}</Row>
|
||||
<Row label="Land pictures held">{num(loaded?.land)}</Row>
|
||||
<Row label="Shard serves">
|
||||
{families.length > 0 ? families.join(', ') : '—'}
|
||||
{shard && !families.includes('static')
|
||||
? ' — this shard’s plugin predates item pictures; update the overlay to get them'
|
||||
: ''}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(false))}
|
||||
>
|
||||
Fetch waiting pictures
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(true))}
|
||||
>
|
||||
Refresh the ones I have
|
||||
</button>
|
||||
</div>
|
||||
{msg.warm && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.warm}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── the cliloc table ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and title names (clilocs)
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
UO stores most item, title and reward names as numbers, and the words live in the client’s
|
||||
cliloc file. Without this table the marketplace and character sheets show numbers. With a
|
||||
shard linked the shard decompresses and serves it; otherwise the site reads a file you
|
||||
point it at below.
|
||||
</p>
|
||||
<Row label="Names loaded">{num(clilocs?.count)}</Row>
|
||||
<Row label="Imported">{when(clilocs?.importedAt)}</Row>
|
||||
<Row label="Source">
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'The shard, over uo-link'
|
||||
: clilocs?.configured
|
||||
? clilocs.path
|
||||
: 'None configured'}
|
||||
</Row>
|
||||
<Row label="Overlays">
|
||||
{clilocs?.sources?.length ? clilocs.sources.join(', ') : 'None'}
|
||||
</Row>
|
||||
<Row label="Changed since import">
|
||||
{clilocs?.drift == null ? '—' : clilocs.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
{clilocs?.problem && (
|
||||
<Row label="Problem">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.problem}</span>
|
||||
</Row>
|
||||
)}
|
||||
{clilocs?.missingSources?.length > 0 && (
|
||||
<Row label="Missing since last import">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.missingSources.join(', ')}</span>
|
||||
</Row>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ force: true, approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
{review.clilocs && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<strong className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
An overlay file that was loaded last time is missing
|
||||
</strong>
|
||||
<p className="sans" style={{ margin: '6px 0 10px', fontSize: '0.85rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{(review.clilocs.result.missingSources || []).join(', ') || 'One or more overlays'} —
|
||||
the table was left exactly as it is. If you deleted those files on purpose, import
|
||||
anyway; if this is a mount that did not come back, fix it first and the next import
|
||||
picks the names up again.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, () => review.clilocs.run(true))
|
||||
}
|
||||
>
|
||||
Import without them
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => setReview((r) => ({ ...r, clilocs: null }))}
|
||||
>
|
||||
Keep the names I have
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'Where custom/ overlay files are read from. The base table comes from the shard' +
|
||||
' either way; leave this blank if you have no overlays.'
|
||||
: 'The directory holding the cliloc file. Blank turns cliloc resolution off — the' +
|
||||
' table that is already loaded keeps serving.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={clilocPath}
|
||||
onChange={(e) => setClilocPath(e.target.value)}
|
||||
placeholder="/srv/uo-client"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={saveClilocPath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg.clilocs && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.clilocs}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 ─────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -36,26 +37,6 @@ const OUTCOME = {
|
||||
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user