forked from snxraven/peardock
Production-grade xterm FitAddon resize, binary-safe I/O, TTY-safe server streams, demuxed log viewer with line buffering, and auto-dismiss deploy job panel without the full-screen spinner.
170 lines
4.5 KiB
JavaScript
170 lines
4.5 KiB
JavaScript
/**
|
|
* Container log streaming over protomux-rpc pushes.
|
|
* Demuxes Docker multiplexed frames server-side so clients receive plain UTF-8.
|
|
*/
|
|
import { docker } from '../services/docker.js'
|
|
import { Pushes } from '../../shared/protocol.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
/**
|
|
* Incremental demux of docker log multiplex protocol.
|
|
*/
|
|
function createServerDemuxer() {
|
|
let pending = Buffer.alloc(0)
|
|
|
|
return {
|
|
/**
|
|
* @param {Buffer} chunk
|
|
* @returns {Buffer[]} payloads
|
|
*/
|
|
push(chunk) {
|
|
pending = Buffer.concat([pending, chunk])
|
|
const out = []
|
|
|
|
// Non-muxed (raw text) — if first byte isn't 0-2 and we have data
|
|
if (pending.length && pending[0] > 2) {
|
|
out.push(pending)
|
|
pending = Buffer.alloc(0)
|
|
return out
|
|
}
|
|
|
|
while (pending.length >= 8) {
|
|
const size = pending.readUInt32BE(4)
|
|
if (pending.length < 8 + size) break
|
|
out.push(pending.subarray(8, 8 + size))
|
|
pending = pending.subarray(8 + size)
|
|
}
|
|
return out
|
|
},
|
|
flush() {
|
|
if (!pending.length) return []
|
|
const rest = pending
|
|
pending = Buffer.alloc(0)
|
|
return [rest]
|
|
},
|
|
}
|
|
}
|
|
|
|
async function startLogStream(session, streams, args) {
|
|
const containerId = args.id || args.containerId
|
|
if (!containerId) throw new Error('container id required')
|
|
|
|
if (streams.has(containerId)) {
|
|
try {
|
|
streams.get(containerId).stream?.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
streams.delete(containerId)
|
|
}
|
|
|
|
const logOpts = {
|
|
stdout: args.stdout !== false,
|
|
stderr: args.stderr !== false,
|
|
tail: args.tail ?? 200,
|
|
follow: args.follow !== false,
|
|
timestamps: args.timestamps !== false,
|
|
}
|
|
if (args.since != null) logOpts.since = args.since
|
|
if (args.until != null) logOpts.until = args.until
|
|
|
|
const logsStream = await docker.getContainer(containerId).logs(logOpts)
|
|
const demux = createServerDemuxer()
|
|
|
|
streams.set(containerId, { stream: logsStream, demux })
|
|
|
|
logsStream.on('data', (chunk) => {
|
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
const payloads = demux.push(buf)
|
|
for (const payload of payloads) {
|
|
if (!payload.length) continue
|
|
try {
|
|
session.push(Pushes.logs, {
|
|
type: 'logs',
|
|
containerId,
|
|
data: payload.toString('base64'),
|
|
encoding: 'base64',
|
|
format: 'utf8-demuxed',
|
|
})
|
|
} catch (e) {
|
|
logger.debug('log push failed', { error: e.message })
|
|
}
|
|
}
|
|
})
|
|
logsStream.on('end', () => {
|
|
for (const payload of demux.flush()) {
|
|
if (!payload.length) continue
|
|
try {
|
|
session.push(Pushes.logs, {
|
|
type: 'logs',
|
|
containerId,
|
|
data: payload.toString('base64'),
|
|
encoding: 'base64',
|
|
format: 'utf8-demuxed',
|
|
})
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
streams.delete(containerId)
|
|
})
|
|
logsStream.on('error', (err) => {
|
|
logger.error('Log stream error', { containerId, error: err.message })
|
|
session.push(Pushes.error, {
|
|
error: `Log stream error: ${err.message}`,
|
|
containerId,
|
|
})
|
|
streams.delete(containerId)
|
|
})
|
|
|
|
return { success: true, message: `Log stream started for ${containerId}` }
|
|
}
|
|
|
|
export function registerLogsHandlers(session) {
|
|
/** @type {Map<string, { stream: import('stream').Readable, demux: ReturnType<typeof createServerDemuxer> }>} */
|
|
const streams = new Map()
|
|
session.state.set('logsStreams', streams)
|
|
|
|
session._cleanupLogsForContainer = (containerId) => {
|
|
for (const [key, entry] of streams.entries()) {
|
|
if (key === containerId) {
|
|
try {
|
|
entry.stream?.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
streams.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
session.respond('startLogs', (args) => startLogStream(session, streams, args))
|
|
session.respond('logs', (args) => startLogStream(session, streams, args))
|
|
|
|
session.respond('stopLogs', async (args) => {
|
|
const containerId = args.id || args.containerId
|
|
if (containerId && streams.has(containerId)) {
|
|
try {
|
|
streams.get(containerId).stream?.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
streams.delete(containerId)
|
|
}
|
|
return { success: true }
|
|
})
|
|
}
|
|
|
|
export function cleanupLogsOnClose(session) {
|
|
const streams = session.state.get('logsStreams')
|
|
if (!streams) return
|
|
for (const entry of streams.values()) {
|
|
try {
|
|
entry.stream?.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
streams.clear()
|
|
}
|