mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
669 lines
26 KiB
JavaScript
669 lines
26 KiB
JavaScript
import {
|
|
Virtualizer,
|
|
elementScroll,
|
|
measureElement,
|
|
observeElementOffset,
|
|
observeElementRect,
|
|
} from '@tanstack/virtual-core';
|
|
|
|
// Alpine data provider for the runtime log viewer (x-data="runtimeLogs(...)").
|
|
// Log lines stay in plain arrays outside Alpine's reactive state. Only the rows
|
|
// in the viewport are rendered, so large logs do not freeze the browser tab.
|
|
|
|
export const MAX_CLIENT_LOG_LINES = 50000;
|
|
export const STREAM_INTERVAL_MS = 2000;
|
|
const ESTIMATED_ROW_HEIGHT = 30;
|
|
const ESTIMATED_TEXT_ROW_HEIGHT = 20;
|
|
|
|
const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s;
|
|
const SOURCE_PREFIX = /^([^\t]+)\t(.*)$/s;
|
|
const SOURCE_COLORS = ['#6f6c78', '#6d7ea8', '#6e8b74', '#a98462', '#8d7398', '#6f8f95'];
|
|
|
|
function sourceColor(label) {
|
|
let hash = 0;
|
|
for (let i = 0; i < label.length; i++) {
|
|
hash = (hash * 31 + label.charCodeAt(i)) >>> 0;
|
|
}
|
|
return SOURCE_COLORS[hash % SOURCE_COLORS.length];
|
|
}
|
|
|
|
export function formatLogDetails(content) {
|
|
const trimmed = String(content ?? '').trim();
|
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return content;
|
|
try {
|
|
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A row can expand only when the detail shows more than the row: the row cut
|
|
* the text off, or the text is JSON that the detail formats.
|
|
*/
|
|
export function isExpandableLog(content, truncated) {
|
|
return truncated || formatLogDetails(content) !== content;
|
|
}
|
|
|
|
const LEVEL_FIELD = /\b(?:level|lvl|severity|loglevel)"?\s*[=:]\s*"?([a-z]+)\b/;
|
|
// A level word at the start of the line, after timestamps: `2026-10-30t16:35:49z info msg`, `[debug] msg`.
|
|
const LEADING_LEVEL = /^(?:\S*\d\S*\s+){0,3}[[(<]?([a-z]+)[\])>:]?(?:\s|$)/;
|
|
const LEVEL_NAMES = {
|
|
error: ['error', 'err', 'fatal', 'panic', 'critical', 'crit', 'alert', 'emerg', 'emergency'],
|
|
warning: ['warn', 'warning', 'wrn'],
|
|
debug: ['debug', 'dbg', 'trace', 'verbose'],
|
|
info: ['info', 'inf', 'information', 'notice'],
|
|
};
|
|
|
|
/**
|
|
* Prefer the declared level (`level=debug`, `"level":"debug"`, a leading
|
|
* `INFO`) over keywords, so `INFO ... 0 error events` or `level=debug err=EOF`
|
|
* is not shown as an error.
|
|
*/
|
|
export function getLogLevel(content) {
|
|
for (const pattern of [LEVEL_FIELD, LEADING_LEVEL]) {
|
|
const declared = content.match(pattern)?.[1];
|
|
for (const [level, names] of Object.entries(LEVEL_NAMES)) {
|
|
if (names.includes(declared)) return level;
|
|
}
|
|
}
|
|
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error';
|
|
if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning';
|
|
if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug';
|
|
return 'info';
|
|
}
|
|
|
|
/**
|
|
* Split raw `docker logs -t` output into frozen line records.
|
|
* Frozen records are not wrapped by Alpine's reactive proxies.
|
|
*/
|
|
export function parseLogLines(output, nextId) {
|
|
const lines = [];
|
|
for (const rawLine of String(output ?? '').split('\n')) {
|
|
if (rawLine.trim() === '') continue;
|
|
const sourced = rawLine.match(SOURCE_PREFIX);
|
|
const source = sourced ? sourced[1] : '';
|
|
const body = sourced ? sourced[2] : rawLine;
|
|
const match = body.match(TIMESTAMP_PATTERN);
|
|
const ts = match ? match[1] : '';
|
|
const text = match ? match[2] : body;
|
|
const lower = `${source}\n${text}`.toLowerCase();
|
|
lines.push(Object.freeze({
|
|
id: nextId(),
|
|
ts,
|
|
text,
|
|
source,
|
|
sourceColor: source ? sourceColor(source) : '',
|
|
lower,
|
|
level: getLogLevel(text.toLowerCase()),
|
|
}));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Remove lines that a `--since` request returns again. Docker includes lines
|
|
* with the same timestamp as the `--since` value, so compare that boundary.
|
|
*/
|
|
function linesSinceBoundary(existing, incoming) {
|
|
let lastTs = '';
|
|
for (let i = existing.length - 1; i >= 0; i--) {
|
|
if (existing[i].ts) {
|
|
lastTs = existing[i].ts;
|
|
break;
|
|
}
|
|
}
|
|
if (!lastTs) return incoming;
|
|
|
|
const seenAtBoundary = new Map();
|
|
for (let i = existing.length - 1; i >= 0 && existing[i].ts === lastTs; i--) {
|
|
const key = existing[i].text;
|
|
seenAtBoundary.set(key, (seenAtBoundary.get(key) ?? 0) + 1);
|
|
}
|
|
|
|
return incoming.filter((line) => {
|
|
if (!line.ts) return true;
|
|
if (line.ts < lastTs) return false;
|
|
if (line.ts > lastTs) return true;
|
|
const seen = seenAtBoundary.get(line.text) ?? 0;
|
|
if (seen === 0) return true;
|
|
seenAtBoundary.set(line.text, seen - 1);
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function groupLinesBySource(entries) {
|
|
const groups = new Map();
|
|
for (const line of entries) {
|
|
const key = line.source || '';
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(line);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function newLinesSince(existing, incoming) {
|
|
const sourced = existing.some((line) => line.source) || incoming.some((line) => line.source);
|
|
if (!sourced) return linesSinceBoundary(existing, incoming);
|
|
|
|
const existingBySource = groupLinesBySource(existing);
|
|
const fresh = [];
|
|
for (const [source, group] of groupLinesBySource(incoming)) {
|
|
fresh.push(...linesSinceBoundary(existingBySource.get(source) ?? [], group));
|
|
}
|
|
fresh.sort((left, right) => {
|
|
if (left.ts === right.ts) return (left.source || '').localeCompare(right.source || '');
|
|
if (!left.ts) return 1;
|
|
if (!right.ts) return -1;
|
|
return left.ts < right.ts ? -1 : 1;
|
|
});
|
|
return fresh;
|
|
}
|
|
|
|
export function lastTimestamp(lines) {
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (lines[i].ts) return lines[i].ts;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Ctrl+F or Cmd+F. The viewer takes it, because browser find cannot see rows that are not rendered. */
|
|
export function isFindShortcut(event) {
|
|
return Boolean(event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key?.toLowerCase() === 'f';
|
|
}
|
|
|
|
const DEFAULT_LOG_FILTERS = { error: true, warning: true, debug: true, info: true };
|
|
|
|
/** Read saved level filters. Invalid saved values fall back to the defaults. */
|
|
export function readLogFilters(saved) {
|
|
const filters = { ...DEFAULT_LOG_FILTERS };
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(saved);
|
|
} catch {
|
|
return filters;
|
|
}
|
|
for (const level of Object.keys(filters)) {
|
|
if (typeof parsed?.[level] === 'boolean') filters[level] = parsed[level];
|
|
}
|
|
return filters;
|
|
}
|
|
|
|
export function filterLogLines(lines, filters, query) {
|
|
const needle = query.trim().toLowerCase();
|
|
return lines.filter((line) => filters[line.level] !== false && (!needle || line.lower.includes(needle)));
|
|
}
|
|
|
|
/** Split text into plain and matching parts. The view renders each part with x-text. */
|
|
export function highlightSegments(text, query) {
|
|
const needle = query.trim().toLowerCase();
|
|
if (!needle) return [{ text, match: false }];
|
|
|
|
const segments = [];
|
|
const lowerText = text.toLowerCase();
|
|
let lastIndex = 0;
|
|
let index = lowerText.indexOf(needle);
|
|
while (index !== -1) {
|
|
if (index > lastIndex) segments.push({ text: text.substring(lastIndex, index), match: false });
|
|
segments.push({ text: text.substring(index, index + needle.length), match: true });
|
|
lastIndex = index + needle.length;
|
|
index = lowerText.indexOf(needle, lastIndex);
|
|
}
|
|
if (lastIndex < text.length) segments.push({ text: text.substring(lastIndex), match: false });
|
|
return segments;
|
|
}
|
|
|
|
/** Format a Docker UTC timestamp like the server-side log viewer: 2025-Dec-04 11:48:39. */
|
|
export function createTimestampFormatter(timeZone) {
|
|
let formatter;
|
|
try {
|
|
formatter = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: timeZone || 'UTC',
|
|
year: 'numeric', month: 'short', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
});
|
|
} catch {
|
|
formatter = createTimestampFormatter('UTC').formatter;
|
|
}
|
|
|
|
const format = (ts) => {
|
|
const date = new Date(ts.slice(0, 19) + 'Z');
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
|
|
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
};
|
|
format.formatter = formatter;
|
|
return format;
|
|
}
|
|
|
|
function downloadText(content, filename) {
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function prefersTextView(stored) {
|
|
return stored !== 'false';
|
|
}
|
|
|
|
export function initializeRuntimeLogsComponent() {
|
|
window.Alpine.data('runtimeLogs', (config = {}) => {
|
|
let lines = [];
|
|
let filtered = [];
|
|
let lineId = 0;
|
|
let virtualizer = null;
|
|
let cleanupVirtualizer = null;
|
|
let layoutObserver = null;
|
|
let streamTimer = null;
|
|
const nextId = () => ++lineId;
|
|
const formatTimestamp = createTimestampFormatter(config.timezone);
|
|
|
|
return {
|
|
collapsible: config.collapsible ?? true,
|
|
expanded: config.expanded ?? false,
|
|
containerName: config.containerName ?? 'logs',
|
|
findShortcutLabel: /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘F' : 'Ctrl+F',
|
|
logsLoaded: false,
|
|
allLines: false,
|
|
loadingAll: false,
|
|
loadingLines: false,
|
|
loading: false,
|
|
fullscreen: false,
|
|
alwaysScroll: false,
|
|
followManuallyDisabled: false,
|
|
isScrolling: false,
|
|
lastTouchY: 0,
|
|
scrollDebounce: null,
|
|
destroyed: false,
|
|
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
|
textView: prefersTextView(localStorage.getItem('coolify-log-text-view')),
|
|
logFilters: readLogFilters(localStorage.getItem('coolify-log-filters')),
|
|
searchQuery: '',
|
|
appliedQuery: '',
|
|
matchCount: 0,
|
|
lineCount: 0,
|
|
expandedLogs: {},
|
|
virtualRows: [],
|
|
totalSize: 0,
|
|
scrollMargin: 0,
|
|
multipleSources: false,
|
|
|
|
init() {
|
|
this.allLines = Number(this.$wire.numberOfLines) === -1;
|
|
this.$watch('searchQuery', () => this.applyFilters());
|
|
this.$watch('loading', () => this.$nextTick(() => this.updateVirtualizer()));
|
|
this.$nextTick(() => this.mountVirtualizer());
|
|
streamTimer = setInterval(() => this.streamTick(), STREAM_INTERVAL_MS);
|
|
if (this.expanded) {
|
|
this.refresh();
|
|
this.logsLoaded = true;
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
this.destroyed = true;
|
|
clearInterval(streamTimer);
|
|
clearTimeout(this.scrollDebounce);
|
|
layoutObserver?.disconnect();
|
|
cleanupVirtualizer?.();
|
|
virtualizer = null;
|
|
},
|
|
|
|
mountVirtualizer() {
|
|
if (this.destroyed || virtualizer) return;
|
|
virtualizer = new Virtualizer({
|
|
count: 0,
|
|
getScrollElement: () => this.$refs.viewport ?? null,
|
|
estimateSize: () => (this.textView ? ESTIMATED_TEXT_ROW_HEIGHT : ESTIMATED_ROW_HEIGHT),
|
|
getItemKey: (index) => filtered[index]?.id ?? index,
|
|
overscan: 12,
|
|
// Follow mode keeps the newest line in view. Otherwise the first line stays at the top.
|
|
anchorTo: 'start',
|
|
observeElementRect,
|
|
observeElementOffset,
|
|
scrollToFn: elementScroll,
|
|
// A row can be measured before its text is laid out. Caching that 0
|
|
// collapses the top of the list and the first lines never come back.
|
|
measureElement: (element, entry, instance) => {
|
|
const size = measureElement(element, entry, instance);
|
|
if (size > 0) return size;
|
|
const index = instance.indexFromElement(element);
|
|
const key = instance.options.getItemKey(index);
|
|
return instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index);
|
|
},
|
|
onChange: () => {
|
|
// Measurement corrections can advance the internal offset while the
|
|
// element is still at the top, which hides the first lines.
|
|
this.pinScrollOffset();
|
|
this.syncRows();
|
|
},
|
|
});
|
|
cleanupVirtualizer = virtualizer._didMount();
|
|
virtualizer._willUpdate();
|
|
if (window.ResizeObserver) {
|
|
layoutObserver = new ResizeObserver(() => {
|
|
const margin = this.measureScrollMargin();
|
|
if (margin !== this.scrollMargin) this.updateVirtualizer();
|
|
});
|
|
if (this.$refs.list) layoutObserver.observe(this.$refs.list);
|
|
if (this.$refs.viewport) layoutObserver.observe(this.$refs.viewport);
|
|
}
|
|
this.updateVirtualizer();
|
|
},
|
|
|
|
measureScrollMargin() {
|
|
const list = this.$refs.list;
|
|
const viewport = this.$refs.viewport;
|
|
if (!list || !viewport || this.lineCount === 0) return 0;
|
|
const margin = list.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
|
|
return Number.isFinite(margin) ? Math.max(0, margin) : 0;
|
|
},
|
|
|
|
// Keep the virtual offset on the real scroll position while the reader is at the top.
|
|
pinScrollOffset() {
|
|
const viewport = this.$refs.viewport;
|
|
if (!virtualizer || this.alwaysScroll || !viewport || viewport.scrollTop !== 0) return;
|
|
if (!virtualizer.scrollOffset && !virtualizer.scrollAdjustments) return;
|
|
virtualizer.scrollOffset = 0;
|
|
virtualizer.scrollAdjustments = 0;
|
|
},
|
|
|
|
updateVirtualizer() {
|
|
if (!virtualizer) return;
|
|
this.pinScrollOffset();
|
|
this.scrollMargin = this.measureScrollMargin();
|
|
virtualizer.setOptions({
|
|
...virtualizer.options,
|
|
count: filtered.length,
|
|
scrollMargin: this.scrollMargin,
|
|
anchorTo: this.alwaysScroll ? 'end' : 'start',
|
|
// A new function makes the virtualizer recalculate keys after every data change.
|
|
getItemKey: (index) => filtered[index]?.id ?? index,
|
|
});
|
|
virtualizer._willUpdate();
|
|
this.syncRows();
|
|
},
|
|
|
|
syncRows() {
|
|
if (!virtualizer) return;
|
|
this.virtualRows = virtualizer.getVirtualItems()
|
|
.filter((item) => filtered[item.index])
|
|
.map((item) => ({ key: item.key, index: item.index, start: item.start, line: filtered[item.index] }));
|
|
this.totalSize = virtualizer.getTotalSize();
|
|
},
|
|
|
|
measureRow(el, logLine = null) {
|
|
const line = el.querySelector('[data-log-line]');
|
|
const message = line?.querySelector('.runtime-log-message');
|
|
if (line && message && !this.textView) {
|
|
const text = logLine?.text ?? message.textContent;
|
|
line.toggleAttribute('data-expandable', isExpandableLog(text, message.scrollWidth > message.clientWidth));
|
|
}
|
|
virtualizer?.measureElement(el);
|
|
},
|
|
|
|
async refresh() {
|
|
if (this.loading) return;
|
|
this.loading = true;
|
|
try {
|
|
this.replaceLines(await this.$wire.getLogs());
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async refreshLines() {
|
|
if (this.loading) return;
|
|
this.loadingLines = true;
|
|
try {
|
|
await this.refresh();
|
|
} finally {
|
|
this.loadingLines = false;
|
|
}
|
|
},
|
|
|
|
async showAllLogs() {
|
|
if (this.loading) return;
|
|
this.loading = true;
|
|
this.loadingAll = true;
|
|
try {
|
|
this.replaceLines(await this.$wire.showAllLogs());
|
|
this.allLines = true;
|
|
} finally {
|
|
this.loading = false;
|
|
this.loadingAll = false;
|
|
}
|
|
},
|
|
|
|
sinceArgument() {
|
|
const bySource = {};
|
|
let last = null;
|
|
let sourced = false;
|
|
for (const line of lines) {
|
|
if (!line.ts) continue;
|
|
last = line.ts;
|
|
if (line.source) {
|
|
sourced = true;
|
|
bySource[line.source] = line.ts;
|
|
}
|
|
}
|
|
if (!sourced) return last;
|
|
return Object.keys(bySource).length > 0 ? bySource : null;
|
|
},
|
|
|
|
async streamTick() {
|
|
if (this.destroyed || this.loading || !this.expanded || document.hidden || !this.$wire.streamLogs) return;
|
|
const since = this.sinceArgument();
|
|
if (!since) {
|
|
await this.refresh();
|
|
return;
|
|
}
|
|
this.loading = true;
|
|
try {
|
|
const output = await this.$wire.getLogs(since);
|
|
if (this.destroyed || !this.$wire.streamLogs) return;
|
|
this.appendLines(newLinesSince(lines, parseLogLines(output, nextId)));
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
replaceLines(output) {
|
|
if (this.destroyed) return;
|
|
lines = parseLogLines(output, nextId);
|
|
this.expandedLogs = {};
|
|
this.applyFilters();
|
|
},
|
|
|
|
appendLines(newLines) {
|
|
if (newLines.length === 0) return;
|
|
lines = lines.concat(newLines);
|
|
if (lines.length > MAX_CLIENT_LOG_LINES) {
|
|
lines = lines.slice(lines.length - MAX_CLIENT_LOG_LINES);
|
|
}
|
|
this.applyFilters();
|
|
},
|
|
|
|
applyFilters() {
|
|
filtered = filterLogLines(lines, this.logFilters, this.searchQuery);
|
|
this.appliedQuery = this.searchQuery.trim();
|
|
this.matchCount = this.appliedQuery ? filtered.length : 0;
|
|
this.lineCount = lines.length;
|
|
const sources = new Set(filtered.map((line) => line.source).filter(Boolean));
|
|
this.multipleSources = sources.size > 1;
|
|
// Wait for x-show to reveal the list so its offset can be measured.
|
|
this.$nextTick(() => {
|
|
this.updateVirtualizer();
|
|
if (this.alwaysScroll) this.scrollToBottom();
|
|
});
|
|
},
|
|
|
|
visibleText(source = filtered) {
|
|
const withTime = this.$wire.showTimeStamps;
|
|
const showSource = new Set(source.map((line) => line.source).filter(Boolean)).size > 1;
|
|
return source
|
|
.map((line) => {
|
|
const body = withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text;
|
|
return showSource && line.source ? `${line.source} | ${body}` : body;
|
|
})
|
|
.join('\n');
|
|
},
|
|
|
|
formatTimestamp(ts) {
|
|
return formatTimestamp(ts);
|
|
},
|
|
|
|
highlight(text) {
|
|
return highlightSegments(text, this.appliedQuery);
|
|
},
|
|
|
|
formatLogDetails(content) {
|
|
return formatLogDetails(content);
|
|
},
|
|
|
|
toggleLogDetails(id, event) {
|
|
if (this.textView) return;
|
|
if (window.getSelection()?.toString()) return;
|
|
if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return;
|
|
if (event.type === 'keydown') event.preventDefault();
|
|
if (!this.expandedLogs[id] && !event.currentTarget?.hasAttribute('data-expandable')) return;
|
|
this.expandedLogs[id] = !this.expandedLogs[id];
|
|
},
|
|
|
|
isLogExpanded(id) {
|
|
return !this.textView && this.expandedLogs[id] === true;
|
|
},
|
|
|
|
toggleLogFilter(level) {
|
|
this.logFilters[level] = !this.logFilters[level];
|
|
localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters));
|
|
this.applyFilters();
|
|
},
|
|
|
|
toggleColorLogs() {
|
|
this.colorLogs = !this.colorLogs;
|
|
localStorage.setItem('coolify-color-logs', this.colorLogs);
|
|
},
|
|
|
|
// Plain text lines without columns, row borders, or expandable details.
|
|
toggleTextView() {
|
|
this.textView = !this.textView;
|
|
localStorage.setItem('coolify-log-text-view', this.textView);
|
|
this.$nextTick(() => {
|
|
virtualizer?.measure();
|
|
this.$refs.list?.querySelectorAll('.runtime-log-virtual-row').forEach((el) => this.measureRow(el));
|
|
this.updateVirtualizer();
|
|
if (this.alwaysScroll) this.scrollToBottom();
|
|
});
|
|
},
|
|
|
|
// The browser holds unredacted lines, so fetch the redacted copy from the server
|
|
// and apply the same level and search filters before downloading it.
|
|
async downloadLogs() {
|
|
let logs;
|
|
try {
|
|
logs = await this.$wire.copyLogs();
|
|
} catch {
|
|
window.Livewire.dispatch('error', ['Failed to prepare logs for download.']);
|
|
return;
|
|
}
|
|
if (!logs) return;
|
|
let id = 0;
|
|
const exported = filterLogLines(parseLogLines(logs, () => ++id), this.logFilters, this.searchQuery);
|
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
|
downloadText(this.visibleText(exported) + '\n', `${this.containerName}-logs-${timestamp}.txt`);
|
|
},
|
|
|
|
makeFullscreen() {
|
|
this.fullscreen = !this.fullscreen;
|
|
if (this.fullscreen === false) {
|
|
this.alwaysScroll = false;
|
|
}
|
|
},
|
|
|
|
handleKeyDown(event) {
|
|
if (event.key === 'Escape' && this.fullscreen) {
|
|
this.makeFullscreen();
|
|
}
|
|
if (isFindShortcut(event) && this.ownsFindShortcut()) {
|
|
event.preventDefault();
|
|
this.$refs.search.focus();
|
|
this.$refs.search.select();
|
|
}
|
|
},
|
|
|
|
// Focus inside this viewer wins. Otherwise, unless the user types in another
|
|
// field or works in another viewer, the fullscreen viewer or else the first
|
|
// open viewer takes it. A second Ctrl+F in the search field falls through
|
|
// to the browser find.
|
|
ownsFindShortcut() {
|
|
if (!this.expanded) return false;
|
|
const active = document.activeElement;
|
|
if (active === this.$refs.search) return false;
|
|
if (this.$root.contains(active)) return true;
|
|
if (active?.closest('[data-runtime-logs]')) return false;
|
|
if (active?.isContentEditable || active?.matches('input, textarea, select')) return false;
|
|
const target = document.querySelector('[data-runtime-logs="fullscreen"]')
|
|
?? document.querySelector('[data-runtime-logs="expanded"]');
|
|
return target === this.$root;
|
|
},
|
|
|
|
scrollToBottom() {
|
|
if (this.destroyed || !virtualizer || filtered.length === 0) return;
|
|
this.isScrolling = true;
|
|
virtualizer.scrollToIndex(filtered.length - 1, { align: 'end' });
|
|
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
},
|
|
|
|
toggleScroll() {
|
|
this.alwaysScroll = !this.alwaysScroll;
|
|
this.followManuallyDisabled = !this.alwaysScroll;
|
|
this.updateVirtualizer();
|
|
if (this.alwaysScroll) this.scrollToBottom();
|
|
},
|
|
|
|
disableFollow() {
|
|
this.alwaysScroll = false;
|
|
},
|
|
|
|
handleWheel(event) {
|
|
if (this.alwaysScroll && event.deltaY < 0) this.disableFollow();
|
|
},
|
|
|
|
handleTouchStart(event) {
|
|
this.lastTouchY = event.touches[0].clientY;
|
|
},
|
|
|
|
handleTouchMove(event) {
|
|
if (!this.alwaysScroll) return;
|
|
const currentY = event.touches[0].clientY;
|
|
if (currentY > this.lastTouchY) this.disableFollow();
|
|
this.lastTouchY = currentY;
|
|
},
|
|
|
|
handleKeyScroll(event) {
|
|
if (this.alwaysScroll && ['ArrowUp', 'PageUp', 'Home'].includes(event.key)) this.disableFollow();
|
|
},
|
|
|
|
handleScroll(event) {
|
|
if (this.isScrolling || this.destroyed) return;
|
|
clearTimeout(this.scrollDebounce);
|
|
this.scrollDebounce = setTimeout(() => {
|
|
if (this.destroyed) return;
|
|
const el = event.target;
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
const overflows = el.scrollHeight - el.clientHeight > 10;
|
|
if (!this.alwaysScroll && !this.followManuallyDisabled && overflows && el.scrollTop > 0 && distanceFromBottom <= 10) {
|
|
this.alwaysScroll = true;
|
|
this.updateVirtualizer();
|
|
}
|
|
}, 150);
|
|
},
|
|
};
|
|
});
|
|
}
|