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
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
// ── 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>
|
||
)
|
||
}
|