diff --git a/client/src/App.jsx b/client/src/App.jsx
index 5963be8..e7e98bf 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -18,6 +18,7 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardChar from './routes/public/ShardChar.jsx'
+import ShardActivity from './routes/public/ShardActivity.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -75,6 +76,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 34f9409..b2599c2 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -96,9 +96,11 @@ export const api = {
idoc: () => req('/public/shard/idoc'),
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},
- // Full path (incl. /api/v1) for the browser EventSource — the req() wrapper is
- // fetch-only, so SSE subscribers build the URL from here.
+ // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
+ // 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`,
+ adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
// ----- admin -----
admin: {
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
new file mode 100644
index 0000000..45a4305
--- /dev/null
+++ b/client/src/lib/shardEvents.js
@@ -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, ' ')
+}
diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js
index d4069e8..60e9558 100644
--- a/client/src/lib/useShardFeed.js
+++ b/client/src/lib/useShardFeed.js
@@ -10,19 +10,20 @@ import { api } from '../api/client.js'
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
// Set of kinds, optional) limits which events are buffered. `max` caps the
// buffer length.
-export function useShardFeed({ filter, max = 40 } = {}) {
+export function useShardFeed({ url, filter, max = 40 } = {}) {
const [events, setEvents] = useState([])
const [connected, setConnected] = useState(false)
// Keep the latest filter in a ref so re-renders don't tear down the stream.
const filterRef = useRef(filter)
filterRef.current = filter
+ const streamUrl = url || api.shardStreamUrl
useEffect(() => {
// EventSource isn't available during SSR / very old browsers — degrade to
// "no live feed" rather than throwing.
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.onerror = () => setConnected(false) // EventSource will retry on its own
@@ -47,7 +48,7 @@ export function useShardFeed({ filter, max = 40 } = {}) {
return () => es.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [max])
+ }, [max, streamUrl])
return { events, connected }
}
diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx
index cc854aa..e59a405 100644
--- a/client/src/routes/admin/views/ShardAdmin.jsx
+++ b/client/src/routes/admin/views/ShardAdmin.jsx
@@ -1,7 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react'
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'
+// 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 (
+
+
+
Live feed (all events)
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+ {events.length === 0 ? (
+ Waiting for shard events…
+ ) : (
+
+ {events.map((e) => (
+ -
+ {kindLabel(e.kind)}
+ {describe(e)}
+ {ago(e.t)}
+
+ ))}
+
+ )}
+
+ )
+}
+
// 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.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
@@ -208,6 +241,8 @@ export default function ShardAdmin() {
+
+
)
}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index 9917524..304765d 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -4,6 +4,7 @@ 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 } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.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() {
const { loading, error, data } = useAsync(() =>
Promise.all([
@@ -206,10 +175,15 @@ export default function Shard() {
Live feed
-
-
- {connected ? 'Live' : 'Offline'}
-
+
+
+ View all activity →
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
{events.length === 0 ? (
diff --git a/client/src/routes/public/ShardActivity.jsx b/client/src/routes/public/ShardActivity.jsx
new file mode 100644
index 0000000..3068d08
--- /dev/null
+++ b/client/src/routes/public/ShardActivity.jsx
@@ -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 (
+
+
+
+
+ ← Back to shard
+
+
+ {/* Category tabs */}
+
+ {CATEGORIES.map((c) => (
+
+ ))}
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ filtered.length === 0 ? (
+
+
Nothing here yet — events will appear as they happen in the world.
+
+ ) : (
+
+ {filtered.map((e) => (
+ -
+
+ {kindLabel(e.kind)}
+
+ {describe(e)}
+ {ago(e.t)}
+
+ ))}
+
+ )
+ )}
+
+
+ )
+}
diff --git a/server/src/model/shardEvents/shardEvents.db.js b/server/src/model/shardEvents/shardEvents.db.js
index 792672a..6c8f864 100644
--- a/server/src/model/shardEvents/shardEvents.db.js
+++ b/server/src/model/shardEvents/shardEvents.db.js
@@ -12,8 +12,18 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
return res.affectedRows > 0
}
-// Recent events, newest first. Optional kind filter; limit is clamped by the model.
-async function list({ kind, limit }) {
+// Recent events, newest first. Filter by a single `kind`, or an allowlist of
+// `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) {
return query(
`SELECT id, kind, t, boot_id, payload, created_at
diff --git a/server/src/model/shardEvents/shardEvents.model.js b/server/src/model/shardEvents/shardEvents.model.js
index 27818a0..1518204 100644
--- a/server/src/model/shardEvents/shardEvents.model.js
+++ b/server/src/model/shardEvents/shardEvents.model.js
@@ -35,9 +35,10 @@ function normalizeLimit(limit) {
return Math.min(Math.floor(n), MAX_LIMIT)
}
-// Recent events, newest first. Each row's JSON payload is parsed back to an object.
-async function list({ kind, limit } = {}) {
- const rows = await db.list({ kind, limit: normalizeLimit(limit) })
+// Recent events, newest first. Each row's JSON payload is parsed back to an
+// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
+async function list({ kind, kinds, limit } = {}) {
+ const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
return rows.map((row) => ({
id: row.id,
kind: row.kind,
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 14e72d7..28be23d 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -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) {
try {
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)
} catch (err) {
log.error('shard.getFeed', err)