From e7bc3168636bcb808a6b164d13239889f9b4637b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:22:17 -0500 Subject: [PATCH] Add admin shard control: config, status, town crier (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin/uoLink.controller.js: GET /admin/uo-link/config (masked config + live health + ingestion stats from the socket/broadcaster); PUT to save base/ws URL + write-only token + protocol + enabled, which (re)starts or stops the WS ingest client and activity-logs the change; POST/DELETE /uo-link/towncrier to publish/remove town-crier messages; GET /uo-link/stream (admin SSE channel, full feed incl. audit/cheat). Mounted adminOnly with express-validator guards + #swagger annotations (new "Admin · Shard" tag, TownCrierRequest schema). - server.js: startup probe (checkUoLink) that logs reachability and warns loudly on a protocol mismatch when the integration is enabled. - client: api.admin uo-link methods; ShardAdmin.jsx control panel (status panel with ingestion stats, config form, town crier) modeled on DiscordBotAdmin; wired into AdminLayout nav/titles + the /admin/shard route. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- client/src/App.jsx | 2 + client/src/api/client.js | 6 + client/src/routes/admin/AdminLayout.jsx | 3 + client/src/routes/admin/views/ShardAdmin.jsx | 213 ++++++++++ server/src/router/v1/admin/admin.routes.js | 72 ++++ .../src/router/v1/admin/uoLink.controller.js | 123 ++++++ server/src/server.js | 30 ++ server/swagger/swagger-output.json | 366 ++++++++++++++++++ server/swagger/swagger.js | 10 + 9 files changed, 825 insertions(+) create mode 100644 client/src/routes/admin/views/ShardAdmin.jsx create mode 100644 server/src/router/v1/admin/uoLink.controller.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 591df74..1560d18 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -34,6 +34,7 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' +import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -111,6 +112,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index b3021dd..8263791 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -223,6 +223,12 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), + // ----- uo-link sidecar control (admin only) ----- + getUoLinkConfig: () => req('/admin/uo-link/config'), + saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), + postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), + deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // ----- Email delivery / Gmail OAuth2 (admin only) ----- getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 9cf3c69..4c6358b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -37,6 +37,7 @@ const IconKey = () => const IconPulse = () => const IconUser = () => +const IconShard = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -72,6 +73,7 @@ const NAV = [ { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, + { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] }, { to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] }, ], }, @@ -95,6 +97,7 @@ const TITLES = { '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', + '/admin/shard': 'Shard (uo-link)', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx new file mode 100644 index 0000000..cc854aa --- /dev/null +++ b/client/src/routes/admin/views/ShardAdmin.jsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// 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 +// effect immediately with no redeploy. + +function Toggle({ checked, onChange, label }) { + return ( + + ) +} + +const STATUS_COLOR = { + connected: '#7fd0a4', + reconnecting: '#e0b070', + error: '#d98b84', + disconnected: 'var(--muted)', +} + +function StatusPanel({ config }) { + const color = STATUS_COLOR[config.status] || 'var(--muted)' + const ingest = config.ingest || {} + const health = config.health || {} + return ( +
+
+ + + {config.status || 'disconnected'} + +
+ {config.statusDetail && ( +

{config.statusDetail}

+ )} +
+ Shard link: {config.pluginConnected ? 'up' : 'down'} + WS ingest: {ingest.connected ? 'connected' : 'offline'} + Reconnects: {ingest.reconnects ?? 0} + SSE clients: {(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)} + {config.lastEventAt && Last event: {new Date(config.lastEventAt).toLocaleString()}} + {health.uptime && Sidecar uptime: {health.uptime}} +
+
+ ) +} + +// ── Town crier ────────────────────────────────────────────────────────────── +function TownCrier() { + const [id, setId] = useState('') + const [text, setText] = useState('') + const [durationSec, setDurationSec] = useState(3600) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function post() { + setBusy(true); setMsg(''); setError('') + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + if (!id.trim() || lines.length === 0) { + setBusy(false) + return setError('An id and at least one line are required.') + } + try { + await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined }) + setMsg(`Posted “${id.trim()}”.`) + } catch (err) { + setError(err.message || 'Could not post.') + } finally { + setBusy(false) + } + } + async function remove() { + if (!id.trim()) return setError('Enter the id to remove.') + setBusy(true); setMsg(''); setError('') + try { + await api.admin.deleteTownCrier(id.trim()) + setMsg(`Removed “${id.trim()}”.`) + } catch (err) { + setError(err.message || 'Could not remove.') + } finally { + setBusy(false) + } + } + + return ( +
+

Town crier

+

+ Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it. +

+ +