Merge pull request 'feat(assets): the panel that operates the client-file imports (Phase 8)' (#41) from feat/asset-bridge-p8 into edge
Reviewed-on: #41
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 (
|
||||
|
||||
@@ -102,6 +102,42 @@ test('admin atlas actions use the right methods and bodies', async () => {
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
|
||||
})
|
||||
|
||||
// ── the Asset Bridge's two stages (docs/link/v8.md §6) ──────────────────────
|
||||
// Update and Re-import are one route and differ only by `force`, and the
|
||||
// difference is not cosmetic: one transfers nothing when the client files are
|
||||
// unchanged, the other fetches the whole catalogue. A binding that sent `force`
|
||||
// on both would make the cheap button the expensive one, and nothing visible
|
||||
// would change — the pictures would be correct either way.
|
||||
test('assets.update asks for the diff and assets.reimport asks for everything', async () => {
|
||||
await admin.assets.update()
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/assets/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.deepEqual(calls[0].opts.body, { approve: false })
|
||||
|
||||
await admin.assets.reimport()
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: false })
|
||||
})
|
||||
|
||||
// Approving a vanished key re-runs the SAME operation the operator pressed, so
|
||||
// `approve` has to ride on both. Sending the update's approval as a re-import
|
||||
// would quietly turn "yes, accept those deletions" into a full re-download.
|
||||
test('approve rides on whichever import the operator ran', async () => {
|
||||
await admin.assets.update(true)
|
||||
await admin.assets.reimport(true)
|
||||
assert.deepEqual(calls[0].opts.body, { approve: true })
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: true })
|
||||
})
|
||||
|
||||
test('cliloc admin actions use the right methods and bodies', async () => {
|
||||
await admin.clilocs.import({ force: true })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/clilocs/import')
|
||||
assert.deepEqual(calls[0].opts.body, { force: true, approve: false })
|
||||
|
||||
await admin.clilocs.setPath('/srv/uo-client')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/uo-client' })
|
||||
})
|
||||
|
||||
// ── path encoding ───────────────────────────────────────────────────────────
|
||||
// A city name with an apostrophe and a space is the real case: "Serpent's Hold"
|
||||
// is a governor city, and an unencoded one would break the route match rather
|
||||
|
||||
@@ -120,7 +120,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
|
||||
it('registers routes in all three areas, namespaced under the module id', () => {
|
||||
const { routes } = registered
|
||||
assert.equal(routes.public.length, 13)
|
||||
assert.equal(routes.admin.length, 7)
|
||||
assert.equal(routes.admin.length, 8)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
for (const r of routes[area]) {
|
||||
|
||||
@@ -30,11 +30,26 @@ async function batched(conn, sql, rows) {
|
||||
|
||||
// ── the manifest side ──────────────────────────────────────────────────────
|
||||
|
||||
/** Every asset row we hold, as a Map of key → row. */
|
||||
async function allAssets() {
|
||||
/**
|
||||
* The asset rows we hold in one family, as a Map of key → row.
|
||||
*
|
||||
* **The family is required, and the reason is a deletion.** The import diffs what
|
||||
* this returns against a manifest, and a manifest is always of ONE family (§14 —
|
||||
* the reply carries a single catalogue id, so it could not be otherwise). Phase 5
|
||||
* put item and land art in this table beside the body catalogue; read whole, the
|
||||
* body import then sees every item picture as a key the shard has stopped
|
||||
* offering and stages all of them for deletion. On a real install that is a few
|
||||
* hundred pictures the operator is asked to approve the loss of, with a sentence
|
||||
* that is entirely wrong about what happened.
|
||||
*
|
||||
* `null` reads every family, which nothing in the import path should ever want.
|
||||
*/
|
||||
async function allAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog ' +
|
||||
'FROM shard_assets',
|
||||
'FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
@@ -68,8 +83,16 @@ async function allAssets() {
|
||||
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
|
||||
* key must keep the file it already points at — re-writing the file for every
|
||||
* asset on every Update is exactly the cost the manifest diff exists to avoid.
|
||||
*
|
||||
* `remove` is the keys an operator has APPROVED the loss of (§6). They are
|
||||
* deleted here, inside the same transaction, because a half-applied removal is
|
||||
* the worst of the three outcomes: until phase 8 the import unlinked the sprite
|
||||
* and left the row, so the catalogue still counted a picture that was gone, the
|
||||
* atlas could point a creature at a deleted file, and the very next forced
|
||||
* import staged the same key for review again — telling the operator nothing had
|
||||
* changed, about a file it had already deleted.
|
||||
*/
|
||||
async function saveAssets(rows, meta) {
|
||||
async function saveAssets(rows, meta, remove = []) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
@@ -101,6 +124,16 @@ async function saveAssets(rows, meta) {
|
||||
values,
|
||||
)
|
||||
|
||||
if (remove.length > 0) {
|
||||
for (let i = 0; i < remove.length; i += BATCH) {
|
||||
const slice = remove.slice(i, i + BATCH)
|
||||
await conn.query(
|
||||
`DELETE FROM shard_assets WHERE asset_key IN (${slice.map(() => '?').join(',')})`,
|
||||
slice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
await conn.query(
|
||||
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
|
||||
@@ -188,6 +221,31 @@ async function countByFamily() {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the import that just finished actually did (§6, phase 8).
|
||||
*
|
||||
* **A second write, deliberately.** The interesting half of that summary — how
|
||||
* many atlas creatures resolved to a body id, how many portraits were applied —
|
||||
* does not exist when `saveAssets` commits: producing it takes another round trip
|
||||
* to the shard, and widening the rows-and-meta transaction to cover a network
|
||||
* call is how an import ends up holding a write lock for the length of a timeout.
|
||||
*
|
||||
* `JSON_SET` rather than a read-modify-write for the same reason the rest of this
|
||||
* file is one statement per operation: the payload is the gate an Update compares
|
||||
* against, and re-serialising it from the outside is how a concurrent import
|
||||
* loses a field nobody notices for a month.
|
||||
*
|
||||
* It is cosmetic by design — nothing reads `last` to make a decision, the panel
|
||||
* only renders it — so a failure here is logged and swallowed by the caller
|
||||
* rather than failing an import that has already applied.
|
||||
*/
|
||||
async function recordLastImport(last) {
|
||||
await query('UPDATE shard_asset_meta SET payload = JSON_SET(payload, ?, JSON_COMPACT(?)) WHERE id = 1', [
|
||||
'$.last',
|
||||
JSON.stringify(last),
|
||||
])
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_asset_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
@@ -195,9 +253,23 @@ async function getMeta() {
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
async function countAssets() {
|
||||
/**
|
||||
* How many assets we hold, optionally in one family.
|
||||
*
|
||||
* **The family argument is not optional in spirit.** Phase 5 put item and land
|
||||
* art in this table beside the body catalogue, and they are counted differently
|
||||
* by nature: the catalogue is a SET with a known size, while item art is however
|
||||
* much of an unbounded space the site has happened to ask for. A whole-table
|
||||
* count answers neither question — it reported the creature catalogue as 1,408
|
||||
* rows on an install holding 1,095 portraits and 313 item pictures, which is a
|
||||
* confident wrong number in the one place an operator checks whether the import
|
||||
* worked.
|
||||
*/
|
||||
async function countAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets',
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
|
||||
}
|
||||
@@ -295,6 +367,7 @@ async function artBySlug() {
|
||||
module.exports = {
|
||||
allAssets,
|
||||
saveAssets,
|
||||
recordLastImport,
|
||||
getMeta,
|
||||
countAssets,
|
||||
replaceBodies,
|
||||
|
||||
@@ -176,8 +176,12 @@ function removeSprite(name) {
|
||||
* an operator recovers from a deleted uploads directory — the database still
|
||||
* holds the hashes, but the files behind them are gone). `approve` accepts a
|
||||
* catalogue that no longer offers keys we hold.
|
||||
*
|
||||
* `by` is who pressed the button, carried through only so the panel can say what
|
||||
* the last import did and who ran it without reading the audit log (phase 8). It
|
||||
* decides nothing.
|
||||
*/
|
||||
async function importAssets({ force = false, approve = false } = {}) {
|
||||
async function importAssets({ force = false, approve = false, by = null } = {}) {
|
||||
if (!(await shardLinked())) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
@@ -207,7 +211,7 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
if (!force && bridge.sameSources(sources, meta?.sources)) {
|
||||
const counts = await db.countAssets()
|
||||
const counts = await db.countAssets(bridge.FAMILY)
|
||||
const bodies = await db.countBodies()
|
||||
|
||||
return {
|
||||
@@ -228,7 +232,10 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
return failure(err, 'asset manifest')
|
||||
}
|
||||
|
||||
const held = await db.allAssets()
|
||||
// The body family only. This diff decides what gets DELETED, and the manifest
|
||||
// it is diffed against is of one family by construction — so reading the whole
|
||||
// table here stages every item picture phase 5 warmed as a vanished key.
|
||||
const held = await db.allAssets(bridge.FAMILY)
|
||||
const offered = new Set(manifest.rows.map((r) => r.key))
|
||||
|
||||
// A key we hold that the shard no longer offers. An unmounted client volume and
|
||||
@@ -243,7 +250,11 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
reason:
|
||||
`${vanished.length} asset(s) this site holds are no longer offered by the shard; ` +
|
||||
'nothing was changed',
|
||||
vanished: vanished.slice(0, 50),
|
||||
// Each one carries the picture it currently has, because the decision the
|
||||
// operator is being asked for is "is it right that these disappear?" and a
|
||||
// list of keys cannot be looked at. `body/820/a23` names nothing a human
|
||||
// recognises; the horse it is a picture of does.
|
||||
vanished: vanished.slice(0, 50).map((key) => ({ key, file: held.get(key)?.file ?? null })),
|
||||
vanishedCount: vanished.length,
|
||||
}
|
||||
}
|
||||
@@ -318,14 +329,21 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
await db.saveAssets(rows, {
|
||||
await db.saveAssets(
|
||||
rows,
|
||||
{
|
||||
catalog: manifest.catalog,
|
||||
extractorVersion: manifest.extractorVersion,
|
||||
family: bridge.FAMILY,
|
||||
playerBodies: manifest.playerBodies,
|
||||
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
|
||||
count: rows.length,
|
||||
})
|
||||
},
|
||||
// The approved removals go in with the write. The sprite is already
|
||||
// unlinked above; leaving the row behind would keep counting a picture
|
||||
// that is gone and re-offer the same key for review on every import.
|
||||
removed,
|
||||
)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
@@ -333,6 +351,36 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
const bodies = await resolveAtlasBodies()
|
||||
const art = await applyArt()
|
||||
|
||||
// What this run did, kept beside the catalogue it produced (phase 8). The admin
|
||||
// panel renders it as "the last import", which is the question an operator has
|
||||
// straight after pressing a button that takes a minute and prints nothing:
|
||||
// what changed, and did the body pass find drift. Core's activity log records
|
||||
// the same action, but it is one unfiltered list of every admin action on the
|
||||
// site, so an import from three client patches ago is not findable there.
|
||||
//
|
||||
// Best-effort on purpose: the import has already applied, and losing a cosmetic
|
||||
// summary must not turn a successful import into a failure.
|
||||
const last = {
|
||||
at: new Date().toISOString(),
|
||||
by,
|
||||
force,
|
||||
approve,
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
removed: removed.length,
|
||||
absent: fetched.missing.absent,
|
||||
unsupported: fetched.missing.unsupported,
|
||||
bodies: bodies.tally ?? null,
|
||||
art: art.applied ?? 0,
|
||||
}
|
||||
|
||||
try {
|
||||
await db.recordLastImport(last)
|
||||
} catch (err) {
|
||||
log.warn('could not record the import summary', { error: err.message })
|
||||
}
|
||||
|
||||
log.info('asset import applied', {
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
@@ -460,12 +508,21 @@ async function applyArt() {
|
||||
* state with a reason an operator can act on.
|
||||
*/
|
||||
async function getStatus() {
|
||||
const counts = await db.countAssets().catch(() => ({ total: 0, stored: 0 }))
|
||||
// The BODY family, not the whole table: item and land art live here too and
|
||||
// are reported separately below, because they are a working set rather than a
|
||||
// catalogue with a size (§11).
|
||||
const counts = await db.countAssets(bridge.FAMILY).catch(() => ({ total: 0, stored: 0 }))
|
||||
const bodies = await db.countBodies().catch(() => ({ total: 0, resolved: 0 }))
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const families = await db.countByFamily().catch(() => ({}))
|
||||
|
||||
const status = {
|
||||
// Is there a shard to ask at all? Stated rather than left to be inferred:
|
||||
// the panel disables its import buttons on it, and the alternative — reading
|
||||
// it out of `reason`'s wording, or out of `shard` being null, which is also
|
||||
// what a shard that is merely DOWN looks like — is a sentence that decides
|
||||
// behaviour.
|
||||
linked: await shardLinked(),
|
||||
loaded: {
|
||||
assets: counts.total,
|
||||
stored: counts.stored,
|
||||
@@ -480,12 +537,18 @@ async function getStatus() {
|
||||
// for and holds, which is the only number that means anything here.
|
||||
items: families.static?.stored ?? 0,
|
||||
land: families.land?.stored ?? 0,
|
||||
// What the last import did, and who ran it (phase 8). Null on an install
|
||||
// that has never imported, and on one whose last import predates this
|
||||
// field — both of which render as "no import recorded" rather than as
|
||||
// zeroes, because an import that fetched nothing is a real and different
|
||||
// answer from one that never happened.
|
||||
last: meta?.last ?? null,
|
||||
},
|
||||
shard: null,
|
||||
drift: null,
|
||||
}
|
||||
|
||||
if (!(await shardLinked())) {
|
||||
if (!status.linked) {
|
||||
status.reason = 'uo-link is not configured'
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
// operator patches their client, which is an event they know about and the site
|
||||
// does not. So this endpoint is what an operator presses afterwards.
|
||||
//
|
||||
// The full panel — per-key review, the activity view, approve/reject as buttons —
|
||||
// is phase 8. This pair is what makes phase 3 reachable at all.
|
||||
// Phase 8 built the panel these serve (`Admin → Client Files`) and added one
|
||||
// thing to this pair: the import records a summary of what it did, and the
|
||||
// vanished keys it refuses to apply come back with the pictures they currently
|
||||
// have. Both exist because an operator pressing Update needs to see an answer,
|
||||
// and the audit log — which still receives every action here — is one unfiltered
|
||||
// list of every admin action on the site, so an import from three client patches
|
||||
// ago cannot be found in it (org lead, 2026-09-14).
|
||||
|
||||
const assets = require('../../model/shardAssets/shardAssets.model')
|
||||
const itemArt = require('../../model/shardAssets/shardItemArt.model')
|
||||
@@ -56,7 +61,9 @@ async function importAssets(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
const result = await assets.importAssets({ force, approve })
|
||||
// From the session, never the body — the same rule the in-game ops routes
|
||||
// apply, and for the same reason: this is recorded as who did it.
|
||||
const result = await assets.importAssets({ force, approve, by: req.user?.username ?? null })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
|
||||
@@ -626,6 +626,11 @@ module.exports = {
|
||||
description:
|
||||
'Admin view of the client-asset import (docs/link/v8.md §6, §8). What the site holds beside what the shard’s UO client currently is. Holding nothing at all is a supported state — creature pages simply render without pictures, which is what every install did before this pipeline existed.',
|
||||
properties: {
|
||||
linked: {
|
||||
type: 'boolean',
|
||||
description: 'Whether a shard is configured and enabled at all. Stated rather than inferred: `shard: null` is also what a linked shard that is merely DOWN looks like, and the two want opposite things from an admin surface — one disables its import buttons, the other keeps them available so the operator can retry.',
|
||||
example: true,
|
||||
},
|
||||
loaded: {
|
||||
type: 'object',
|
||||
description: 'What this site currently holds.',
|
||||
@@ -639,6 +644,35 @@ module.exports = {
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
items: { type: 'integer', description: 'Item pictures held. Unlike the catalogue this has no total to compare against: item art is fetched because something on the site names it, so this is the working set rather than a fraction of one.', example: 1840 },
|
||||
land: { type: 'integer', description: 'Land tile pictures held. Zero on every install until something asks for one.', example: 0 },
|
||||
last: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'What the last import actually did. NULL on an install that has never imported, and on one whose last import predates this field — both of which mean "no import recorded", which is a different answer from an import that fetched nothing. The admin activity log records the same action, but it is one unfiltered list of every admin action on the site, so an import from three client patches ago is not findable there.',
|
||||
properties: {
|
||||
at: { type: 'string', format: 'date-time' },
|
||||
by: { type: 'string', nullable: true, description: 'The admin who pressed it, from their session.' },
|
||||
force: { type: 'boolean', description: 'True when it was a full re-import rather than an update.' },
|
||||
approve: { type: 'boolean', description: 'True when it accepted assets the shard had stopped offering.' },
|
||||
assets: { type: 'integer', example: 1095 },
|
||||
fetched: { type: 'integer', example: 12 },
|
||||
written: { type: 'integer', example: 12 },
|
||||
removed: { type: 'integer', example: 0 },
|
||||
absent: { type: 'integer', example: 0 },
|
||||
unsupported: { type: 'integer', example: 0 },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The body pass, as a tally rather than one number: `unknown` is real drift — a spawn file naming a type this shard’s scripts do not define — and reads identically to a failure if both are summed into "not resolved".',
|
||||
properties: {
|
||||
ok: { type: 'integer', example: 780 },
|
||||
unknown: { type: 'integer', example: 20 },
|
||||
notCreature: { type: 'integer', example: 12 },
|
||||
failed: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
art: { type: 'integer', description: 'Creatures pointing at a picture afterwards.', example: 763 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shard: {
|
||||
@@ -723,7 +757,18 @@ module.exports = {
|
||||
description: 'The body ids the shard reports as player-character bodies — every registered race’s male, female and ghost bodies, asked of the shard rather than hardcoded. These render head-on; everything else renders three-quarter.',
|
||||
example: [400, 401, 402, 403, 605, 606, 607, 608, 666, 667, 694, 695],
|
||||
},
|
||||
vanished: { type: 'array', nullable: true, items: { type: 'string' }, description: 'On `needsReview`: up to fifty of the keys that disappeared.' },
|
||||
vanished: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'On `needsReview`: up to fifty of the keys that disappeared, each with the picture this site currently serves for it. The filename is there because the decision being asked for is "is it right that these disappear?", and an asset key names nothing a human recognises — `body/820/a23` is a horse.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', example: 'body/820/a23' },
|
||||
file: { type: 'string', nullable: true, description: 'Filename under uploads/atlas/, or null if this site never stored a picture for it.', example: 'uo-body-820-a23-9f3c1a77.png' },
|
||||
},
|
||||
},
|
||||
},
|
||||
vanishedCount: { type: 'integer', nullable: true },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
|
||||
@@ -40,6 +40,7 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
saved.resolveBodies = bridge.resolveBodies
|
||||
saved.allAssets = db.allAssets
|
||||
saved.saveAssets = db.saveAssets
|
||||
saved.recordLastImport = db.recordLastImport
|
||||
saved.getMeta = db.getMeta
|
||||
saved.countAssets = db.countAssets
|
||||
saved.countBodies = db.countBodies
|
||||
@@ -50,7 +51,7 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
saved.loadArtMap = atlasModel.loadArtMap
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
|
||||
const seen = { saved: null, fetchedKeys: null, art: null }
|
||||
const seen = { saved: null, fetchedKeys: null, art: null, last: null }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: true, baseUrl: 'http://127.0.0.1:8080' })
|
||||
|
||||
@@ -78,6 +79,9 @@ function stubEverything({ manifest, fetched, held = new Map(), meta = null, sour
|
||||
seen.saved = rows
|
||||
return rows.length
|
||||
}
|
||||
db.recordLastImport = async (last) => {
|
||||
seen.last = last
|
||||
}
|
||||
db.replaceBodies = async () => 0
|
||||
db.artBySlug = async () => ({})
|
||||
|
||||
@@ -363,3 +367,193 @@ test('a sprite filename carries its hash so a changed picture is a changed URL',
|
||||
assert.notEqual(before, after)
|
||||
assert.match(before, /^uo-body-34-a0-[0-9a-f]{8}\.png$/)
|
||||
})
|
||||
|
||||
// ── what the panel reads (phase 8) ────────────────────────────────────────
|
||||
//
|
||||
// The admin surface is the only thing that imports — boot never calls the shard
|
||||
// — so everything an operator can learn about an import, they learn from what
|
||||
// these two return. Each of these is a way the panel would render a confident
|
||||
// sentence that is not true.
|
||||
|
||||
test('the vanished keys come back with the pictures they currently have', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([
|
||||
['body/820/a23', { key: 'body/820/a23', sha256: 'a', file: 'uo-body-820-a23-aabbccdd.png' }],
|
||||
])
|
||||
|
||||
stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// The decision being asked for is "is it right that these disappear?", and a
|
||||
// key names nothing a human recognises. Without the filename the panel has
|
||||
// nothing to show but `body/820/a23`, which is a horse.
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.vanished, [
|
||||
{ key: 'body/820/a23', file: 'uo-body-820-a23-aabbccdd.png' },
|
||||
])
|
||||
})
|
||||
|
||||
test('an import records what it did, including the body tally and who ran it', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 3, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
atlasDb.allCreatureTypes = async () => [{ slug: 'wolf', name: 'Wolf' }]
|
||||
bridge.resolveBodies = async () => [
|
||||
{ slug: 'wolf', typeName: 'Wolf', body: 34, status: 'ok' },
|
||||
{ slug: 'ghost-of-something', typeName: 'GhostOfSomething', body: null, status: 'unknown' },
|
||||
]
|
||||
|
||||
await model.importAssets({ force: true, by: 'colby' })
|
||||
|
||||
assert.equal(seen.last.by, 'colby')
|
||||
assert.equal(seen.last.force, true)
|
||||
assert.equal(seen.last.written, 1)
|
||||
assert.equal(seen.last.absent, 3)
|
||||
// The body pass is kept as a TALLY rather than a single "resolved" number:
|
||||
// `unknown` means the spawn files name a type this shard's scripts do not
|
||||
// define, which is real drift, and it reads identically to a failure if both
|
||||
// are summed into "not resolved".
|
||||
assert.deepEqual(seen.last.bodies, { ok: 1, unknown: 1, notCreature: 0, failed: 0 })
|
||||
})
|
||||
|
||||
test('a summary that cannot be written does not fail an import that applied', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
db.recordLastImport = async () => {
|
||||
throw new Error('the meta row is locked')
|
||||
}
|
||||
|
||||
// The pictures are already on disk and the rows are already committed. Failing
|
||||
// here would report a failure for an import that succeeded, and the operator's
|
||||
// next move — press it again — would re-fetch the whole catalogue for nothing.
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.written, 1)
|
||||
})
|
||||
|
||||
test('status says whether a shard is linked rather than leaving it to be inferred', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
db.getMeta = async () => ({ catalog: 'cat1', last: { by: 'colby', written: 4 } })
|
||||
|
||||
const linked = await model.getStatus()
|
||||
|
||||
assert.equal(linked.linked, true)
|
||||
assert.deepEqual(linked.loaded.last, { by: 'colby', written: 4 })
|
||||
|
||||
// A shard that is linked but DOWN also reports `shard: null`, which is why the
|
||||
// panel cannot read this off that: one wants its buttons disabled and the
|
||||
// other wants them available so the operator can retry.
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: false, baseUrl: '' })
|
||||
|
||||
const unlinked = await model.getStatus()
|
||||
|
||||
assert.equal(unlinked.linked, false)
|
||||
assert.equal(unlinked.reason, 'uo-link is not configured')
|
||||
})
|
||||
|
||||
test('the catalogue count is the body family, not every asset in the table', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
let askedFor = 'never called'
|
||||
|
||||
// Item and land art live in the same table as the body catalogue (phase 5) and
|
||||
// are counted separately on purpose: one is a set with a size, the other is
|
||||
// however much of an unbounded space the site has happened to ask for. A
|
||||
// whole-table count reported 1,095 portraits plus 313 item pictures as a
|
||||
// "1,408-row catalogue" on the one screen that answers "did the import work".
|
||||
db.countAssets = async (family) => {
|
||||
askedFor = family
|
||||
return { total: 1095, stored: 1095 }
|
||||
}
|
||||
|
||||
const status = await model.getStatus()
|
||||
|
||||
assert.equal(askedFor, 'body')
|
||||
assert.equal(status.loaded.assets, 1095)
|
||||
})
|
||||
|
||||
test('item pictures are not "vanished" just because the body manifest never listed them', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
// The state every install reaches within a day of its first import: a body
|
||||
// catalogue, plus whatever item art the warm pass has fetched because a
|
||||
// marketplace page asked for it. Both live in `shard_assets`.
|
||||
const held = new Map([
|
||||
['body/12/a0', { key: 'body/12/a0', family: 'body', sha256: 'a', file: 'wolf.png' }],
|
||||
['static/3934/h1801', { key: 'static/3934/h1801', family: 'static', sha256: 'b', file: 'robe.png' }],
|
||||
])
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
// The family filter is the fix, so the stub has to honour it or the test
|
||||
// passes against a whole-table read.
|
||||
db.allAssets = async (family) =>
|
||||
new Map([...held].filter(([, r]) => !family || r.family === family))
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// Before the filter this was `needsReview` naming the item picture, and
|
||||
// approving it would have deleted every picture the warm pass had fetched —
|
||||
// with a sentence saying the shard had stopped offering them, which it had
|
||||
// not: a body manifest never mentions item art at all.
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.removed, 0)
|
||||
assert.ok(seen.saved)
|
||||
})
|
||||
|
||||
test('an approved vanish deletes the row, not just the picture', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'gone.png'), 'x')
|
||||
|
||||
const held = new Map([
|
||||
['body/99/a0', { key: 'body/99/a0', family: 'body', sha256: 'a', file: 'gone.png' }],
|
||||
])
|
||||
|
||||
let removedKeys = null
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
db.saveAssets = async (rows, meta, remove) => {
|
||||
seen.saved = rows
|
||||
removedKeys = remove
|
||||
return rows.length
|
||||
}
|
||||
|
||||
const result = await model.importAssets({ force: true, approve: true })
|
||||
|
||||
assert.equal(result.removed, 1)
|
||||
// The file was already unlinked before this fix; the ROW was not. A row whose
|
||||
// picture is gone keeps being counted, keeps being offered for review on every
|
||||
// forced import, and can still point a creature page at a file that is not
|
||||
// there — with the import reporting "nothing was changed" about a deletion it
|
||||
// had already performed.
|
||||
assert.deepEqual(removedKeys, ['body/99/a0'])
|
||||
assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false)
|
||||
})
|
||||
|
||||
@@ -8482,6 +8482,23 @@
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a shard is configured and enabled at all. Stated rather than inferred: `shard: null` is also what a linked shard that is merely DOWN looks like, and the two want opposite things from an admin surface — one disables its import buttons, the other keeps them available so the operator can retry."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"loaded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8656,6 +8673,253 @@
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"last": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What the last import actually did. NULL on an install that has never imported, and on one whose last import predates this field — both of which mean \"no import recorded\", which is a different answer from an import that fetched nothing. The admin activity log records the same action, but it is one unfiltered list of every admin action on the site, so an import from three client patches ago is not findable there."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"by": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The admin who pressed it, from their session."
|
||||
}
|
||||
}
|
||||
},
|
||||
"force": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True when it was a full re-import rather than an update."
|
||||
}
|
||||
}
|
||||
},
|
||||
"approve": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True when it accepted assets the shard had stopped offering."
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1095
|
||||
}
|
||||
}
|
||||
},
|
||||
"fetched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"written": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"absent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"unsupported": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodies": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The body pass, as a tally rather than one number: `unknown` is real drift — a spawn file naming a type this shard’s scripts do not define — and reads identically to a failure if both are summed into \"not resolved\"."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 780
|
||||
}
|
||||
}
|
||||
},
|
||||
"unknown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"notCreature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"art": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Creatures pointing at a picture afterwards."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 763
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9271,18 +9535,57 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On `needsReview`: up to fifty of the keys that disappeared, each with the picture this site currently serves for it. The filename is there because the decision being asked for is \"is it right that these disappear?\", and an asset key names nothing a human recognises — `body/820/a23` is a horse."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "body/820/a23"
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "On `needsReview`: up to fifty of the keys that disappeared."
|
||||
"example": "Filename under uploads/atlas/, or null if this site never stored a picture for it."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "uo-body-820-a23-9f3c1a77.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user