// ── 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 (

Nothing on this site names who is online unless you choose to show it. That covers more than the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last seen”, all say that a named player was on at a given moment. How many players are online is always shown.

{DESCRIBE[fleet]}
{rows.length === 0 && (

No servers are configured yet.

)} {rows.map((s) => (
{s.name} {!s.enabled && · disabled} setServers((prev) => ({ ...prev, [s.id]: v }))} audiences={audiences} inherit={`Default (${LABEL[fleet] || fleet})`} label={`Who is online on ${s.name}`} /> {servers[s.id] ? 'its own setting' : 'follows the default'}
))}
{widened && (

Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is undefended.

)}
{CLAN_DESCRIBE[clanRoster]}

Each clan’s name, colour, score and member count are always public.

{clanRoster !== 'members' && (

A roster also shows which members are online right now, so this shows who is on to{' '} {clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.

)}

When a news post is published, its title is said in the chat of every server switched on here. A server that is down when a post is published is skipped rather than told late. A popup needs PopupNotifications on that server; one without it refuses the post and says why.

{newsRows.length === 0 && (

No servers are configured yet.

)} {newsRows.map((s) => ( ))}
{mapCard && ( effective(id)} fleet={mapFleet} setFleet={setMapFleet} servers={mapServers} setServers={setMapServers} onActed={() => setReloads((n) => n + 1)} /> )}
{saved && !dirty && Saved.} {error && {error}}
) } /** * 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 (
    {notes.map(([s, text], i) => ( // eslint-disable-next-line react/no-array-index-key
  • {s.name} {text}
  • ))}
) } 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.

{MAP_LAYERS.map((l) => ( setFleet((f) => ({ ...f, [l.id]: v }))} audiences={audiences} label={`${l.label}: fleet default`} /> ))}
{card.servers.length === 0 && (

No servers are configured yet.

)} {card.servers.map((s) => { const form = servers[s.id] || mapOverridesToForm(s.overrides) const set = (key, value) => setServers((prev) => ({ ...prev, [s.id]: { ...form, [key]: value } })) const players = form.players || fleet.players const presence = presenceOf(s.id) const capped = RANK[players] < RANK[presence] const noPicture = !s.picture || !s.picture.hasPicture return (
{s.name} {!s.enabled && · disabled}
{MAP_LAYERS.map((l) => ( ))}
set('markers', next)} /> {capped && (

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` : ''}
{labels.map((l) => { const mine = markers && Object.prototype.hasOwnProperty.call(markers, l.key) ? (markers[l.key] ? 'on' : 'off') : INHERIT const fleetOn = fleetShows(minorLabels, fleetMarkers, l.key) return ( ) })}
) } 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()}` : ''}.` }