ANSI escape codes

This commit is contained in:
Dockpeek
2025-10-18 21:16:59 +02:00
parent c854d5d8b9
commit 04354891fb
4 changed files with 517 additions and 42 deletions
+18
View File
@@ -1990,4 +1990,22 @@ body {
.hidden {
display: none;
}
@keyframes ansi-blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.ansi-blink {
animation: ansi-blink 1s linear infinite;
}
+90
View File
@@ -608,6 +608,15 @@
.text-white {
color: var(--color-white);
}
.italic {
font-style: italic;
}
.line-through {
text-decoration-line: line-through;
}
.underline {
text-decoration-line: underline;
}
.opacity-25 {
opacity: 25%;
}
@@ -618,6 +627,10 @@
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.grayscale {
--tw-grayscale: grayscale(100%);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -2768,6 +2781,17 @@ body {
.hidden {
display: none;
}
@keyframes ansi-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.ansi-blink {
animation: ansi-blink 1s linear infinite;
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
@@ -2876,6 +2900,59 @@ body {
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@property --tw-duration {
syntax: "*";
inherits: false;
@@ -2916,6 +2993,19 @@ body {
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-duration: initial;
--tw-ease: initial;
}
+364
View File
@@ -0,0 +1,364 @@
export class AnsiParser {
constructor() {
this.colors = {
30: '#000000',
31: '#CD3131',
32: '#0DBC79',
33: '#E5E510',
34: '#2472C8',
35: '#BC3FBC',
36: '#11A8CD',
37: '#E5E5E5',
90: '#666666',
91: '#F14C4C',
92: '#23D18B',
93: '#F5F543',
94: '#3B8EEA',
95: '#D670D6',
96: '#29B8DB',
97: '#FFFFFF',
40: '#000000',
41: '#CD3131',
42: '#0DBC79',
43: '#E5E510',
44: '#2472C8',
45: '#BC3FBC',
46: '#11A8CD',
47: '#E5E5E5',
100: '#666666',
101: '#F14C4C',
102: '#23D18B',
103: '#F5F543',
104: '#3B8EEA',
105: '#D670D6',
106: '#29B8DB',
107: '#FFFFFF'
};
}
parse(text) {
const ansiRegex = /\x1b\[([0-9;]*)m/g;
const segments = [];
let lastIndex = 0;
let currentStyle = this.createEmptyStyle();
let match;
while ((match = ansiRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textSegment = text.substring(lastIndex, match.index);
segments.push({
text: textSegment,
style: { ...currentStyle }
});
}
const codes = match[1] ? match[1].split(';').map(Number) : [0];
currentStyle = this.applyCodes(currentStyle, codes);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
style: { ...currentStyle }
});
}
if (segments.length === 0) {
segments.push({
text: text,
style: this.createEmptyStyle()
});
}
return this.segmentsToHtml(segments);
}
createEmptyStyle() {
return {
color: null,
background: null,
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
reverse: false,
hidden: false,
strikethrough: false
};
}
applyCodes(style, codes) {
const newStyle = { ...style };
let i = 0;
while (i < codes.length) {
const code = codes[i];
switch (code) {
case 0:
return this.createEmptyStyle();
case 1:
newStyle.bold = true;
break;
case 2:
newStyle.dim = true;
break;
case 3:
newStyle.italic = true;
break;
case 4:
newStyle.underline = true;
break;
case 5:
newStyle.blink = true;
break;
case 7:
newStyle.reverse = true;
break;
case 8:
newStyle.hidden = true;
break;
case 9:
newStyle.strikethrough = true;
break;
case 22:
newStyle.bold = false;
newStyle.dim = false;
break;
case 23:
newStyle.italic = false;
break;
case 24:
newStyle.underline = false;
break;
case 25:
newStyle.blink = false;
break;
case 27:
newStyle.reverse = false;
break;
case 28:
newStyle.hidden = false;
break;
case 29:
newStyle.strikethrough = false;
break;
case 39:
newStyle.color = null;
break;
case 49:
newStyle.background = null;
break;
case 38:
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
newStyle.color = this.get256Color(codes[i + 2]);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
newStyle.color = `rgb(${codes[i + 2]}, ${codes[i + 3]}, ${codes[i + 4]})`;
i += 4;
}
break;
case 48:
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
newStyle.background = this.get256Color(codes[i + 2]);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
newStyle.background = `rgb(${codes[i + 2]}, ${codes[i + 3]}, ${codes[i + 4]})`;
i += 4;
}
break;
default:
if (this.colors[code]) {
if (code >= 30 && code <= 37) {
newStyle.color = this.colors[code];
} else if (code >= 40 && code <= 47) {
newStyle.background = this.colors[code];
} else if (code >= 90 && code <= 97) {
newStyle.color = this.colors[code];
} else if (code >= 100 && code <= 107) {
newStyle.background = this.colors[code];
}
}
}
i++;
}
return newStyle;
}
get256Color(code) {
if (code < 16) {
const mapping = [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97];
return this.colors[mapping[code]];
}
if (code >= 16 && code <= 231) {
const index = code - 16;
const r = Math.floor(index / 36);
const g = Math.floor((index % 36) / 6);
const b = index % 6;
const toRgb = (v) => v === 0 ? 0 : 55 + v * 40;
return `rgb(${toRgb(r)}, ${toRgb(g)}, ${toRgb(b)})`;
}
if (code >= 232 && code <= 255) {
const gray = 8 + (code - 232) * 10;
return `rgb(${gray}, ${gray}, ${gray})`;
}
return null;
}
segmentsToHtml(segments, additionalColorizer = null) {
return segments.map(segment => {
if (!segment.text) return '';
const style = segment.style;
const hasStyle = style.color || style.background || style.bold ||
style.dim || style.italic || style.underline ||
style.strikethrough || style.reverse || style.hidden;
let escapedText = this.escapeHtml(segment.text);
if (additionalColorizer) {
escapedText = additionalColorizer(escapedText, segment.text);
}
if (!hasStyle) {
return escapedText;
}
const styles = [];
const classes = [];
let color = style.color;
let background = style.background;
if (style.reverse) {
[color, background] = [background || '#E5E5E5', color || '#000000'];
}
if (color) styles.push(`color: ${color}`);
if (background) styles.push(`background-color: ${background}`);
if (style.bold) {
styles.push('font-weight: bold');
}
if (style.dim) {
styles.push('opacity: 0.6');
}
if (style.italic) {
styles.push('font-style: italic');
}
if (style.underline) {
styles.push('text-decoration: underline');
}
if (style.strikethrough) {
styles.push('text-decoration: line-through');
}
if (style.blink) {
classes.push('ansi-blink');
}
if (style.hidden) {
styles.push('visibility: hidden');
}
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
const styleAttr = styles.length > 0 ? ` style="${styles.join('; ')}"` : '';
return `<span${classAttr}${styleAttr}>${escapedText}</span>`;
}).join('');
}
parseWithColorizer(text, colorizer) {
const ansiRegex = /\x1b\[([0-9;]*)m/g;
const segments = [];
let lastIndex = 0;
let currentStyle = this.createEmptyStyle();
let match;
while ((match = ansiRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textSegment = text.substring(lastIndex, match.index);
segments.push({
text: textSegment,
style: { ...currentStyle }
});
}
const codes = match[1] ? match[1].split(';').map(Number) : [0];
currentStyle = this.applyCodes(currentStyle, codes);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
style: { ...currentStyle }
});
}
if (segments.length === 0) {
segments.push({
text: text,
style: this.createEmptyStyle()
});
}
return this.segmentsToHtml(segments, colorizer);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
static stripAnsi(text) {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
}
export const ansiParser = new AnsiParser();
export default AnsiParser;
+45 -42
View File
@@ -1,5 +1,7 @@
// modules/logs-viewer.js
import { apiUrl } from './config.js';
import { ansiParser } from './ansi-parser.js';
export class LogsViewer {
constructor() {
this.modal = null;
@@ -276,7 +278,7 @@ export class LogsViewer {
}
});
// Close on overlay click
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.close();
});
@@ -292,7 +294,7 @@ export class LogsViewer {
this.navigateToContainer(1);
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this.modal.classList.contains('hidden')) {
this.close();
@@ -449,7 +451,7 @@ export class LogsViewer {
let timestampPart = '';
let contentPart = line;
// Parse timestamp first
const timestampRegex = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/;
const timestampMatch = line.match(timestampRegex);
@@ -458,7 +460,7 @@ export class LogsViewer {
contentPart = timestampMatch[2];
}
// Parse Swarm task details from content
const swarmDetailsRegex = /^com\.docker\.swarm\.[^\s]+ (.*)$/;
const swarmMatch = contentPart.match(swarmDetailsRegex);
@@ -497,10 +499,13 @@ export class LogsViewer {
}
colorizeLogLine(line) {
const urls = [];
const urlPlaceholder = '___URL_PLACEHOLDER_';
if (!line.trim()) return '';
let processedLine = line.replace(
return ansiParser.parseWithColorizer(line, (escapedHtml, originalText) => {
const urls = [];
const urlPlaceholder = '___URL___';
escapedHtml = escapedHtml.replace(
/https?:\/\/[^\s"'<>|\\{}[\]`]+/g,
(match) => {
const cleaned = match.replace(/[.,;:!?)}"':]+$/, '');
@@ -508,46 +513,44 @@ export class LogsViewer {
return `${urlPlaceholder}${urls.length - 1}${urlPlaceholder}`;
}
);
let escapedLine = this.escapeHtml(processedLine);
escapedLine = escapedLine.replace(/\d+/g, (match, offset) => {
const before = escapedLine.substring(Math.max(0, offset - urlPlaceholder.length), offset);
const after = escapedLine.substring(offset + match.length, offset + match.length + urlPlaceholder.length);
escapedHtml = escapedHtml.replace(/(\d+)/g, (match, num, offset) => {
const before = escapedHtml.substring(Math.max(0, offset - urlPlaceholder.length), offset);
const after = escapedHtml.substring(offset + match.length, offset + match.length + urlPlaceholder.length);
if (before === urlPlaceholder && after === urlPlaceholder) {
return match;
}
return `<span class="log-number">${match}</span>`;
});
escapedLine = escapedLine.replace(
escapedHtml = escapedHtml.replace(
new RegExp(`${urlPlaceholder}(\\d+)${urlPlaceholder}`, 'g'),
(match, index) => {
const url = urls[parseInt(index)];
const escapedUrl = this.escapeHtml(url);
return `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer" class="log-link">${escapedUrl}</a>`;
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="log-link">${url}</a>`;
}
);
if (/\b(ERROR|ERR|ERRO)\b|\[(ERROR|ERR|ERRO)\]/i.test(escapedLine)) {
return `<span class="log-error">${escapedLine}</span>`;
if (/\b(ERROR|ERR|ERRO)\b|\[(ERROR|ERR|ERRO)\]/i.test(originalText)) {
return `<span class="log-error">${escapedHtml}</span>`;
}
if (/\b(WARN|WARNING)\b|\[(WARN|WARNING)\]/i.test(escapedLine)) {
return `<span class="log-warning">${escapedLine}</span>`;
if (/\b(WARN|WARNING)\b|\[(WARN|WARNING)\]/i.test(originalText)) {
return `<span class="log-warning">${escapedHtml}</span>`;
}
if (/\b(INFO)\b|\[(INFO)\]/i.test(escapedLine)) {
return `<span class="log-info">${escapedLine}</span>`;
if (/\b(INFO)\b|\[(INFO)\]/i.test(originalText)) {
return `<span class="log-info">${escapedHtml}</span>`;
}
if (/\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\]/i.test(escapedLine)) {
return `<span class="log-debug">${escapedLine}</span>`;
if (/\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\]/i.test(originalText)) {
return `<span class="log-debug">${escapedHtml}</span>`;
}
if (/\b(SUCCESS|OK|DONE|READY)\b|\[(SUCCESS|OK|DONE|READY)\]/i.test(escapedLine)) {
return `<span class="log-success">${escapedLine}</span>`;
if (/\b(SUCCESS|OK|DONE|READY)\b|\[(SUCCESS|OK|DONE|READY)\]/i.test(originalText)) {
return `<span class="log-success">${escapedHtml}</span>`;
}
return escapedLine;
}
return escapedHtml;
});
}
escapeHtml(text) {
const div = document.createElement('div');
@@ -656,19 +659,19 @@ export class LogsViewer {
appendLogLine(line) {
let pre = this.logsContent.querySelector('.logs-pre');
// Jeśli nie ma pre, utwórz go
if (!pre) {
this.logsContent.innerHTML = '<pre class="logs-pre"></pre>';
pre = this.logsContent.querySelector('.logs-pre');
}
if (pre) {
// Usuń trailing newline z linii
const cleanLine = line.replace(/\n$/, '');
const formattedLine = this.formatLogLine(cleanLine);
pre.insertAdjacentHTML('beforeend', formattedLine);
// Ogranicz liczbę linii w pamięci
const lines = pre.querySelectorAll('.log-line');
if (lines.length > 5000) {
lines[0].remove();
@@ -732,7 +735,7 @@ export class LogsViewer {
const prevBtn = document.getElementById('logs-search-prev');
const nextBtn = document.getElementById('logs-search-next');
// Reset
this.searchMatches = [];
this.currentMatchIndex = -1;
@@ -747,7 +750,7 @@ export class LogsViewer {
clearBtn.classList.remove('hidden');
// Find all matches
const lines = this.logsContent.querySelectorAll('.log-line');
const lowerQuery = query.toLowerCase();
@@ -771,7 +774,7 @@ export class LogsViewer {
}
});
// Update UI
if (this.searchMatches.length > 0) {
countSpan.classList.remove('hidden');
prevBtn.classList.remove('hidden');
@@ -891,13 +894,13 @@ export class LogsViewer {
}
updateActiveHighlight() {
// Remove all active highlights
this.logsContent.querySelectorAll('mark.search-highlight-active').forEach(el => {
el.classList.remove('search-highlight-active');
el.classList.add('search-highlight');
});
// Add active highlight to current match
const marks = this.logsContent.querySelectorAll('mark[data-match-index]');
marks.forEach(mark => {
const idx = parseInt(mark.dataset.matchIndex);
@@ -956,5 +959,5 @@ export class LogsViewer {
}
}
// Export singleton instance
export const logsViewer = new LogsViewer();