Files
Modrinth-plus/packages/ui/src/composables/server-console.ts
Calum H. 620894aecb feat: backups page cleanup before worlds (#5844)
* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
2026-04-27 19:03:48 +00:00

419 lines
11 KiB
TypeScript

import { createGlobalState } from '@vueuse/core'
import { type Ref, ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
// Flip to true during development to enable console perf logging.
// Uses a plain constant to avoid turbo env-var declarations.
const DEBUG_PERF = false
// TODO: for true unbounded history, consider IndexedDB or similar
const ARCHIVE_CAPACITY = 500_000
const ENTRY_START_RE = /^\[\d{2}:\d{2}:\d{2}\]/
/**
* Reorders a batch of log lines so that continuation lines (lines without a
* timestamp prefix) stay grouped with their parent error/warn entry, even when
* unrelated timestamped lines arrive between them from the server.
*/
function groupContinuations(lines: LogLine[]): LogLine[] {
if (lines.length <= 1) return lines
const groups: LogLine[][] = []
for (const line of lines) {
if (ENTRY_START_RE.test(line.text)) {
groups.push([line])
} else if (groups.length > 0) {
let target = groups.length - 1
const lastEntry = groups[target][0]
if (lastEntry.level !== 'error' && lastEntry.level !== 'warn') {
if (line.level === 'error' || line.level === null) {
for (let i = groups.length - 2; i >= 0; i--) {
if (groups[i][0].level === 'error' || groups[i][0].level === 'warn') {
target = i
break
}
}
}
}
groups[target].push(line)
} else {
groups.push([line])
}
}
return groups.flat()
}
const batchTimeout = 300
const initialBatchSize = 256
const initialHydrationQuietMs = 700
const initialHydrationMaxMs = 2000
const LogLevelCode = {
None: 0,
Trace: 1,
Debug: 2,
Info: 3,
Warn: 4,
Error: 5,
} as const
type LogLevelCode = (typeof LogLevelCode)[keyof typeof LogLevelCode]
function encodeLevel(level: LogLevel | null): LogLevelCode {
if (!level) return LogLevelCode.None
switch (level) {
case 'trace':
return LogLevelCode.Trace
case 'debug':
return LogLevelCode.Debug
case 'info':
return LogLevelCode.Info
case 'warn':
return LogLevelCode.Warn
case 'error':
return LogLevelCode.Error
}
}
function decodeLevel(code: LogLevelCode): LogLevel | null {
switch (code) {
case LogLevelCode.Trace:
return 'trace'
case LogLevelCode.Debug:
return 'debug'
case LogLevelCode.Info:
return 'info'
case LogLevelCode.Warn:
return 'warn'
case LogLevelCode.Error:
return 'error'
default:
return null
}
}
// Columnar ring buffer: stores text and level in parallel arrays instead of
// LogLine objects, eliminating ~40 bytes of object header per line (~20MB
// saved at 500k lines). Lines are stored by value — get(i) returns a fresh
// LogLine each call, so consumers must not rely on reference identity.
class ColumnarRingBuffer {
texts: (string | undefined)[]
levels: Uint8Array
private head = 0
private _size = 0
constructor(readonly capacity: number) {
this.texts = new Array(capacity)
this.levels = new Uint8Array(capacity)
}
get size(): number {
return this._size
}
push(text: string, level: LogLevel | null): boolean {
const wrapped = this._size === this.capacity
this.texts[this.head] = text
this.levels[this.head] = encodeLevel(level)
this.head = (this.head + 1) % this.capacity
if (!wrapped) this._size++
return wrapped
}
get(index: number): LogLine {
if (index < 0 || index >= this._size) {
throw new RangeError(`Index ${index} out of bounds [0, ${this._size})`)
}
const start = this._size === this.capacity ? this.head : 0
const physical = (start + index) % this.capacity
return {
text: this.texts[physical] as string,
level: decodeLevel(this.levels[physical] as LogLevelCode),
}
}
toArray(): LogLine[] {
if (this._size === 0) return []
const start = this._size === this.capacity ? this.head : 0
const result = new Array<LogLine>(this._size)
for (let i = 0; i < this._size; i++) {
const physical = (start + i) % this.capacity
result[i] = {
text: this.texts[physical] as string,
level: decodeLevel(this.levels[physical] as LogLevelCode),
}
}
return result
}
clear(): void {
this.texts = new Array(this.capacity)
this.levels = new Uint8Array(this.capacity)
this.head = 0
this._size = 0
}
}
function mapLog4jLevel(level?: string): LogLevel | null {
if (!level) return null
switch (level.toUpperCase()) {
case 'FATAL':
case 'ERROR':
return 'error'
case 'WARN':
return 'warn'
case 'INFO':
return 'info'
case 'DEBUG':
return 'debug'
case 'TRACE':
return 'trace'
default:
return null
}
}
function formatTimestamp(millis?: number): string {
if (!millis) return ''
const date = new Date(millis)
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `[${h}:${m}:${s}]`
}
function formatLog4jLines(event: Log4jEvent): LogLine[] {
const level = mapLog4jLevel(event.level)
const time = formatTimestamp(event.timestamp_millis)
const thread = event.thread_name ?? ''
const levelStr = event.level ?? ''
const message = event.message?.trim() ?? ''
const prefix = time ? `${time} [${thread}/${levelStr}]: ` : `[${thread}/${levelStr}]: `
const messageLines = message.split(/[\r\n]+/)
const lines: LogLine[] = [{ text: prefix + messageLines[0], level }]
for (let i = 1; i < messageLines.length; i++) {
if (!messageLines[i]) continue
lines.push({ text: messageLines[i], level })
}
if (event.throwable) {
for (const line of event.throwable.split(/[\r\n]+/)) {
if (!line) continue
lines.push({ text: line, level: 'error' })
}
}
return lines
}
function textToLogLine(text: string): LogLine {
return { text, level: detectLogLevel(text) }
}
export function createConsoleState() {
const archive = new ColumnarRingBuffer(ARCHIVE_CAPACITY)
const output: Ref<LogLine[]> = shallowRef<LogLine[]>([])
const WS_EVENT_HISTORY_MAX = 25000
const wsEventHistory: unknown[] = []
let wsEventCaptureEnabled = false
let lineBuffer: LogLine[] = []
let batchTimer: NodeJS.Timeout | null = null
let initialHydrationQuietTimer: ReturnType<typeof setTimeout> | null = null
let initialHydrationMaxTimer: ReturnType<typeof setTimeout> | null = null
const isInitialLogHydrating = ref(false)
let wrapCount = 0
let lastFlushMs = 0
const clearInitialHydrationTimers = (): void => {
if (initialHydrationQuietTimer) {
clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = null
}
if (initialHydrationMaxTimer) {
clearTimeout(initialHydrationMaxTimer)
initialHydrationMaxTimer = null
}
}
const settleInitialLogHydration = (): void => {
if (!isInitialLogHydrating.value) return
clearInitialHydrationTimers()
flushBuffer()
isInitialLogHydrating.value = false
}
const armInitialHydrationQuietTimer = (): void => {
if (initialHydrationQuietTimer) clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = setTimeout(settleInitialLogHydration, initialHydrationQuietMs)
}
const beginInitialLogHydration = (): void => {
clearInitialHydrationTimers()
isInitialLogHydrating.value = true
armInitialHydrationQuietTimer()
initialHydrationMaxTimer = setTimeout(settleInitialLogHydration, initialHydrationMaxMs)
}
const flushBuffer = (): void => {
if (lineBuffer.length === 0) return
const t0 = DEBUG_PERF ? performance.now() : 0
const arr = output.value
const lines = groupContinuations(lineBuffer)
const flushedCount = lines.length
let didWrap = false
for (const line of lines) {
if (archive.push(line.text, line.level)) didWrap = true
arr.push(line)
}
if (didWrap) {
const evictedCount = Math.max(0, arr.length - archive.size)
if (evictedCount > 0) {
arr.splice(0, evictedCount)
}
wrapCount++
}
lineBuffer = []
batchTimer = null
triggerRef(output)
if (DEBUG_PERF) {
lastFlushMs = performance.now() - t0
if (arr.length !== archive.size) {
console.error(
`[mr-console] drift: output.length=${arr.length} !== archive.size=${archive.size}`,
)
}
console.debug(
`[mr-console] flush: ${flushedCount} lines in ${lastFlushMs.toFixed(2)}ms` +
` | buffer: ${archive.size} | wrap: ${didWrap}`,
)
}
}
const addLines = (lines: LogLine[]): void => {
if (isInitialLogHydrating.value) {
lineBuffer.push(...lines)
armInitialHydrationQuietTimer()
return
}
if (output.value.length === 0 && lines.length >= initialBatchSize) {
lineBuffer = lines
flushBuffer()
return
}
lineBuffer.push(...lines)
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout)
}
}
const addLog4jEvent = (event: Log4jEvent): void => {
addLines(formatLog4jLines(event))
}
const recordWsEvent = (event: unknown): void => {
if (!wsEventCaptureEnabled) return
wsEventHistory.push(event)
if (wsEventHistory.length > WS_EVENT_HISTORY_MAX) {
wsEventHistory.splice(0, wsEventHistory.length - WS_EVENT_HISTORY_MAX)
}
}
const getWsEventHistory = (): unknown[] => wsEventHistory.slice()
const setWsEventCaptureEnabled = (enabled: boolean): void => {
wsEventCaptureEnabled = enabled
if (!enabled) wsEventHistory.length = 0
}
const addLegacyLog = (message: string): void => {
const logLines = message
.split(/[\r\n]+/)
.filter((l) => l)
.map(textToLogLine)
let parentLevel: LogLevel | null = null
for (const line of logLines) {
if (ENTRY_START_RE.test(line.text)) {
parentLevel = line.level
} else if (line.level === null && parentLevel !== null) {
line.level = parentLevel
}
}
addLines(logLines)
}
const clear = (): void => {
const t0 = DEBUG_PERF ? performance.now() : 0
archive.clear()
output.value = []
lineBuffer = []
wsEventHistory.length = 0
wrapCount = 0
isInitialLogHydrating.value = false
clearInitialHydrationTimers()
if (batchTimer) {
clearTimeout(batchTimer)
batchTimer = null
}
if (DEBUG_PERF) {
console.debug(`[mr-console] clear in ${(performance.now() - t0).toFixed(2)}ms`)
}
}
const __debugStats = ():
| { enabled: false }
| {
enabled: true
bufferSize: number
heapEstimate: number
recentFlushMs: number
wrapCount: number
} => {
if (!DEBUG_PERF) return { enabled: false }
const heapEstimate =
archive.texts.reduce<number>((a, s) => a + (s?.length ?? 0) * 2, 0) +
archive.levels.byteLength
return {
enabled: true,
bufferSize: archive.size,
heapEstimate,
recentFlushMs: lastFlushMs,
wrapCount,
}
}
return {
output,
isInitialLogHydrating,
beginInitialLogHydration,
addLines,
addLog4jEvent,
addLegacyLog,
recordWsEvent,
getWsEventHistory,
setWsEventCaptureEnabled,
clear,
__debugStats,
}
}
export const useModrinthServersConsole = createGlobalState(createConsoleState)