Files
Module-Rust/client/src/routes/admin/ZonePresets.jsx
wtclaude 8361b19c24 feat(rust): zone presets, and the zone step's options, messages and dome (PLAN_REDESIGNS §3, D210)
Admin → Rust zone presets: named sets of ZoneManager flags and settings for
one server, several, or every server (D195), ticked in groups read from each
server's own ZoneManager list in its last hello (D211). A flag not on a
covered server is refused on save with the server named; two presets of one
name may not share a server.

rust.zone.open gains options (one line, NoBuild, radiation=10), enterMessage,
leaveMessage, delivery, dome and domeStack. The options field's dropdown is
rust.options.zone_presets, whose row VALUE is the preset's line, so picking
one copies it into the step (D210) and no published event changes when a
preset does. The line and the dome are checked against the server's hello
on save and in a dry run; bad-option and dome-unavailable are permanent.

Schema: rust_zone_presets, rust_zone_preset_servers. Swagger fragment and
routes.manifest regenerated against the pinned core f0e7d2a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:44 -05:00

295 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Admin · Rust · Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ──────────
//
// Where a zone's ZoneManager flags and settings are chosen. Core's step editor
// has single-value fields only, so the org lead put the checkboxes here (D210):
// an admin saves a named set for one server, several, or every server, and a
// zone step's Options field offers the presets. Picking one COPIES its line into
// the step, so editing a preset here changes no published event.
//
// The flags are whatever each server's ZoneManager declared in its last hello
// (D211), grouped by `server/model/zones/zoneOptions.js`. A flag that file does
// not know is under Other, never hidden. A flag not on every server a preset
// covers is refused on save, with the server named.
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 6,
padding: '6px 8px',
font: 'inherit',
}
function blankForm() {
return { id: null, name: '', flags: [], settings: {}, allServers: false, servers: [] }
}
function formFrom(p) {
const settings = {}
for (const [k, v] of Object.entries(p.settings || {})) settings[k] = String(v)
return { id: p.id, name: p.name, flags: [...p.flags], settings, allServers: p.allServers, servers: [...p.servers] }
}
/** What the page sends: blank settings left out, the rest as typed (the server checks them). */
function bodyFrom(form) {
const settings = {}
for (const [k, v] of Object.entries(form.settings)) {
if (v !== '' && v !== undefined && v !== null) settings[k] = v
}
return { name: form.name.trim(), flags: form.flags, settings, allServers: form.allServers, servers: form.allServers ? [] : form.servers }
}
/** One line on what a server said about its zone mods. */
export function zoneModsLine(s) {
const zm = s.zoneManager
const zd = s.zoneDomes
const parts = [zm ? `ZoneManager ${zm.version || ''} · ${zm.flagCount} flags`.replace(' ', ' ') : 'no ZoneManager reported']
if (!zd || !zd.loaded) parts.push('no ZoneDomes, so no domes')
else if (!zd.helper || zd.helper.state === 'missing') parts.push('ZoneDomes without RunicGatewayDomes.cs, so no domes')
else if (zd.helper.state !== 'patched') parts.push(`the domes helper could not patch ZoneDomes${zd.helper.reason ? ` (${zd.helper.reason})` : ''}`)
else parts.push('domes available')
return parts.join(' · ')
}
export default function ZonePresets() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminZones.read(), [reloads])
const [form, setForm] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
if (form.id) await api.adminZones.update(form.id, bodyFrom(form))
else await api.adminZones.create(bodyFrom(form))
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const remove = async (preset) => {
setBusy(true)
setError('')
try {
await api.adminZones.remove(preset.id)
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not delete.')
} finally {
setBusy(false)
}
}
return (
<div style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Named sets of ZoneManager flags and settings. An event’s <em>Open a zone</em> step offers these in its Options
field; picking one copies it into the step, so changing a preset later does not change an event already
published. Who is exempt from a flag is a <code>zonemanager.ignoreflag.*</code> permission, set on the{' '}
<Link to="/admin/rust">permissions page</Link>.
</p>
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 8px', color: 'var(--head)' }}>Servers</h2>
{servers.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>}
{servers.map((s) => (
<div key={s.id} className="sans" style={{ fontSize: '0.8rem', padding: '4px 0' }}>
<strong style={{ color: 'var(--head)' }}>{s.name || s.id}</strong>{' '}
<span className="dim">{zoneModsLine(s)}</span>
</div>
))}
</section>
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Presets</h2>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(blankForm()) }}>
New preset
</button>
</div>
{(data.presets || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
None yet. None ship with the module: what an arena is differs by server.
</p>
)}
{(data.presets || []).map((p) => (
<div key={p.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--head)' }}>{p.name}</strong>
<span className="dim" style={{ fontSize: '0.76rem' }}>
{p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')}
</span>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(p)) }}>
Edit
</button>
</div>
<code className="dim" style={{ fontSize: '0.74rem', wordBreak: 'break-word' }}>{p.line || 'no flags or settings'}</code>
</div>
))}
</section>
{form && (
<PresetForm
form={form}
setForm={setForm}
data={data}
busy={busy}
error={error}
onSave={save}
onCancel={() => setForm(null)}
onDelete={form.id ? () => remove(form) : null}
/>
)}
</div>
)
}
function PresetForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
const servers = data.servers || []
// The flags on EVERY covered server that reported a list: a flag missing from
// one of them is shown but marked, and the save refuses it with the server named.
const covered = form.allServers ? servers : servers.filter((s) => form.servers.includes(s.id))
const missingOn = useMemo(() => {
const out = new Map()
for (const s of covered) {
const flags = s.zoneManager && s.zoneManager.flags
if (!flags || flags.length === 0) continue
const have = new Set(flags.map((f) => f.toLowerCase()))
for (const g of data.groups || []) {
for (const f of g.flags) {
if (!have.has(f.name.toLowerCase())) out.set(f.name, [...(out.get(f.name) || []), s.name || s.id])
}
}
}
return out
}, [covered, data.groups])
const toggleFlag = (name) =>
setForm((f) => ({ ...f, flags: f.flags.includes(name) ? f.flags.filter((x) => x !== name) : [...f.flags, name] }))
const toggleServer = (id) =>
setForm((f) => ({ ...f, servers: f.servers.includes(id) ? f.servers.filter((x) => x !== id) : [...f.servers, id] }))
const setSetting = (key, value) => setForm((f) => ({ ...f, settings: { ...f.settings, [key]: value } }))
return (
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{form.id ? `Edit ${form.name}` : 'A new preset'}
</h2>
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>Name</span>
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required maxLength={64} style={inputStyle} />
</label>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 6 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>For</legend>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.allServers} onChange={(e) => setForm((f) => ({ ...f, allServers: e.target.checked }))} />
Every server, including servers added later
</label>
{!form.allServers &&
servers.map((s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.servers.includes(s.id)} onChange={() => toggleServer(s.id)} />
{s.name || s.id}
</label>
))}
</fieldset>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Flags</legend>
{(data.groups || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
No server has reported its ZoneManager flags yet. They arrive when a server with ZoneManager connects.
</p>
)}
{(data.groups || []).map((g) => (
<div key={g.group}>
<div className="sans" style={{ fontSize: '0.8rem', color: 'var(--head)', marginBottom: 4 }}>{g.group}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: '4px 12px' }}>
{g.flags.map((f) => {
const missing = missingOn.get(f.name)
return (
<label
key={f.name}
className="sans"
title={[f.note, missing ? `Not on ${missing.join(', ')}` : null].filter(Boolean).join(' ')}
style={{ display: 'flex', gap: 6, fontSize: '0.8rem', opacity: missing ? 0.55 : 1 }}
>
<input type="checkbox" checked={form.flags.includes(f.name)} onChange={() => toggleFlag(f.name)} />
{f.name}
{missing && <span className="dim">*</span>}
</label>
)
})}
</div>
</div>
))}
{missingOn.size > 0 && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
* not on every server this preset is for. Hover for which.
</p>
)}
</fieldset>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 10 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Settings</legend>
{(data.settings || []).map((s) => (
<label key={s.key} className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{s.label}</span>
{s.type === 'boolean' ? (
<select value={form.settings[s.key] ?? ''} onChange={(e) => setSetting(s.key, e.target.value)} style={inputStyle}>
<option value="">Not set</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
) : (
<input
value={form.settings[s.key] ?? ''}
onChange={(e) => setSetting(s.key, e.target.value)}
inputMode={s.type === 'float' ? 'decimal' : undefined}
placeholder={s.type === 'float' ? `${s.min} to ${s.max}` : ''}
style={{ ...inputStyle, maxWidth: 260 }}
/>
)}
<span className="dim" style={{ fontSize: '0.74rem' }}>{s.description}</span>
</label>
))}
</fieldset>
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{onDelete && (
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={onDelete} disabled={busy}>
Delete preset
</button>
)}
</div>
</form>
)
}