@@ -0,0 +1,366 @@
// ── Admin · Rust · Servers (phase 16, D133) ───────────────────────────────
//
// The page the module did not have. Until phase 16 a server row was written only
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
// schedule needed somewhere to be typed, and the org lead chose to build the
// missing page rather than hang the schedule on the visibility page (D133).
//
// One form for adding and editing, because they are one `PUT`. Three rules it
// keeps from the API:
//
// 1. **The token is write-only.** The field is blank on every edit, a blank
// save leaves the stored one alone, and the row says whether one is stored.
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
// an omitted protocol to this build's, so an edit sends the stored value back
// rather than silently re-stamping the row.
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
// shows what was stored; the row shows what it computes to, from the same
// answer the public pages read.
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
import { useState } from 'react'
import { ErrorState , Loading , useAsync } from '../../core.js'
import api from '../../api.js'
import { ago , nextWipe } from '../../lib/format.js'
const RULES = [
{ id : 'none' , label : 'No schedule' , hint : 'Nothing is forecast, not even the monthly forced wipe.' } ,
{ id : 'forced' , label : 'Forced wipe only' , hint : 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' } ,
{ id : 'weekly' , label : 'Weekly' , hint : 'Every week on the day and time below, and the forced wipe.' } ,
{ id : 'biweekly' , label : 'Every other week' , hint : 'Every second week, on the weeks the date below falls in, and the forced wipe.' } ,
]
const DAYS = [ 'Sunday' , 'Monday' , 'Tuesday' , 'Wednesday' , 'Thursday' , 'Friday' , 'Saturday' ]
const SOURCE = { forced : 'the forced wipe' , rule : 'its own schedule' , once : 'the one-off date' }
/** The browser's own zone — the likeliest answer for an operator typing a time. */
const localZone = ( ) => {
try {
return Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
const ZONES = ( ( ) => {
try {
return typeof Intl . supportedValuesOf === 'function' ? Intl . supportedValuesOf ( 'timeZone' ) : [ ]
} catch {
return [ ]
}
} ) ( )
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
function toLocalInput ( iso ) {
if ( ! iso ) return ''
const d = new Date ( iso )
if ( Number . isNaN ( d . getTime ( ) ) ) return ''
const pad = ( n ) => String ( n ) . padStart ( 2 , '0' )
return ` ${ d . getFullYear ( ) } - ${ pad ( d . getMonth ( ) + 1 ) } - ${ pad ( d . getDate ( ) ) } T ${ pad ( d . getHours ( ) ) } : ${ pad ( d . getMinutes ( ) ) } `
}
function blankForm ( ) {
return {
isNew : true ,
id : '' ,
name : '' ,
sidecarBaseUrl : '' ,
sidecarToken : '' ,
enabled : true ,
sortOrder : 0 ,
protocol : undefined ,
wipeRule : 'none' ,
wipeDay : 4 ,
wipeTime : '19:00' ,
wipeTz : localZone ( ) ,
wipeAnchor : '' ,
wipeOnce : '' ,
}
}
function formFrom ( server ) {
const s = server . schedule || { rule : 'none' }
return {
isNew : false ,
id : server . id ,
name : server . name ,
sidecarBaseUrl : server . sidecarBaseUrl ,
sidecarToken : '' ,
enabled : server . enabled ,
sortOrder : server . sortOrder ,
protocol : server . protocol ,
wipeRule : s . rule || 'none' ,
wipeDay : s . day == null ? 4 : s . day ,
wipeTime : s . time || '19:00' ,
wipeTz : s . tz || localZone ( ) ,
wipeAnchor : s . anchor || '' ,
wipeOnce : toLocalInput ( s . onceAt ) ,
storedOnceAt : s . onceAt || null ,
}
}
/** The PUT body. The token only when one was typed; the schedule always. */
function bodyFrom ( form ) {
const weekly = form . wipeRule === 'weekly' || form . wipeRule === 'biweekly'
const onceUnchanged = form . storedOnceAt && form . wipeOnce === toLocalInput ( form . storedOnceAt )
const body = {
name : form . name . trim ( ) ,
sidecarBaseUrl : form . sidecarBaseUrl . trim ( ) ,
enabled : form . enabled ,
sortOrder : Number ( form . sortOrder ) || 0 ,
wipeRule : form . wipeRule ,
wipeDay : weekly ? Number ( form . wipeDay ) : null ,
wipeTime : weekly ? form . wipeTime : null ,
wipeTz : weekly ? form . wipeTz . trim ( ) : null ,
wipeAnchor : form . wipeRule === 'biweekly' ? form . wipeAnchor : null ,
// A past one-off date that was not touched is dropped rather than sent back:
// the server refuses a past date on save (it says nothing about the next
// wipe), and an operator editing the name must not be stopped by it.
wipeOnceAt : ! form . wipeOnce || ( onceUnchanged && Date . parse ( form . storedOnceAt ) <= Date . now ( ) )
? null
: new Date ( form . wipeOnce ) . toISOString ( ) ,
}
if ( form . sidecarToken ) body . sidecarToken = form . sidecarToken
if ( form . protocol !== undefined ) body . protocol = form . protocol
return body
}
export default function ServerSettings ( ) {
const [ reloads , setReloads ] = useState ( 0 )
const { data , error : loadError } = useAsync ( ( ) => api . admin . listServers ( ) , [ reloads ] )
const [ form , setForm ] = useState ( null )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const [ notes , setNotes ] = useState ( { } )
const [ confirming , setConfirming ] = useState ( null )
if ( loadError ) return < ErrorState error = { loadError } / >
if ( ! data ) return < Loading / >
const servers = data . servers || [ ]
const set = ( key ) => ( e ) => {
const value = e && e . target ? ( e . target . type === 'checkbox' ? e . target . checked : e . target . value ) : e
setForm ( ( f ) => ( { ... f , [ key ] : value } ) )
}
const save = async ( e ) => {
e . preventDefault ( )
setBusy ( true )
setError ( '' )
try {
await api . admin . saveServer ( form . id . trim ( ) , bodyFrom ( form ) )
setForm ( null )
setReloads ( ( n ) => n + 1 )
} catch ( err ) {
setError ( err . message || 'That did not save.' )
} finally {
setBusy ( false )
}
}
const test = async ( server ) => {
setNotes ( ( n ) => ( { ... n , [ server . id ] : 'Asking the sidecar…' } ) )
try {
const r = await api . admin . testServer ( server . id )
const sidecar = r . sidecar || { }
const text = r . ok
? ` The sidecar answered ${ sidecar . protocol ? ` (protocol ${ sidecar . protocol } ) ` : '' } ${ sidecar . plugin _connected === false ? ', but the game’ s plugin is not connected to it' : '' } . `
: ` The sidecar did not answer: ${ r . status } . `
setNotes ( ( n ) => ( { ... n , [ server . id ] : text } ) )
} catch ( err ) {
setNotes ( ( n ) => ( { ... n , [ server . id ] : err . message || 'The test did not run.' } ) )
}
}
const remove = async ( server ) => {
setConfirming ( null )
try {
await api . admin . deleteServer ( server . id )
setReloads ( ( n ) => n + 1 )
} catch ( err ) {
setNotes ( ( n ) => ( { ... n , [ server . id ] : err . message || 'That did not delete.' } ) )
}
}
return (
< div style = { { maxWidth : 900 } } >
< p className = "sans dim" style = { { fontSize : '0.82rem' , marginTop : 0 } } >
Each Rust server this site follows , the sidecar it answers through , and when it wipes . A server ’ s sidecar is
installed on its own game host ; this page tells the site where to find it .
< / p >
< section className = "panel" style = { { padding : '16px 18px' , marginBottom : 18 } } >
{ 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 = { { borderTop : '1px solid var(--line-soft)' , padding : '10px 0' , fontSize : '0.84rem' } } >
< div style = { { display : 'flex' , flexWrap : 'wrap' , alignItems : 'baseline' , gap : 10 } } >
< strong style = { { color : 'var(--head)' } } > { s . name } < / strong >
< code className = "dim" style = { { fontSize : '0.74rem' } } > { s . id } < / code >
{ ! s . enabled && < span className = "dim" style = { { fontSize : '0.74rem' } } > disabled < / span > }
< span style = { { marginLeft : 'auto' , color : s . online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' } } >
{ s . online ? ` online · ${ s . players } ${ s . maxPlayers ? ` / ${ s . maxPlayers } ` : '' } ` : s . reachable ? 'sidecar up, game offline' : 'unreachable' }
< / span >
< / div >
< div className = "dim" style = { { fontSize : '0.76rem' , marginTop : 4 } } >
{ s . sidecarBaseUrl } · { s . hasToken ? 'token stored' : 'no token' } · protocol { s . protocol }
{ s . sidecarProtocol != null && s . sidecarProtocol !== s . protocol ? ` (sidecar speaks ${ s . sidecarProtocol } ) ` : '' }
{ s . lastSeenAt ? ` · last seen ${ ago ( s . lastSeenAt ) } ` : '' }
< / div >
< div className = "dim" style = { { fontSize : '0.76rem' , marginTop : 2 } } >
{ s . nextWipe
? ` Next wipe ${ nextWipe ( s . nextWipe ) } , from ${ SOURCE [ s . nextWipe . source ] || s . nextWipe . source } . `
: 'No wipe schedule set.' }
< / div >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : 8 , marginTop : 8 } } >
< button type = "button" className = "btn" onClick = { ( ) => { setError ( '' ) ; setForm ( formFrom ( s ) ) } } > Edit < / button >
< button type = "button" className = "btn" onClick = { ( ) => test ( s ) } > Test the sidecar < / button >
{ confirming === s . id ? (
< >
< button type = "button" className = "btn" onClick = { ( ) => remove ( s ) } > Delete { s . name } and everything recorded about it < / button >
< button type = "button" className = "btn" onClick = { ( ) => setConfirming ( null ) } > Keep it < / button >
< / >
) : (
< button type = "button" className = "btn" onClick = { ( ) => setConfirming ( s . id ) } > Delete < / button >
) }
< / div >
{ notes [ s . id ] && < p className = "dim" style = { { fontSize : '0.76rem' , margin : '6px 0 0' } } > { notes [ s . id ] } < / p > }
< / div >
) ) }
{ ! form && (
< div style = { { marginTop : 12 } } >
< button type = "button" className = "btn" onClick = { ( ) => { setError ( '' ) ; setForm ( blankForm ( ) ) } } > Add a server < / button >
< / div >
) }
< / section >
{ form && (
< ServerForm form = { form } set = { set } busy = { busy } error = { error } onSave = { save } onCancel = { ( ) => setForm ( null ) } / >
) }
< / div >
)
}
function Field ( { label , hint , children } ) {
return (
< label className = "sans" style = { { display : 'grid' , gap : 4 , fontSize : '0.84rem' } } >
< span style = { { color : 'var(--head)' } } > { label } < / span >
{ children }
{ hint && < span className = "dim" style = { { fontSize : '0.74rem' } } > { hint } < / span > }
< / label >
)
}
function ServerForm ( { form , set , busy , error , onSave , onCancel } ) {
const weekly = form . wipeRule === 'weekly' || form . wipeRule === 'biweekly'
const rule = RULES . find ( ( r ) => r . id === form . wipeRule ) || RULES [ 0 ]
const oncePast = form . storedOnceAt && form . wipeOnce === toLocalInput ( form . storedOnceAt ) && Date . parse ( form . storedOnceAt ) <= Date . now ( )
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 . isNew ? 'Add a server' : ` Edit ${ form . name } ` }
< / h2 >
{ form . isNew && (
< Field label = "Id" hint = "Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later." >
< input value = { form . id } onChange = { set ( 'id' ) } required pattern = "[a-z0-9][a-z0-9-]{0,63}" style = { inputStyle } / >
< / Field >
) }
< Field label = "Name" >
< input value = { form . name } onChange = { set ( 'name' ) } required maxLength = { 120 } style = { inputStyle } / >
< / Field >
< Field label = "Sidecar address" hint = "The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090." >
< input value = { form . sidecarBaseUrl } onChange = { set ( 'sidecarBaseUrl' ) } required style = { inputStyle } / >
< / Field >
< Field
label = "Sidecar token"
hint = { form . isNew ? 'From the sidecar’ s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.' }
>
< input
type = "password"
autoComplete = "new-password"
value = { form . sidecarToken }
onChange = { set ( 'sidecarToken' ) }
required = { form . isNew }
maxLength = { 512 }
style = { inputStyle }
/ >
< / Field >
< div style = { { display : 'flex' , gap : 18 , flexWrap : 'wrap' } } >
< label className = "sans" style = { { display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : '0.84rem' } } >
< input type = "checkbox" checked = { form . enabled } onChange = { set ( 'enabled' ) } / > Enabled
< / label >
< Field label = "Order" >
< input type = "number" value = { form . sortOrder } onChange = { set ( 'sortOrder' ) } min = { - 1000 } max = { 1000 } style = { { ... inputStyle , width : 90 } } / >
< / Field >
< / div >
< fieldset style = { { border : '1px solid var(--line-soft)' , borderRadius : 8 , padding : '12px 14px' , display : 'grid' , gap : 12 } } >
< legend className = "sans" style = { { color : 'var(--head)' , fontSize : '0.86rem' , padding : '0 6px' } } > Wipe schedule < / legend >
< Field label = "Rule" hint = { rule . hint } >
< select value = { form . wipeRule } onChange = { set ( 'wipeRule' ) } style = { inputStyle } >
{ RULES . map ( ( r ) => < option key = { r . id } value = { r . id } > { r . label } < / option > ) }
< / select >
< / Field >
{ weekly && (
< div style = { { display : 'flex' , gap : 12 , flexWrap : 'wrap' } } >
< Field label = "Day" >
< select value = { form . wipeDay } onChange = { set ( 'wipeDay' ) } style = { inputStyle } >
{ DAYS . map ( ( d , i ) => < option key = { d } value = { i } > { d } < / option > ) }
< / select >
< / Field >
< Field label = "Time" >
< input type = "time" value = { form . wipeTime } onChange = { set ( 'wipeTime' ) } required style = { inputStyle } / >
< / Field >
< Field label = "Time zone" hint = "The zone the time is in. It follows that zone’ s summer time." >
< input value = { form . wipeTz } onChange = { set ( 'wipeTz' ) } list = "rust-zones" required style = { inputStyle } / >
< / Field >
< / div >
) }
{ form . wipeRule === 'biweekly' && (
< Field label = "One wipe on this schedule" hint = { ` A ${ DAYS [ form . wipeDay ] } the server wiped or will wipe on. It says which weeks are wipe weeks. ` } >
< input type = "date" value = { form . wipeAnchor } onChange = { set ( 'wipeAnchor' ) } required style = { inputStyle } / >
< / Field >
) }
< Field
label = "One-off wipe (optional)"
hint = "A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
>
< span style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< input type = "datetime-local" value = { form . wipeOnce } onChange = { set ( 'wipeOnce' ) } style = { inputStyle } / >
{ form . wipeOnce && < button type = "button" className = "btn" onClick = { ( ) => set ( 'wipeOnce' ) ( '' ) } > Clear < / button > }
< / span >
< / Field >
{ oncePast && (
< p className = "sans dim" style = { { fontSize : '0.76rem' , margin : 0 } } >
That date has passed , so it no longer changes anything . Saving will clear it .
< / p >
) }
< / fieldset >
< datalist id = "rust-zones" > { ZONES . map ( ( z ) => < option key = { z } value = { z } / > ) } < / datalist >
< div className = "sans" style = { { display : 'flex' , alignItems : 'center' , gap : 12 } } >
< button type = "submit" className = "btn" disabled = { busy } > { busy ? 'Saving…' : 'Save' } < / button >
< button type = "button" className = "btn" onClick = { onCancel } disabled = { busy } > Cancel < / button >
{ error && < span style = { { color : '#d08a2a' , fontSize : '0.8rem' } } > { error } < / span > }
< / div >
< / form >
)
}
const inputStyle = {
background : 'var(--panel-flat, transparent)' ,
color : 'var(--text)' ,
border : '1px solid var(--line)' ,
borderRadius : 'var(--radius-input, 6px)' ,
padding : '5px 8px' ,
fontSize : '0.84rem' ,
}