The plugin now answers a save once the files are written, with pending and a writeId, and reports the reload later as a config.outcome event. The save is recorded as reloading with a settle_by of two plugin ceilings plus slack on the database's clock; ingest settles the row by (server, writeId), only while it is still reloading, so a replay moves nothing and a late outcome still lands. A row past settle_by reads as lost. GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page polls it every two seconds, holds the Save button while it waits, and says whether a rolled-back plugin came back on its old file. config.outcome is a staff kind: it carries the server's log tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
642 lines
23 KiB
JavaScript
642 lines
23 KiB
JavaScript
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||
//
|
||
// R18. An admin picks a server, a plugin and a file, changes something, and the
|
||
// plugin reloads. This module's second admin page, and the first that writes to
|
||
// somebody's filesystem.
|
||
//
|
||
// **What is on the screen is decided by what is dangerous about the action.**
|
||
// Four things are true here that are not true anywhere else in this module, and
|
||
// each of them is a piece of the page rather than a line in a doc:
|
||
//
|
||
// • a save can take a required plugin DOWN. So the reload target is a
|
||
// deliberate choice with the folder name as a guess, the result is reported
|
||
// as its own panel, and a rollback shows the server's own log line.
|
||
// • the form cannot express everything a config holds. A `null`, an empty
|
||
// array and anything past the depth limit are marked and sent to the raw
|
||
// tier rather than half-drawn.
|
||
// • three keys in the bridge's own config would cut the link carrying the
|
||
// edit, or split the server's history. They render read-only, with the
|
||
// reason (D38).
|
||
// • configs hold API keys and Discord webhooks. Those fields render masked
|
||
// with a reveal, which is about the shoulder rather than the wire: an admin
|
||
// can already read the file over SSH (D37), and the audit trail never
|
||
// records the values either way.
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
|
||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||
import { ago } from '../../lib/format.js'
|
||
import api from '../../api.js'
|
||
|
||
function Card({ title, subtitle, children, actions }) {
|
||
return (
|
||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||
{title}
|
||
</h2>
|
||
{subtitle && (
|
||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||
{subtitle}
|
||
</span>
|
||
)}
|
||
<span style={{ flex: 1 }} />
|
||
{actions}
|
||
</header>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Warn({ children, tone = '#d08a2a' }) {
|
||
return (
|
||
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||
{children}
|
||
</p>
|
||
)
|
||
}
|
||
|
||
/** A value the form can edit: one row, typed by what the file already holds. */
|
||
function Field({ field, value, onChange, revealed, onReveal }) {
|
||
const indent = 12 * Math.max(0, field.depth - 1)
|
||
const label = (
|
||
<label
|
||
className="sans"
|
||
style={{
|
||
flex: '0 0 300px',
|
||
paddingLeft: indent,
|
||
color: field.locked ? 'var(--ink)' : 'var(--head)',
|
||
fontSize: '0.84rem',
|
||
wordBreak: 'break-word',
|
||
}}
|
||
title={field.path}
|
||
>
|
||
{field.key}
|
||
{field.locked && (
|
||
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||
{' '}
|
||
· read-only
|
||
</span>
|
||
)}
|
||
</label>
|
||
)
|
||
|
||
if (field.type === 'object' || field.type === 'array') {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
|
||
<span
|
||
className="sans"
|
||
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
|
||
>
|
||
{field.key || '(the file)'}
|
||
</span>
|
||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
|
||
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (field.advanced) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||
{label}
|
||
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||
{field.reason} — edit it in Raw JSON
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||
{label}
|
||
{field.type === 'boolean' ? (
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(value)}
|
||
disabled={field.locked}
|
||
onChange={(event) => onChange(field, event.target.checked)}
|
||
/>
|
||
) : (
|
||
<input
|
||
className="input"
|
||
style={{ flex: 1, minWidth: 0 }}
|
||
type={field.secret && !revealed ? 'password' : 'text'}
|
||
value={value === undefined || value === null ? '' : String(value)}
|
||
disabled={field.locked}
|
||
onChange={(event) => onChange(field, event.target.value)}
|
||
/>
|
||
)}
|
||
{field.secret && !field.locked && (
|
||
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
|
||
{revealed ? 'Hide' : 'Show'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** How often a save that is still reloading asks how it went. */
|
||
const POLL_MS = 2000
|
||
|
||
/**
|
||
* A settled write row as the report panel reads it.
|
||
*
|
||
* The row is this module's audit record, not the plugin's frame, so it carries
|
||
* no per-file `rewritten` — the file is re-read after either way, which is what
|
||
* the panel's note about rewriting was for.
|
||
*/
|
||
function reportFromWrite(write) {
|
||
return {
|
||
pending: write.outcome === 'reloading',
|
||
lost: write.outcome === 'lost',
|
||
reload: write.reloadTarget,
|
||
reloaded: Boolean(write.reloaded),
|
||
rolledBack: write.outcome === 'rolled-back',
|
||
restored: write.restored,
|
||
reason: write.detail,
|
||
log: write.log,
|
||
files: [],
|
||
}
|
||
}
|
||
|
||
/** The sentence at the top of the panel. */
|
||
function headline(report) {
|
||
if (report.pending) {
|
||
return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.`
|
||
}
|
||
|
||
if (report.lost) {
|
||
return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.'
|
||
}
|
||
|
||
if (report.rolledBack) {
|
||
return report.restored === false
|
||
? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.'
|
||
: 'The plugin did not come back, so the old file was put back automatically.'
|
||
}
|
||
|
||
return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}`
|
||
}
|
||
|
||
/** What the game said happened. The rollback case is the one worth reading. */
|
||
function Report({ report }) {
|
||
if (!report) return null
|
||
|
||
const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)'
|
||
|
||
return (
|
||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||
{headline(report)}
|
||
</p>
|
||
{report.rolledBack && report.reason && (
|
||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||
{report.reason}
|
||
</p>
|
||
)}
|
||
{report.log && (
|
||
<pre
|
||
className="sans"
|
||
style={{
|
||
background: 'var(--line-soft)',
|
||
padding: 10,
|
||
marginTop: 8,
|
||
fontSize: '0.74rem',
|
||
maxHeight: 200,
|
||
overflow: 'auto',
|
||
whiteSpace: 'pre-wrap',
|
||
}}
|
||
>
|
||
{report.log}
|
||
</pre>
|
||
)}
|
||
{report.files.some((f) => f.rewritten) && (
|
||
<Warn>
|
||
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
|
||
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
|
||
</Warn>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function ModConfig() {
|
||
const [serverId, setServerId] = useState('')
|
||
const [path, setPath] = useState('')
|
||
const [tier, setTier] = useState('form')
|
||
const [edits, setEdits] = useState({})
|
||
const [raw, setRaw] = useState('')
|
||
const [reload, setReload] = useState('')
|
||
const [revealed, setRevealed] = useState({})
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [report, setReport] = useState(null)
|
||
const [fileNonce, setFileNonce] = useState(0)
|
||
// The write a save left reloading, polled until it settles (protocol 13, D179).
|
||
const [watching, setWatching] = useState(null)
|
||
|
||
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
|
||
|
||
// The tree is asked for per server and never cached across one: what is on a
|
||
// host's disk has no stale answer worth showing, and a plugin loaded a minute
|
||
// ago has to be able to appear.
|
||
const { data: tree, error: treeError } = useAsync(
|
||
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
|
||
[serverId],
|
||
)
|
||
|
||
const { data: file, error: fileError } = useAsync(
|
||
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
|
||
[serverId, path, fileNonce],
|
||
)
|
||
|
||
const reset = useCallback(() => {
|
||
setEdits({})
|
||
setRevealed({})
|
||
setError('')
|
||
}, [])
|
||
|
||
// A freshly opened file starts from what the host holds: the raw editor's text
|
||
// and the reload target's guess both come from the answer rather than from
|
||
// whatever the previous file left behind.
|
||
//
|
||
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
|
||
// whose value matches no `<option>` displays the first one, so a guess of
|
||
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
|
||
// reload itself — put "nothing — just write the file" on the screen while the
|
||
// request carried `reload: RunicGateway`, and every save of our own config was
|
||
// refused for a reason the page had just said did not apply.
|
||
useEffect(() => {
|
||
if (!file) return
|
||
setRaw(file.text)
|
||
|
||
const offered = (tree ? tree.loaded : []).some(
|
||
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
|
||
)
|
||
|
||
setReload(offered ? file.plugin : '')
|
||
reset()
|
||
}, [file, tree, reset])
|
||
|
||
useEffect(() => {
|
||
setPath('')
|
||
setReport(null)
|
||
setWatching(null)
|
||
}, [serverId])
|
||
|
||
// A save that reloads a plugin comes back before the reload has finished.
|
||
// Ask how it went every couple of seconds until the row leaves `reloading` —
|
||
// the server calls it `lost` once twice the plugin's ceiling has passed, so
|
||
// this always ends — then re-read the file, because a reload rewrites it and a
|
||
// rollback puts the old one back.
|
||
useEffect(() => {
|
||
if (!watching) return undefined
|
||
|
||
let stopped = false
|
||
let handle = null
|
||
|
||
const poll = async () => {
|
||
try {
|
||
const { write } = await api.adminConfig.write(watching.serverId, watching.id)
|
||
if (stopped) return
|
||
|
||
setReport(reportFromWrite(write))
|
||
|
||
if (write.outcome !== 'reloading') {
|
||
setWatching(null)
|
||
setFileNonce((n) => n + 1)
|
||
return
|
||
}
|
||
} catch {
|
||
// One failed poll is a blip, not an answer; the next one asks again.
|
||
if (stopped) return
|
||
}
|
||
|
||
handle = setTimeout(poll, POLL_MS)
|
||
}
|
||
|
||
handle = setTimeout(poll, POLL_MS)
|
||
|
||
return () => {
|
||
stopped = true
|
||
clearTimeout(handle)
|
||
}
|
||
}, [watching])
|
||
|
||
if (serverError) return <ErrorState error={serverError} />
|
||
if (!servers) return <Loading />
|
||
|
||
const rows = servers.servers || servers || []
|
||
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
|
||
|
||
const save = async () => {
|
||
setBusy(true)
|
||
setError('')
|
||
setReport(null)
|
||
|
||
try {
|
||
const body =
|
||
tier === 'form'
|
||
? {
|
||
path,
|
||
version: file.version,
|
||
...(reload ? { reload } : {}),
|
||
// A number goes up as the TEXT that was typed. `2.50` stays
|
||
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
|
||
// number here is precisely the bug the server half exists to
|
||
// avoid, and it would be reintroduced in the browser.
|
||
edits: Object.values(edits).map(({ field, value }) =>
|
||
field.type === 'number'
|
||
? { pointer: field.pointer, raw: String(value) }
|
||
: { pointer: field.pointer, value },
|
||
),
|
||
}
|
||
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
|
||
|
||
const answer = await api.adminConfig.save(serverId, body)
|
||
|
||
if (answer.pending && answer.write && answer.write.id) {
|
||
setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] })
|
||
setWatching({ serverId, id: answer.write.id })
|
||
} else {
|
||
setReport(answer.report || null)
|
||
}
|
||
|
||
if (!answer.changed) setError('Nothing changed, so nothing was written.')
|
||
|
||
// Re-read either way: a successful reload usually rewrites the file with
|
||
// the defaults it was missing, and a rollback means what is on disk is no
|
||
// longer what is on the screen.
|
||
setFileNonce((n) => n + 1)
|
||
} catch (err) {
|
||
setError(err.message || 'That save did not work.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const pending = Object.keys(edits).length
|
||
|
||
return (
|
||
<div style={{ maxWidth: 980 }}>
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||
These are the configuration files on the game host itself, read live through the bridge. A
|
||
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
|
||
file back automatically</strong> if the plugin does not come back. The game’s data
|
||
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
|
||
never listed here.
|
||
</p>
|
||
|
||
<Card title="Server" subtitle={`${rows.length} configured`}>
|
||
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
|
||
<option value="">Choose a server…</option>
|
||
{rows.map((row) => (
|
||
<option key={row.id} value={row.id}>
|
||
{row.name || row.id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{tree && tree.root && (
|
||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||
{tree.root}
|
||
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
|
||
</p>
|
||
)}
|
||
</Card>
|
||
|
||
{serverId && treeError && <ErrorState error={treeError} />}
|
||
|
||
{serverId && !treeError && !tree && <Loading />}
|
||
|
||
{tree && (
|
||
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
|
||
{tree.plugins.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||
This server reports no configuration files.
|
||
</p>
|
||
)}
|
||
{tree.plugins.map((group) => (
|
||
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
|
||
{group.title || group.plugin}
|
||
</strong>
|
||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
|
||
{group.isBridge ? ' · this bridge' : ''}
|
||
</span>
|
||
</div>
|
||
{group.files.map((entry) => (
|
||
<div
|
||
key={entry.path}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
|
||
disabled={!entry.editable}
|
||
onClick={() => {
|
||
setPath(entry.path)
|
||
setReport(null)
|
||
setTier('form')
|
||
}}
|
||
>
|
||
{entry.path}
|
||
</button>
|
||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||
{Math.round(entry.bytes / 102.4) / 10} KB
|
||
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
|
||
{entry.reason ? ` · ${entry.reason}` : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{!group.loaded && (
|
||
<Warn>
|
||
Nothing on this server is loaded under that name, so a save here is written and
|
||
not reloaded. It applies the next time the plugin loads.
|
||
</Warn>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
{path && fileError && <ErrorState error={fileError} />}
|
||
{path && !fileError && !file && <Loading />}
|
||
|
||
{file && (
|
||
<Card
|
||
title={file.path}
|
||
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
|
||
actions={
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||
onClick={() => setTier('form')}
|
||
>
|
||
Settings
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||
onClick={() => setTier('raw')}
|
||
>
|
||
Raw JSON
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
{file.parseError && (
|
||
<Warn tone="#e05a5a">
|
||
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
|
||
draw a form from. Raw JSON is the tier that can fix it.
|
||
</Warn>
|
||
)}
|
||
|
||
{file.isBridge && (
|
||
<Warn>
|
||
This is the bridge’s own configuration. Its address, port and server id are read-only
|
||
here — changing any of them from the website would cut the link carrying the change,
|
||
or strand every row this site holds for this server. They are editable on the host
|
||
itself. This plugin also cannot be reloaded from here.
|
||
</Warn>
|
||
)}
|
||
|
||
{tier === 'form' && file.fields && (
|
||
<div style={{ marginTop: 6 }}>
|
||
{file.fields
|
||
.filter((field) => field.path !== '')
|
||
.map((field) => (
|
||
<Field
|
||
key={field.path}
|
||
field={field}
|
||
value={
|
||
edits[field.path]
|
||
? edits[field.path].value
|
||
: field.type === 'number'
|
||
? field.raw
|
||
: field.value
|
||
}
|
||
onChange={change}
|
||
revealed={Boolean(revealed[field.path])}
|
||
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{tier === 'raw' && (
|
||
<textarea
|
||
className="input"
|
||
spellCheck={false}
|
||
value={raw}
|
||
onChange={(event) => setRaw(event.target.value)}
|
||
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||
/>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
marginTop: 12,
|
||
borderTop: '1px solid var(--line-soft)',
|
||
paddingTop: 12,
|
||
}}
|
||
>
|
||
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||
Reload
|
||
</label>
|
||
{/* A guess, and it says so. The folder a config sits in is convention
|
||
rather than contract, so reloading it silently is how the wrong
|
||
plugin gets reloaded, reports success, and the edited one never
|
||
re-reads anything. */}
|
||
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
|
||
<option value="">nothing — just write the file</option>
|
||
{(tree ? tree.loaded : [])
|
||
.filter((p) => p.name !== tree.self)
|
||
.map((p) => (
|
||
<option key={p.name} value={p.name}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<span style={{ flex: 1 }} />
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={busy || Boolean(watching) || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||
onClick={save}
|
||
>
|
||
{busy ? 'Saving…' : watching ? 'Reloading…' : 'Save and reload'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Beside the button, not at the top of the page. A save is made at the
|
||
bottom of a long form, and a refusal rendered above the fold is a
|
||
click that visibly did nothing. */}
|
||
{error && (
|
||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<Report report={report} />
|
||
</Card>
|
||
)}
|
||
|
||
{serverId && <History serverId={serverId} nonce={fileNonce} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Who changed what, including the saves that were refused or undone. */
|
||
function History({ serverId, nonce }) {
|
||
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
|
||
|
||
if (!data || !data.writes || data.writes.length === 0) return null
|
||
|
||
return (
|
||
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
|
||
{data.writes.map((row) => (
|
||
<div
|
||
key={row.id}
|
||
className="sans"
|
||
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
|
||
>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
||
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
|
||
<span
|
||
className="sans"
|
||
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
|
||
>
|
||
{row.outcome}
|
||
{row.reloaded ? ' · reloaded' : ''}
|
||
</span>
|
||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||
{ago(row.createdAt)}
|
||
{row.tier === 'raw' ? ' · raw' : ''}
|
||
</span>
|
||
</div>
|
||
{(row.changes || []).map((change, index) => (
|
||
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
|
||
{change.path}
|
||
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
|
||
</div>
|
||
))}
|
||
{row.detail && (
|
||
<div className="dim" style={{ fontSize: '0.74rem' }}>
|
||
{row.detail}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Card>
|
||
)
|
||
}
|