feat: admin terminal - show connected instance

scarlett
NGPixel 4 weeks ago
parent e393e720fd
commit 95587ae71b
No known key found for this signature in database

@ -5,9 +5,9 @@ import type { WebSocket } from 'ws'
* _terminal Routes * _terminal Routes
* *
* The websocket behind the admin area's terminal view, which streams this instance's log lines to a * The websocket behind the admin area's terminal view, which streams this instance's log lines to a
* browser as they are written. Read-only: nothing a client sends is looked at, and the socket carries * browser as they are written. Read-only: nothing a client sends is looked at, and every frame after
* exactly what `core/logger.ts` hands to `console.log`, formatted string and all including the ANSI * the first is exactly what `core/logger.ts` hands to `console.log`, formatted string and all
* colours, which xterm renders. * including the ANSI colours, which xterm renders. The first frame is the handshake below.
* *
* Only this instance's own main thread is on that stream. Worker threads build their own logger * Only this instance's own main thread is on that stream. Worker threads build their own logger
* (`worker.ts`), and other instances write to their own consoles, so a clustered deployment shows the * (`worker.ts`), and other instances write to their own consoles, so a clustered deployment shows the
@ -43,6 +43,15 @@ async function routes(app: FastifyInstance) {
return socket.close(4403, 'You are not allowed to read the server logs') return socket.close(4403, 'You are not allowed to read the server logs')
} }
const user = req.session.user?.email ?? req.session.user?.id ?? 'unknown'
/*
Logged before the listener is attached, so the line is already in the backlog by the time it
is replayed below and the terminal opens on its own arrival. Every other connected terminal
sees it live, which is the point: who is reading the logs is itself worth logging.
*/
WIKI.logger.info(`Streaming server logs to user ${user}... [ CONNECTED ]`)
const send = (line: string) => { const send = (line: string) => {
if (socket.readyState !== socket.OPEN || socket.bufferedAmount > MAX_BUFFERED) { if (socket.readyState !== socket.OPEN || socket.bufferedAmount > MAX_BUFFERED) {
return return
@ -50,6 +59,14 @@ async function routes(app: FastifyInstance) {
socket.send(line) socket.send(line)
} }
/*
The handshake, and the only frame that is not a log line: which instance the client ended up
talking to, since in a cluster that is the one thing the logs themselves cannot tell it a
line names the instance that WROTE it, and the backlog replayed below was written by this one.
Sent before anything else, so "the first frame" is all the client has to know to find it.
*/
socket.send(JSON.stringify({ instance: WIKI.INSTANCE_ID }))
// -> A terminal that opens onto an idle server would otherwise sit empty and look broken // -> A terminal that opens onto an idle server would otherwise sit empty and look broken
for (const line of WIKI.logger.backlog()) { for (const line of WIKI.logger.backlog()) {
send(line) send(line)
@ -57,7 +74,12 @@ async function routes(app: FastifyInstance) {
WIKI.logger.ws.on('log', send) WIKI.logger.ws.on('log', send)
socket.on('close', () => { socket.on('close', () => {
// -> Off the stream first, so this instance's own goodbye is not sent down a socket that is
// already closing
WIKI.logger.ws.off('log', send) WIKI.logger.ws.off('log', send)
WIKI.logger.info(
`User ${user} has disconnected from server logs streaming. [ DISCONNECTED ]`
)
}) })
} }
) )

@ -1026,8 +1026,9 @@
"admin.terminal.connecting": "Connecting to server...", "admin.terminal.connecting": "Connecting to server...",
"admin.terminal.disconnect": "Disconnect", "admin.terminal.disconnect": "Disconnect",
"admin.terminal.disconnected": "Disconnected.", "admin.terminal.disconnected": "Disconnected.",
"admin.terminal.instance": "Connected to instance",
"admin.terminal.logs": "Logs", "admin.terminal.logs": "Logs",
"admin.terminal.subtitle": "View process logs in real-time", "admin.terminal.subtitle": "View server logs in real-time",
"admin.terminal.title": "Terminal", "admin.terminal.title": "Terminal",
"admin.theme.accentColor": "Accent Color", "admin.theme.accentColor": "Accent Color",
"admin.theme.accentColorHint": "The accent color for elements that need to stand out or grab the user attention.", "admin.theme.accentColorHint": "The accent color for elements that need to stand out or grab the user attention.",

@ -12,7 +12,14 @@
{{ t('admin.terminal.subtitle') }} {{ t('admin.terminal.subtitle') }}
</div> </div>
</div> </div>
<div class="flex-none flex"> <div class="flex-none flex items-center">
<div v-if="state.connected" class="mr-4 text-right leading-tight">
<div class="text-xs text-grey">{{ t('admin.terminal.instance') }}</div>
<div class="flex items-center justify-end gap-1.5 font-mono text-sm">
<status-light class="admin-terminal-dot" color="positive" pulse />
{{ state.instance }}
</div>
</div>
<w-btn <w-btn
class="acrylic-btn mr-2" class="acrylic-btn mr-2"
v-if="!state.connected || state.connecting" v-if="!state.connected || state.connecting"
@ -89,7 +96,9 @@ useMeta({
const state = reactive({ const state = reactive({
displayMode: 'logs', displayMode: 'logs',
connected: false, connected: false,
connecting: false connecting: false,
/** Which instance is on the other end of the socket, from its handshake frame. */
instance: null
}) })
let socket = null let socket = null
@ -120,6 +129,7 @@ function connect() {
// -> Whether the stream ever started is what tells a session that was refused or never reached the // -> Whether the stream ever started is what tells a session that was refused or never reached the
// server apart from one that ran and ended, and only `close` is guaranteed to fire // server apart from one that ran and ended, and only `close` is guaranteed to fire
let opened = false let opened = false
let handshake = false
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
opened = true opened = true
@ -129,6 +139,15 @@ function connect() {
}) })
socket.addEventListener('message', (ev) => { socket.addEventListener('message', (ev) => {
/*
The server's first frame is the handshake and says which instance answered; everything after it
is a log line to be printed verbatim. See `controllers/terminal.ts`.
*/
if (!handshake) {
handshake = true
state.instance = JSON.parse(ev.data).instance
return
}
term.writeln(ev.data) term.writeln(ev.data)
}) })
@ -136,6 +155,7 @@ function connect() {
socket = null socket = null
state.connected = false state.connected = false
state.connecting = false state.connecting = false
state.instance = null
/* /*
Codes in the 4000 range are the server's own (see `controllers/terminal.ts`) and mean the Codes in the 4000 range are the server's own (see `controllers/terminal.ts`) and mean the
session was refused rather than dropped, so the reason is worth printing reconnecting with the session was refused rather than dropped, so the reason is worth printing reconnecting with the
@ -194,6 +214,14 @@ onBeforeUnmount(() => {
<style lang="scss"> <style lang="scss">
.admin-terminal { .admin-terminal {
/* -> `status-light` is a bar sized by whatever it sits in; here it wants to be a dot */
&-dot {
width: 6px;
height: 6px;
min-height: 6px;
flex: none;
}
&-term { &-term {
width: 100%; width: 100%;
/* -> The terminal fits itself to this box, so the box has to have a height of its own: sized off /* -> The terminal fits itself to this box, so the box has to have a height of its own: sized off

Loading…
Cancel
Save