Add shard activity feed + admin live feed; fix public-feed leak
Front ends for the rest of the sidecar data, plus a security fix the live data surfaced. - lib/shardEvents.js: shared describe()/category/label for every event kind (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds). - Public /site/shard/activity (ShardActivity): the full event log with category filter tabs and a live tail (history + SSE merged, de-duped). Linked from the Shard page. Shard page now reuses the shared describe(). - Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to the admin SSE channel — shows every kind incl. audit/cheat/login attempts. useShardFeed generalized to take a stream url; api.adminShardStreamUrl added. Security fix: GET /public/shard/feed now restricts to the public-safe kind allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned whatever was logged — including audit.* / cheat.* / link.request. Those are still stored for the admin channel but never served publicly (verified: a public request for audit.command returns 0 rows). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -18,6 +18,7 @@ import About from './routes/public/About.jsx'
|
|||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
import Shard from './routes/public/Shard.jsx'
|
import Shard from './routes/public/Shard.jsx'
|
||||||
import ShardChar from './routes/public/ShardChar.jsx'
|
import ShardChar from './routes/public/ShardChar.jsx'
|
||||||
|
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -75,6 +76,7 @@ export default function App() {
|
|||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
<Route path="/site/shard" element={<Shard />} />
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
|
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||||
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
|
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ export const api = {
|
|||||||
idoc: () => req('/public/shard/idoc'),
|
idoc: () => req('/public/shard/idoc'),
|
||||||
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
||||||
},
|
},
|
||||||
// Full path (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||||
// fetch-only, so SSE subscribers build the URL from here.
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||||
|
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||||
shardStreamUrl: `${BASE}/public/shard/stream`,
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||||
|
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
||||||
|
|
||||||
// ----- admin -----
|
// ----- admin -----
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
86
client/src/lib/shardEvents.js
Normal file
86
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Shared formatting for shard events — used by the public Shard page, the
|
||||||
|
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||||
|
// and which category/badge it belongs to.
|
||||||
|
|
||||||
|
function nameOf(who) {
|
||||||
|
if (!who) return 'Someone'
|
||||||
|
if (typeof who === 'string') return who
|
||||||
|
return who.name || who.acct || 'Someone'
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = (v) => Number(v || 0).toLocaleString()
|
||||||
|
|
||||||
|
// A one-line human description of an event. Accepts either a stored event
|
||||||
|
// (with .payload) or a raw live frame (fields at top level).
|
||||||
|
export function describe(ev) {
|
||||||
|
const p = ev.payload || ev
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'vendor.sale':
|
||||||
|
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
|
||||||
|
case 'player.death':
|
||||||
|
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||||
|
case 'player.murdered':
|
||||||
|
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||||
|
case 'mob.killed':
|
||||||
|
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||||
|
case 'skill.gain':
|
||||||
|
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
|
||||||
|
case 'fame.change':
|
||||||
|
return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
|
||||||
|
case 'karma.change':
|
||||||
|
return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
|
||||||
|
case 'quest.complete':
|
||||||
|
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||||
|
case 'house.decay':
|
||||||
|
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
|
||||||
|
case 'mob.login':
|
||||||
|
return `${nameOf(p.who)} entered the world`
|
||||||
|
case 'mob.logout':
|
||||||
|
return `${nameOf(p.who)} left the world`
|
||||||
|
case 'economy.supply':
|
||||||
|
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
|
||||||
|
case 'server.hello':
|
||||||
|
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
|
||||||
|
case 'server.shutdown':
|
||||||
|
return 'Shard shut down'
|
||||||
|
case 'server.crashed':
|
||||||
|
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||||
|
// Staff / sensitive (admin channel only)
|
||||||
|
case 'audit.set':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
||||||
|
case 'audit.command':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
||||||
|
case 'cheat.fastwalk':
|
||||||
|
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
||||||
|
case 'account.login.attempt':
|
||||||
|
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
||||||
|
case 'gold.change':
|
||||||
|
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
||||||
|
default:
|
||||||
|
return ev.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category grouping for the filter tabs.
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'All', kinds: null },
|
||||||
|
{ id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
|
||||||
|
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||||
|
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||||
|
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORY_OF = (() => {
|
||||||
|
const m = {}
|
||||||
|
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||||
|
return m
|
||||||
|
})()
|
||||||
|
|
||||||
|
export function categoryOf(kind) {
|
||||||
|
return CATEGORY_OF[kind] || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||||
|
export function kindLabel(kind) {
|
||||||
|
return String(kind || '').replace(/[._]/g, ' ')
|
||||||
|
}
|
||||||
@@ -10,19 +10,20 @@ import { api } from '../api/client.js'
|
|||||||
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||||
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||||
// buffer length.
|
// buffer length.
|
||||||
export function useShardFeed({ filter, max = 40 } = {}) {
|
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||||
const [events, setEvents] = useState([])
|
const [events, setEvents] = useState([])
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||||
const filterRef = useRef(filter)
|
const filterRef = useRef(filter)
|
||||||
filterRef.current = filter
|
filterRef.current = filter
|
||||||
|
const streamUrl = url || api.shardStreamUrl
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// EventSource isn't available during SSR / very old browsers — degrade to
|
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||||
// "no live feed" rather than throwing.
|
// "no live feed" rather than throwing.
|
||||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||||
|
|
||||||
const es = new EventSource(api.shardStreamUrl, { withCredentials: true })
|
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||||
|
|
||||||
es.onopen = () => setConnected(true)
|
es.onopen = () => setConnected(true)
|
||||||
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||||
@@ -47,7 +48,7 @@ export function useShardFeed({ filter, max = 40 } = {}) {
|
|||||||
|
|
||||||
return () => es.close()
|
return () => es.close()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [max])
|
}, [max, streamUrl])
|
||||||
|
|
||||||
return { events, connected }
|
return { events, connected }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,40 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||||
|
import { describe, kindLabel } from '../../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||||
|
// cheat detection and login attempts that the public channel never carries.
|
||||||
|
function AdminLiveFeed() {
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// uo-link sidecar control panel. The auth token is write-only over this API —
|
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||||
// stored encrypted, never returned — same convention as the Discord bot token.
|
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||||
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||||
@@ -208,6 +241,8 @@ export default function ShardAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TownCrier />
|
<TownCrier />
|
||||||
|
|
||||||
|
<AdminLiveFeed />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PageHeader from '../../components/PageHeader.jsx'
|
|||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
import { ago } from '../../lib/format.js'
|
import { ago } from '../../lib/format.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
@@ -42,38 +43,6 @@ function Stat({ value, label }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A one-line human description of a feed event.
|
|
||||||
function describe(ev) {
|
|
||||||
const p = ev.payload || ev
|
|
||||||
switch (ev.kind) {
|
|
||||||
case 'vendor.sale':
|
|
||||||
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp`
|
|
||||||
case 'player.death':
|
|
||||||
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
|
||||||
case 'player.murdered':
|
|
||||||
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
|
||||||
case 'mob.killed':
|
|
||||||
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
|
||||||
case 'house.decay':
|
|
||||||
return `${p.name || 'A house'} is now ${p.to || p.stage}`
|
|
||||||
case 'quest.complete':
|
|
||||||
return `${nameOf(p.who)} completed “${p.quest}”`
|
|
||||||
case 'skill.gain':
|
|
||||||
return `${nameOf(p.who)} gained ${p.skill}`
|
|
||||||
case 'mob.login':
|
|
||||||
return `${nameOf(p.who)} entered the world`
|
|
||||||
case 'mob.logout':
|
|
||||||
return `${nameOf(p.who)} left the world`
|
|
||||||
default:
|
|
||||||
return ev.kind
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function nameOf(who) {
|
|
||||||
if (!who) return 'Someone'
|
|
||||||
if (typeof who === 'string') return who
|
|
||||||
return who.name || who.acct || 'Someone'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Shard() {
|
export default function Shard() {
|
||||||
const { loading, error, data } = useAsync(() =>
|
const { loading, error, data } = useAsync(() =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -206,10 +175,15 @@ export default function Shard() {
|
|||||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
Live feed
|
Live feed
|
||||||
</div>
|
</div>
|
||||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||||
{connected ? 'Live' : 'Offline'}
|
View all activity →
|
||||||
</span>
|
</Link>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
|||||||
81
client/src/routes/public/ShardActivity.jsx
Normal file
81
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Public activity feed: the full shard event log, filterable by category, with a
|
||||||
|
// live tail that prepends new events as they happen.
|
||||||
|
export default function ShardActivity() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||||
|
const { events: live } = useShardFeed({ max: 60 })
|
||||||
|
const [cat, setCat] = useState('all')
|
||||||
|
|
||||||
|
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||||
|
const merged = useMemo(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const e of [...live, ...(data || [])]) {
|
||||||
|
const key = `${e.kind}-${e.t}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(e)
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||||
|
}, [live, data])
|
||||||
|
|
||||||
|
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||||
|
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||||
|
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
className="pill"
|
||||||
|
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
filtered.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||||
|
{kindLabel(e.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,8 +12,18 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
|||||||
return res.affectedRows > 0
|
return res.affectedRows > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent events, newest first. Optional kind filter; limit is clamped by the model.
|
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
||||||
async function list({ kind, limit }) {
|
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||||
|
// staff/sensitive kinds. limit is clamped by the model.
|
||||||
|
async function list({ kind, kinds, limit }) {
|
||||||
|
if (kinds && kinds.length) {
|
||||||
|
const placeholders = kinds.map(() => '?').join(', ')
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
||||||
|
[...kinds, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
if (kind) {
|
if (kind) {
|
||||||
return query(
|
return query(
|
||||||
`SELECT id, kind, t, boot_id, payload, created_at
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ function normalizeLimit(limit) {
|
|||||||
return Math.min(Math.floor(n), MAX_LIMIT)
|
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent events, newest first. Each row's JSON payload is parsed back to an object.
|
// Recent events, newest first. Each row's JSON payload is parsed back to an
|
||||||
async function list({ kind, limit } = {}) {
|
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
|
||||||
const rows = await db.list({ kind, limit: normalizeLimit(limit) })
|
async function list({ kind, kinds, limit } = {}) {
|
||||||
|
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
|
|||||||
@@ -47,11 +47,20 @@ async function getStatus(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log.
|
// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
|
||||||
|
// restricted to the public-safe allowlist so staff audit / cheat / link events
|
||||||
|
// (which are stored for the admin channel) can never leak to the public.
|
||||||
async function getFeed(req, res) {
|
async function getFeed(req, res) {
|
||||||
try {
|
try {
|
||||||
const { kind, limit } = req.query
|
const { kind, limit } = req.query
|
||||||
const events = await shardEvents.list({ kind, limit })
|
let events
|
||||||
|
if (kind) {
|
||||||
|
// A specific kind is only served if it is itself public-safe.
|
||||||
|
if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
|
||||||
|
events = await shardEvents.list({ kind, limit })
|
||||||
|
} else {
|
||||||
|
events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit })
|
||||||
|
}
|
||||||
return res.json(events)
|
return res.json(events)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getFeed', err)
|
log.error('shard.getFeed', err)
|
||||||
|
|||||||
Reference in New Issue
Block a user