mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
The All button read $wire.numberOfLines, which does not update while the user types or pastes a new number, so All stayed highlighted. Keep the state in Alpine instead: All loads set it, and any input in the number field clears it unless the value is -1. Show a spinner in the All button while all logs load, and in place of the Lines label while a new line count loads. Both keep their width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
467 lines
18 KiB
JavaScript
467 lines
18 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 = 44;
|
|
|
|
const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s;
|
|
|
|
export function getLogLevel(content) {
|
|
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 match = rawLine.match(TIMESTAMP_PATTERN);
|
|
const ts = match ? match[1] : '';
|
|
const text = match ? match[2] : rawLine;
|
|
const lower = text.toLowerCase();
|
|
lines.push(Object.freeze({ id: nextId(), ts, text, lower, level: getLogLevel(lower) }));
|
|
}
|
|
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.
|
|
*/
|
|
export function newLinesSince(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;
|
|
});
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
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 initializeRuntimeLogsComponent() {
|
|
window.Alpine.data('runtimeLogs', (config = {}) => {
|
|
let lines = [];
|
|
let filtered = [];
|
|
let lineId = 0;
|
|
let virtualizer = null;
|
|
let cleanupVirtualizer = null;
|
|
let headerObserver = 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',
|
|
logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || { error: true, warning: true, debug: true, info: true },
|
|
searchQuery: '',
|
|
appliedQuery: '',
|
|
matchCount: 0,
|
|
lineCount: 0,
|
|
expandedLogs: {},
|
|
virtualRows: [],
|
|
totalSize: 0,
|
|
scrollMargin: 0,
|
|
|
|
init() {
|
|
this.allLines = Number(this.$wire.numberOfLines) === -1;
|
|
this.$watch('searchQuery', () => this.applyFilters());
|
|
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);
|
|
headerObserver?.disconnect();
|
|
cleanupVirtualizer?.();
|
|
virtualizer = null;
|
|
},
|
|
|
|
mountVirtualizer() {
|
|
if (this.destroyed || virtualizer) return;
|
|
virtualizer = new Virtualizer({
|
|
count: 0,
|
|
getScrollElement: () => this.$refs.viewport ?? null,
|
|
estimateSize: () => ESTIMATED_ROW_HEIGHT,
|
|
getItemKey: (index) => filtered[index]?.id ?? index,
|
|
overscan: 12,
|
|
// Keep the visible rows in place when old lines are trimmed from the top.
|
|
anchorTo: 'end',
|
|
observeElementRect,
|
|
observeElementOffset,
|
|
scrollToFn: elementScroll,
|
|
measureElement,
|
|
onChange: () => this.syncRows(),
|
|
});
|
|
cleanupVirtualizer = virtualizer._didMount();
|
|
virtualizer._willUpdate();
|
|
if (this.$refs.columns && window.ResizeObserver) {
|
|
headerObserver = new ResizeObserver(() => this.updateVirtualizer());
|
|
headerObserver.observe(this.$refs.columns);
|
|
}
|
|
this.updateVirtualizer();
|
|
},
|
|
|
|
updateVirtualizer() {
|
|
if (!virtualizer) return;
|
|
this.scrollMargin = this.$refs.list?.offsetTop ?? 0;
|
|
virtualizer.setOptions({
|
|
...virtualizer.options,
|
|
count: filtered.length,
|
|
scrollMargin: this.scrollMargin,
|
|
// 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) {
|
|
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;
|
|
}
|
|
},
|
|
|
|
async streamTick() {
|
|
if (this.destroyed || this.loading || !this.expanded || document.hidden || !this.$wire.streamLogs) return;
|
|
const since = lastTimestamp(lines);
|
|
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;
|
|
// Wait for x-show to reveal the list so its offset can be measured.
|
|
this.$nextTick(() => {
|
|
this.updateVirtualizer();
|
|
if (this.alwaysScroll) this.scrollToBottom();
|
|
});
|
|
},
|
|
|
|
visibleText() {
|
|
const withTime = this.$wire.showTimeStamps;
|
|
return filtered
|
|
.map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text))
|
|
.join('\n');
|
|
},
|
|
|
|
formatTimestamp(ts) {
|
|
return formatTimestamp(ts);
|
|
},
|
|
|
|
highlight(text) {
|
|
return highlightSegments(text, this.appliedQuery);
|
|
},
|
|
|
|
formatLogDetails(content) {
|
|
try {
|
|
return JSON.stringify(JSON.parse(content), null, 2);
|
|
} catch {
|
|
return content;
|
|
}
|
|
},
|
|
|
|
toggleLogDetails(id, event) {
|
|
if (window.getSelection()?.toString()) return;
|
|
if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return;
|
|
if (event.type === 'keydown') event.preventDefault();
|
|
this.expandedLogs[id] = !this.expandedLogs[id];
|
|
},
|
|
|
|
isLogExpanded(id) {
|
|
return 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);
|
|
},
|
|
|
|
downloadLogs() {
|
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
|
downloadText(this.visibleText() + '\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;
|
|
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;
|
|
if (!this.alwaysScroll && !this.followManuallyDisabled && distanceFromBottom <= 10) {
|
|
this.alwaysScroll = true;
|
|
}
|
|
}, 150);
|
|
},
|
|
};
|
|
});
|
|
}
|