From 04354891fb04577f52bb81e9b48b5d2945a7548a Mon Sep 17 00:00:00 2001 From: Dockpeek Date: Sat, 18 Oct 2025 21:16:59 +0200 Subject: [PATCH] ANSI escape codes --- dockpeek/static/css/styles.css | 18 ++ dockpeek/static/css/tailwindcss.css | 90 ++++++ dockpeek/static/js/modules/ansi-parser.js | 364 ++++++++++++++++++++++ dockpeek/static/js/modules/logs-viewer.js | 87 +++--- 4 files changed, 517 insertions(+), 42 deletions(-) create mode 100644 dockpeek/static/js/modules/ansi-parser.js diff --git a/dockpeek/static/css/styles.css b/dockpeek/static/css/styles.css index 974ec00..c4532ed 100644 --- a/dockpeek/static/css/styles.css +++ b/dockpeek/static/css/styles.css @@ -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; } \ No newline at end of file diff --git a/dockpeek/static/css/tailwindcss.css b/dockpeek/static/css/tailwindcss.css index 660c267..ace0bba 100644 --- a/dockpeek/static/css/tailwindcss.css +++ b/dockpeek/static/css/tailwindcss.css @@ -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: ""; + 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; } diff --git a/dockpeek/static/js/modules/ansi-parser.js b/dockpeek/static/js/modules/ansi-parser.js new file mode 100644 index 0000000..a586730 --- /dev/null +++ b/dockpeek/static/js/modules/ansi-parser.js @@ -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 `${escapedText}`; + }).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; \ No newline at end of file diff --git a/dockpeek/static/js/modules/logs-viewer.js b/dockpeek/static/js/modules/logs-viewer.js index aea7f95..28d6e0b 100644 --- a/dockpeek/static/js/modules/logs-viewer.js +++ b/dockpeek/static/js/modules/logs-viewer.js @@ -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 `${match}`; }); - - 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 `${escapedUrl}`; + return `${url}`; } ); - - if (/\b(ERROR|ERR|ERRO)\b|\[(ERROR|ERR|ERRO)\]/i.test(escapedLine)) { - return `${escapedLine}`; + + if (/\b(ERROR|ERR|ERRO)\b|\[(ERROR|ERR|ERRO)\]/i.test(originalText)) { + return `${escapedHtml}`; } - if (/\b(WARN|WARNING)\b|\[(WARN|WARNING)\]/i.test(escapedLine)) { - return `${escapedLine}`; + if (/\b(WARN|WARNING)\b|\[(WARN|WARNING)\]/i.test(originalText)) { + return `${escapedHtml}`; } - if (/\b(INFO)\b|\[(INFO)\]/i.test(escapedLine)) { - return `${escapedLine}`; + if (/\b(INFO)\b|\[(INFO)\]/i.test(originalText)) { + return `${escapedHtml}`; } - if (/\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\]/i.test(escapedLine)) { - return `${escapedLine}`; + if (/\b(DEBUG|TRACE)\b|\[(DEBUG|TRACE)\]/i.test(originalText)) { + return `${escapedHtml}`; } - if (/\b(SUCCESS|OK|DONE|READY)\b|\[(SUCCESS|OK|DONE|READY)\]/i.test(escapedLine)) { - return `${escapedLine}`; + if (/\b(SUCCESS|OK|DONE|READY)\b|\[(SUCCESS|OK|DONE|READY)\]/i.test(originalText)) { + return `${escapedHtml}`; } - - 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 = 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();
\ No newline at end of file