"Still linked on the shard" said "tried 1 time, and tried again when the bridge reconnects" about a retry that has not happened yet. It now reads "tried 1 time. It tries again when the bridge reconnects." Found in the account deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
359 lines
17 KiB
JavaScript
359 lines
17 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||
import { describe, kindLabel } from '../../lib/shardEvents.js'
|
||
import { ago } from '../../lib/format.js'
|
||
import api from '../../api.js'
|
||
import { ErrorState, Loading } from '../../core.js'
|
||
|
||
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||
// cheat detection and login attempts that the public channel never carries.
|
||
function AdminLiveFeed() {
|
||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||
{connected ? 'Live' : 'Offline'}
|
||
</span>
|
||
</div>
|
||
{events.length === 0 ? (
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||
) : (
|
||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||
{events.map((e) => (
|
||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||
// stored encrypted, never returned — same convention as the Discord bot token.
|
||
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||
// effect immediately with no redeploy.
|
||
|
||
function Toggle({ checked, onChange, label }) {
|
||
return (
|
||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||
{label}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
const STATUS_COLOR = {
|
||
connected: '#7fd0a4',
|
||
reconnecting: '#e0b070',
|
||
error: '#d98b84',
|
||
disconnected: 'var(--muted)',
|
||
}
|
||
|
||
function StatusPanel({ config }) {
|
||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||
const ingest = config.ingest || {}
|
||
const health = config.health || {}
|
||
return (
|
||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||
{config.status || 'disconnected'}
|
||
</span>
|
||
</div>
|
||
{config.statusDetail && (
|
||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||
)}
|
||
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Unlinks a deleted website account left behind ───────────────────────────
|
||
//
|
||
// ACCOUNT_DELETION.md §4.4. Deleting a website account unlinks its game accounts
|
||
// on the shard first; what the shard could not take is listed here until it
|
||
// clears. `pending` is retried by itself when the bridge reconnects. `refused` is
|
||
// a protected staff account the shard will not unlink over the bridge, so the
|
||
// shard's staff clear its WebsiteUserId tag by hand. A row goes on its own once a
|
||
// login on that account no longer carries the deleted user's id.
|
||
function PendingUnlinks({ rows }) {
|
||
if (!rows || rows.length === 0) return null
|
||
return (
|
||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Still linked on the shard</h3>
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||
Game accounts whose website account was deleted, but whose link the shard has not cleared yet.
|
||
</p>
|
||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{rows.map((r) => (
|
||
<li key={r.account} className="sans" style={{ fontSize: '0.85rem', color: 'var(--ink)' }}>
|
||
<strong>{r.account}</strong>
|
||
<span className="dim"> · deleted user #{r.webUserId} · {new Date(r.queuedAt).toLocaleString()}</span>
|
||
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||
{r.state === 'refused'
|
||
? 'Protected staff account: the shard will not unlink it over the bridge. Clear its link on the shard by hand.'
|
||
: `Waiting for the shard; tried ${r.attempts} time${r.attempts === 1 ? '' : 's'}. It tries again when the bridge reconnects.`}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ── Game-account signup ─────────────────────────────────────────────────────
|
||
//
|
||
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
||
// moved here rather than being deleted or left behind, because its help text has
|
||
// always described an agreement between this site and a ServUO shard — and half
|
||
// of that agreement is configured in Bridge.cfg, which core has never heard of.
|
||
//
|
||
// The setting key and value are unchanged (`game_account_signup`), so an
|
||
// instance that had this configured finds it here, set to what it was.
|
||
const SIGNUP_MODES = [
|
||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||
]
|
||
|
||
function GameSignup() {
|
||
const [mode, setMode] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
api.admin.getSignupMode()
|
||
.then((r) => active && setMode(r.mode))
|
||
.catch(() => active && setError('Could not load the signup mode.'))
|
||
return () => { active = false }
|
||
}, [])
|
||
|
||
async function save(next) {
|
||
const previous = mode
|
||
setMode(next); setBusy(true); setMsg(''); setError('')
|
||
try {
|
||
await api.admin.saveSignupMode(next)
|
||
setMsg('Saved.')
|
||
} catch (err) {
|
||
setMode(previous) // the select must not show a mode the server did not take
|
||
setError(err.message || 'Could not save.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Game-account creation</h3>
|
||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||
Whether players can create a GAME account (for the game client) from the site. The game server’s own
|
||
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
|
||
When enabled, a “Create a game account” form appears in the player portal and after an invite is accepted.
|
||
</p>
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Mode</span>
|
||
<select
|
||
value={mode ?? ''}
|
||
onChange={(e) => save(e.target.value)}
|
||
disabled={busy || mode === null}
|
||
className="input"
|
||
style={{ maxWidth: 420 }}
|
||
>
|
||
{mode === null && <option value="">Loading…</option>}
|
||
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||
</select>
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ── Town crier ──────────────────────────────────────────────────────────────
|
||
function TownCrier() {
|
||
const [id, setId] = useState('')
|
||
const [text, setText] = useState('')
|
||
const [durationSec, setDurationSec] = useState(3600)
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
async function post() {
|
||
setBusy(true); setMsg(''); setError('')
|
||
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||
if (!id.trim() || lines.length === 0) {
|
||
setBusy(false)
|
||
return setError('An id and at least one line are required.')
|
||
}
|
||
try {
|
||
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||
setMsg(`Posted “${id.trim()}”.`)
|
||
} catch (err) {
|
||
setError(err.message || 'Could not post.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
async function remove() {
|
||
if (!id.trim()) return setError('Enter the id to remove.')
|
||
setBusy(true); setMsg(''); setError('')
|
||
try {
|
||
await api.admin.deleteTownCrier(id.trim())
|
||
setMsg(`Removed “${id.trim()}”.`)
|
||
} catch (err) {
|
||
setError(err.message || 'Could not remove.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||
</p>
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Message id</span>
|
||
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||
</label>
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Lines (one per line)</span>
|
||
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||
</label>
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Duration (seconds)</span>
|
||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default function ShardAdmin() {
|
||
const [config, setConfig] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const [baseUrl, setBaseUrl] = useState('')
|
||
const [wsUrl, setWsUrl] = useState('')
|
||
const [token, setToken] = useState('')
|
||
const [protocol, setProtocol] = useState(3)
|
||
const [enabled, setEnabled] = useState(false)
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
const [saveError, setSaveError] = useState('')
|
||
const pollRef = useRef(null)
|
||
const initializedRef = useRef(false)
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const c = await api.admin.getUoLinkConfig()
|
||
setConfig(c)
|
||
// Seed the editable fields once; later polls only refresh the status panel
|
||
// so they never clobber what the admin is mid-typing.
|
||
if (!initializedRef.current) {
|
||
setBaseUrl(c.baseUrl || '')
|
||
setWsUrl(c.wsUrl || '')
|
||
setProtocol(c.protocol || 3)
|
||
setEnabled(c.enabled)
|
||
initializedRef.current = true
|
||
}
|
||
} catch {
|
||
setError('Could not load uo-link config.')
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
load()
|
||
pollRef.current = setInterval(load, 5000)
|
||
return () => clearInterval(pollRef.current)
|
||
}, [load])
|
||
|
||
async function save() {
|
||
setBusy(true); setMsg(''); setSaveError('')
|
||
try {
|
||
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||
if (token) body.token = token
|
||
const saved = await api.admin.saveUoLinkConfig(body)
|
||
setConfig(saved)
|
||
setToken('')
|
||
setMsg('Saved.')
|
||
} catch (err) {
|
||
setSaveError(err.message || 'Could not save.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
if (error) return <ErrorState message={error} />
|
||
if (!config) return <Loading />
|
||
|
||
return (
|
||
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||
|
||
<StatusPanel config={config} />
|
||
|
||
<PendingUnlinks rows={config.pendingUnlinks} />
|
||
|
||
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Base URL (REST)</span>
|
||
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||
</label>
|
||
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">WebSocket URL (feed)</span>
|
||
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||
</label>
|
||
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Auth token</span>
|
||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||
</label>
|
||
|
||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||
<span className="field-label">Protocol</span>
|
||
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||
</label>
|
||
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||
</div>
|
||
|
||
<GameSignup />
|
||
|
||
<TownCrier />
|
||
|
||
<AdminLiveFeed />
|
||
</section>
|
||
)
|
||
}
|