// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
// is online by default — the narrowest audience, staff, unless an operator
// deliberately widens it here. A count of players is public at every setting.
//
// One fleet default and an optional override per server, because a creative or
// PvE server may reasonably publish a roll call a PvP server must not — and a
// server that has not chosen follows the fleet, so narrowing the fleet narrows
// every server that never said otherwise.
//
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
//
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
// defaults to the clan's own members and staff, and widening it widens online
// status too, because a roster row carries it — the page says so. The same card
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
//
// Phase 13b adds a third (D104, D106): whether a published news post is also
// said in each server's in-game chat. Off by default, because core sends every
// post to every registered leg — without a switch, the day this module updated,
// every post would start appearing in every server's chat. It lives here because
// this is the one page that lists every server with a setting of its own.
//
// Phase 14 adds the live map's switches (D114): four layers, each with a fleet
// audience and an optional per-server override, and the own-and-mates switch
// (D118). Beside each server is what its map picture is, and the two buttons
// that act at once rather than on Save: Fetch again, and Render now — shown only
// where there is no picture, with how long it will stall that server (D109).
//
// PLAN_REDESIGNS §4 adds which monuments the map draws (D196): a switch per
// monument label, because that is what staff read and it groups the variants
// (31 substations of four prefabs are one "Substation"). The minor labels start
// hidden and every other label drawn, so a monument Facepunch adds appears. One
// fleet list, and each server's own labels with an override each.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { fleetLabels, fleetShows, markerDiff, serverShows, setFleet as setFleetMarker, setServer as setServerMarker } from '../../lib/mapMarkers.js'
const INHERIT = ''
const LABEL = {
staff: 'Staff only',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_LABEL = {
members: 'The clan’s members and staff',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_DESCRIBE = {
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
function Card({ title, subtitle, children }) {
return (
{title}
{subtitle && (
{subtitle}
)}
{children}
)
}
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
return (
)
}
export default function Visibility() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [news, setNews] = useState({})
const [delivery, setDelivery] = useState({})
const [mapFleet, setMapFleet] = useState({})
const [mapServers, setMapServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
// The form starts from what the server said and is reset from it after every
// save — the answer to a PUT is the new state, so what is on screen is always
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
setDelivery(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, s.delivery || 'chat'])))
if (state.map) {
setMapFleet({ ...state.map.fleet, markers: { ...(state.map.fleet.markers || {}) } })
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
}
}, [])
useEffect(() => {
if (data) load(data)
}, [data, load])
if (loadError) return
if (!data) return
const audiences = data.audiences
const rows = data.presence.servers
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
const newsRows = (data.news && data.news.servers) || []
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
const dirtyDelivery = newsRows.filter((s) => (delivery[s.id] || 'chat') !== (s.delivery || 'chat'))
const mapCard = data.map || null
const mapChanges = mapCard ? mapDiff(mapCard, mapFleet, mapServers) : null
const dirtyMap = Boolean(mapChanges)
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyDelivery.length > 0 || dirtyMap
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
setSaved(false)
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
if (dirtyClans) body.clanRoster = clanRoster
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
if (dirtyDelivery.length) body.newsDelivery = Object.fromEntries(dirtyDelivery.map((s) => [s.id, delivery[s.id] || 'chat']))
if (dirtyMap) body.map = mapChanges
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
)
}
/**
* What each server's clan board says about itself. Only the servers with
* something to report are listed: a board that is current, complete and read
* normally is the case that needs no sentence.
*/
function ClanBoards({ servers }) {
const notes = []
for (const s of servers) {
if (s.umodClans) {
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
}
if (!s.supported) {
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
} else if (s.truncated) {
notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
} else if (!s.fresh) {
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
}
}
if (!notes.length) return null
return (
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '4px 8px',
fontSize: '0.84rem',
}
// ── The live map (phase 14) ───────────────────────────────────────────────
const MAP_LAYERS = [
{ id: 'world', label: 'Monuments & world events', hint: 'Cargo, helicopters, Bradley, supply drops, locked crates, and RunicNPC bosses placed outside an event.' },
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
]
const RANK = { public: 0, signed_in: 1, staff: 2 }
/** A server's overrides as form values: '' follows the fleet, mates is 'on' / 'off'. */
function mapOverridesToForm(overrides) {
const form = {}
for (const l of MAP_LAYERS) form[l.id] = overrides[l.id] || INHERIT
form.mates = overrides.mates === null || overrides.mates === undefined ? INHERIT : overrides.mates ? 'on' : 'off'
form.markers = { ...(overrides.markers || {}) }
return form
}
/** What the form changed, in the shape the PUT takes, or null when nothing did. */
function mapDiff(card, fleet, servers) {
const out = {}
const fleetChanges = {}
for (const key of [...MAP_LAYERS.map((l) => l.id), 'mates']) {
if (key in fleet && fleet[key] !== card.fleet[key]) fleetChanges[key] = fleet[key]
}
const fleetMarkers = markerDiff(card.fleet.markers, fleet.markers)
if (fleetMarkers) fleetChanges.markers = fleetMarkers
if (Object.keys(fleetChanges).length) out.fleet = fleetChanges
const serverChanges = {}
for (const s of card.servers) {
const before = mapOverridesToForm(s.overrides)
const now = servers[s.id] || before
const changed = {}
const markers = markerDiff(before.markers, now.markers)
if (markers) changed.markers = markers
for (const key of Object.keys(before)) {
if (key === 'markers' || now[key] === before[key]) continue
if (key === 'mates') changed.mates = now.mates === INHERIT ? null : now.mates === 'on'
else changed[key] = now[key] === INHERIT ? null : now[key]
}
if (Object.keys(changed).length) serverChanges[s.id] = changed
}
if (Object.keys(serverChanges).length) out.servers = serverChanges
return Object.keys(out).length ? out : null
}
function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setServers, onActed }) {
const [acting, setActing] = useState(null)
const [notes, setNotes] = useState({})
// Fetch again and Render now act at once rather than on Save: they are
// questions put to a game server, not settings.
const act = async (server, kind) => {
setActing(`${server.id}:${kind}`)
setNotes((n) => ({ ...n, [server.id]: '' }))
try {
if (kind === 'fetch') {
const r = await api.admin.fetchMap(server.id)
setNotes((n) => ({ ...n, [server.id]: r.message || `Fetched: ${r.outcome}.` }))
} else {
const r = await api.admin.renderMap(server.id)
setNotes((n) => ({
...n,
[server.id]: `The server is drawing its map now — about ${r.stallSeconds} seconds of stall. The picture appears here when it is done.`,
}))
}
onActed()
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not work.' }))
} finally {
setActing(null)
}
}
return (
The picture of the map is always public: it is drawn from the map seed and says nothing about who plays.
Everything that moves on it is a layer, and each layer has its own audience. The players layer can never
show more than who is online does, whatever it is set to here.
Players will be shown to {(LABEL[presence] || presence).toLowerCase()} on this server, because that is who may see who is online.
)}
{describePicture(s.picture)}
{noPicture && (
)}
{noPicture && (
Render now makes the game draw its own map, which stops that server for about {s.renderStallSeconds} seconds —
nobody on it can move while it draws. A server with Rust+ switched on (app.port) never needs it.
)}
{(notes[s.id] || s.lastError) && (
{notes[s.id] || s.lastError}
)}
)
})}
)
}
/**
* The fleet's monument labels (D196): every label on any server's current map,
* and the minor list, each a checkbox. Ticked is drawn.
*/
function FleetMarkers({ card, fleet, setFleet }) {
const minor = card.minorLabels || []
const labels = fleetLabels(minor, card.servers)
const hidden = labels.filter((l) => !fleetShows(minor, fleet.markers, l.key)).length
return (
Monuments on the map · {labels.length} labels, {hidden} hidden by default
Ticked labels are drawn on every server that has not chosen for itself. The minor ones — substations, caves, train
tunnels, wells and the like — start hidden. Any label not listed here, such as a monument a game update adds, is drawn.
{labels.map((l) => (
))}
)
}
/**
* One server's monument labels (D196): the labels on its current map, each
* following the fleet or drawn or hidden on this server alone.
*/
function ServerMarkers({ server, minorLabels, fleetMarkers, markers, onChange }) {
const labels = server.markerLabels || []
if (!labels.length) return null
const hidden = labels.filter((l) => !serverShows(minorLabels, fleetMarkers, markers, l.key)).length
const own = Object.keys(markers || {}).length
return (
Monuments: {labels.length} labels on this map, {hidden} hidden{own ? `, ${own} set for this server` : ''}
)
}
function FleetRow({ label, hint, children }) {
return (
<>
{label}{hint}
{children}
>
)
}
function describePicture(pic) {
if (!pic) return 'The server has not described its map yet.'
if (!pic.hasPicture) return `Map ${pic.mapKey}: the game has no picture of it.`
const from = pic.source === 'companion' ? 'from Rust+' : 'drawn on request'
const kb = Math.round((pic.bytes || 0) / 1024)
return `Map ${pic.mapKey}: picture ${from}, ${pic.width} × ${pic.height}, ${kb} KB${pic.fetchedAt ? `, fetched ${new Date(pic.fetchedAt).toLocaleString()}` : ''}.`
}