diff --git a/apps/dokploy/__test__/logs/log-type.test.ts b/apps/dokploy/__test__/logs/log-type.test.ts new file mode 100644 index 000000000..87ee25f54 --- /dev/null +++ b/apps/dokploy/__test__/logs/log-type.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "vitest"; +import { getLogType } from "../../components/dashboard/docker/logs/utils"; + +describe("getLogType", () => { + describe("Structured error patterns", () => { + test("should detect [ERROR] format", () => { + expect(getLogType("[ERROR] Database connection failed").type).toBe( + "error", + ); + }); + + test("should detect ERROR: format", () => { + expect(getLogType("ERROR: Connection refused").type).toBe("error"); + }); + + test("should detect level=error format", () => { + expect( + getLogType('level=error msg="Database connection failed"').type, + ).toBe("error"); + }); + + test("should detect error emojis", () => { + expect(getLogType("❌ Operation failed").type).toBe("error"); + expect(getLogType("🔴 Critical issue").type).toBe("error"); + }); + }); + + describe("Contextual error patterns", () => { + test("should detect uncaught exceptions", () => { + expect(getLogType("Uncaught exception in handler").type).toBe("error"); + }); + + test("should detect 'failed to' patterns", () => { + expect(getLogType("Failed to start server").type).toBe("error"); + }); + + test("should detect 'X failed with' patterns", () => { + expect(getLogType("Token exchange failed with status: 400").type).toBe( + "error", + ); + }); + + test("should detect HTTP error codes", () => { + expect(getLogType("Request returned status: 400").type).toBe("error"); + expect(getLogType("500 Internal Server Error").type).toBe("error"); + }); + + test("should detect stack trace lines", () => { + expect( + getLogType(" at Object. (/app/server.js:123:45)").type, + ).toBe("error"); + }); + }); + + describe("False positives - should NOT be detected as errors", () => { + test("should not flag 'Failed=0' as error", () => { + const result = getLogType( + 'time="2025-11-20T08:19:13Z" level=info msg="Session done" Failed=0 Scanned=1', + ); + expect(result.type).toBe("info"); + }); + + test("should not flag '0 failed' as error", () => { + expect( + getLogType("0 practices builds failed, and 0 uploads failed.").type, + ).toBe("info"); + }); + + test("should not flag negative contexts as error", () => { + expect(getLogType("The operation did not fail").type).toBe("info"); + }); + + test("should not flag conditional statements as error", () => { + expect(getLogType("If failed, retry the operation").type).toBe("info"); + }); + }); + + describe("Warning patterns", () => { + test("should detect [WARN] format", () => { + expect(getLogType("[WARN] API rate limit approaching").type).toBe( + "warning", + ); + }); + + test("should detect WARNING: format", () => { + expect(getLogType("WARNING: Disk space low").type).toBe("warning"); + }); + + test("should detect warning emojis", () => { + expect( + getLogType("⚠️ Token exchange failed with status: 400 Bad Request").type, + ).not.toBe("info"); + }); + + test("should detect deprecated warnings", () => { + expect(getLogType("Deprecated since version 2.0").type).toBe("warning"); + }); + }); + + describe("Success patterns", () => { + test("should detect [SUCCESS] format", () => { + expect(getLogType("[SUCCESS] Deployment completed").type).toBe("success"); + }); + + test("should detect 'listening on port' patterns", () => { + expect(getLogType("Server listening on port 3000").type).toBe("success"); + }); + + test("should detect 'successfully' patterns", () => { + expect(getLogType("Successfully connected to database").type).toBe( + "success", + ); + }); + + test("should detect success emojis", () => { + expect(getLogType("✅ Build completed").type).toBe("success"); + }); + }); + + describe("Debug patterns", () => { + test("should detect [DEBUG] format", () => { + expect(getLogType("[DEBUG] Processing request").type).toBe("debug"); + }); + + test("should detect HTTP method patterns", () => { + expect(getLogType("GET /api/users").type).toBe("debug"); + expect(getLogType("POST /api/login").type).toBe("debug"); + }); + }); + + describe("Info patterns (default)", () => { + test("should default to info for generic messages", () => { + expect(getLogType("Server started").type).toBe("info"); + }); + + test("should detect [INFO] format", () => { + expect(getLogType("[INFO] Application initialized").type).toBe("info"); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 80a79eb2b..d6e6e5f61 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -72,74 +72,255 @@ export function parseLogs(logString: string): LogLine[] { .filter((log) => log !== null); } -// Detect log type based on message content +interface LogPattern { + pattern: RegExp; + score: number; + type: LogType; +} + +// Priority for tie-breaking: higher priority wins when scores are equal +const LOG_TYPE_PRIORITY: Record = { + error: 5, + warning: 4, + success: 3, + info: 2, + debug: 1, +}; + +const isNegativeContext = (message: string, keyword: string): boolean => { + const lowerMessage = message.toLowerCase(); + const lowerKeyword = keyword.toLowerCase(); + + // Allow up to 3 words between negation and keyword + const negativePatterns = [ + `(?:not|no|without|won't|didn't|doesn't|never)\\s+(?:\\w+\\s+){0,3}${lowerKeyword}`, + `${lowerKeyword}\\s+(?:is|was)\\s+(?:not|never|avoided|prevented)`, + `prevent(?:s|ed|ing)?\\s+(?:\\w+\\s+){0,2}${lowerKeyword}`, + `avoid(?:s|ed|ing)?\\s+(?:\\w+\\s+){0,2}${lowerKeyword}`, + `if\\s+.*${lowerKeyword}`, + `when\\s+.*${lowerKeyword}`, + ]; + + return negativePatterns.some((pattern) => + new RegExp(pattern, "i").test(lowerMessage), + ); +}; + export const getLogType = (message: string): LogStyle => { const lowerMessage = message.toLowerCase(); - if ( - /(?:^|\s)(?:info|inf|information):?\s/i.test(lowerMessage) || - /\[(?:info|information)\]/i.test(lowerMessage) || - /\b(?:status|state|current|progress)\b:?\s/i.test(lowerMessage) || - /\b(?:processing|executing|performing)\b/i.test(lowerMessage) - ) { - return LOG_STYLES.info; - } + const scores: Record = { + error: 0, + warning: 0, + success: 0, + info: 0, + debug: 0, + }; + + const structuredPatterns: LogPattern[] = [ + { + pattern: /\[(?:error|err|fatal|crit(?:ical)?)\]/i, + score: 100, + type: "error", + }, + { + pattern: /^(?:error|err|fatal|crit(?:ical)?):?\s/i, + score: 100, + type: "error", + }, + { + pattern: /level=(?:error|fatal|crit(?:ical)?)/i, + score: 100, + type: "error", + }, + { + pattern: /severity=(?:error|fatal|crit(?:ical)?)/i, + score: 100, + type: "error", + }, + { pattern: /❌|🔴|🚨/, score: 100, type: "error" }, - if ( - /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) || - /\b(?:exception|failed|failure)\b/i.test(lowerMessage) || - /(?:stack\s?trace):\s*$/i.test(lowerMessage) || - /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) || - /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) || - /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) || - /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) || - /\[(?:error|err|fatal)\]/i.test(lowerMessage) || - /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) || - /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage) - ) { - return LOG_STYLES.error; + { + pattern: /\[(?:warn(?:ing)?|attention|notice)\]/i, + score: 100, + type: "warning", + }, + { pattern: /^(?:warn(?:ing)?):?\s/i, score: 100, type: "warning" }, + { pattern: /level=(?:warn(?:ing)?)/i, score: 100, type: "warning" }, + { pattern: /severity=(?:warn(?:ing)?)/i, score: 100, type: "warning" }, + { pattern: /⚠️|⚠|⛔/, score: 100, type: "warning" }, + + { pattern: /\[(?:info|information)\]/i, score: 100, type: "info" }, + { pattern: /^(?:info|information):?\s/i, score: 100, type: "info" }, + { pattern: /level=(?:info|information)/i, score: 100, type: "info" }, + + { pattern: /\[(?:debug|trace|verbose)\]/i, score: 100, type: "debug" }, + { pattern: /^(?:debug|trace):?\s/i, score: 100, type: "debug" }, + { pattern: /level=(?:debug|trace)/i, score: 100, type: "debug" }, + + { pattern: /\[(?:success|ok|done)\]/i, score: 100, type: "success" }, + { pattern: /✓|√|✅/, score: 100, type: "success" }, + { pattern: /\[ok\]/i, score: 100, type: "success" }, + ]; + + const contextualPatterns: LogPattern[] = [ + { + pattern: /(?:uncaught|unhandled)\s+(?:exception|error|rejection)/i, + score: 70, + type: "error", + }, + { + pattern: /(?:stack\s?trace|call\s+stack)(?::|$)/i, + score: 70, + type: "error", + }, + { + pattern: /^\s*at\s+[\w.<>$]+\s*\([^)]*:\d+:\d+\)/i, + score: 70, + type: "error", + }, + { pattern: /Error:\s+.*(?:at|in)\s+.*:\d+/i, score: 70, type: "error" }, + + { + pattern: /\b(?:status|code)[:\s]+(?:4\d{2}|5\d{2})\b/i, + score: 70, + type: "error", + }, + { + pattern: + /\b(?:4\d{2}|5\d{2})\s+(?:error|bad\s+request|unauthorized|forbidden|not\s+found|internal\s+server\s+error)/i, + score: 70, + type: "error", + }, + + { + pattern: /\b\w+\s+(?:failed|failure)\s+(?:with|at|in|on|for|to)/i, + score: 65, + type: "error", + }, + { pattern: /(?:failed|unable)\s+to\s+\w+/i, score: 60, type: "error" }, + { + pattern: /(?:error|exception)\s+(?:occurred|thrown|raised|caught)/i, + score: 60, + type: "error", + }, + { + pattern: /(?:connection|request|operation)\s+(?:failed|error)/i, + score: 60, + type: "error", + }, + { + pattern: /\b(?:errno|exitcode):\s*(?:\d+|[A-Z_]+)/i, + score: 60, + type: "error", + }, + { + pattern: /\b(?:crash(?:ed)?|fatal\s+error)\b/i, + score: 60, + type: "error", + }, + + { + pattern: /(?:deprecated|obsolete)(?:\s+since|\s+in|\s+as\s+of)/i, + score: 60, + type: "warning", + }, + { pattern: /(?:caution|attention|notice):/i, score: 60, type: "warning" }, + { + pattern: /!+\s*(?:warning|caution|attention)\s*!+/i, + score: 60, + type: "warning", + }, + { + pattern: + /(?:might|may|could)\s+(?:cause|lead\s+to)\s+(?:error|issue|problem)/i, + score: 50, + type: "warning", + }, + + { + pattern: + /(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|deployed|connected)/i, + score: 60, + type: "success", + }, + { + pattern: /(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i, + score: 60, + type: "success", + }, + { + pattern: /(?:connected|established|ready)\s+(?:to|for|on)/i, + score: 50, + type: "success", + }, + { + pattern: /\b(?:loaded|mounted|initialized)\s+successfully/i, + score: 60, + type: "success", + }, + + { + pattern: /^(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i, + score: 50, + type: "debug", + }, + { pattern: /\b(?:request|response|query):/i, score: 50, type: "debug" }, + ]; + + const keywordPatterns: LogPattern[] = [ + { + pattern: /\b(?:exception|crash|critical|fatal)\b/i, + score: 30, + type: "error", + }, + { + pattern: /\b(?:deprecated|obsolete|unstable|experimental)\b/i, + score: 25, + type: "warning", + }, + { + pattern: /\b(?:success(?:ful)?|completed|ready)\b/i, + score: 20, + type: "success", + }, + ]; + + for (const { pattern, score, type } of structuredPatterns) { + if (pattern.test(lowerMessage)) { + scores[type] += score; + } } - if ( - /(?:^|\s)(?:warning|warn):?\s/i.test(lowerMessage) || - /\[(?:warn(?:ing)?|attention)\]/i.test(lowerMessage) || - /(?:deprecated|obsolete)\s+(?:since|in|as\s+of)/i.test(lowerMessage) || - /\b(?:caution|attention|notice):\s/i.test(lowerMessage) || - /(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) || - /(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) || - /\b(?:deprecated|obsolete)\b/i.test(lowerMessage) || - /\b(?:unstable|experimental)\b/i.test(lowerMessage) || - /⚠|⚠️/i.test(lowerMessage) - ) { - return LOG_STYLES.warning; + for (const { pattern, score, type } of contextualPatterns) { + if (pattern.test(lowerMessage)) { + scores[type] += score; + } } - if ( - /(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|done|deployed)/i.test( - lowerMessage, - ) || - /\[(?:success|ok|done)\]/i.test(lowerMessage) || - /(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i.test(lowerMessage) || - /(?:connected|established|ready)\s+(?:to|for|on)/i.test(lowerMessage) || - /\b(?:loaded|mounted|initialized)\s+successfully\b/i.test(lowerMessage) || - /✓|√|✅|\[ok\]|done!/i.test(lowerMessage) || - /\b(?:success(?:ful)?|completed|ready)\b/i.test(lowerMessage) || - /\b(?:started|starting|active)\b/i.test(lowerMessage) - ) { - return LOG_STYLES.success; + for (const { pattern, score, type } of keywordPatterns) { + const match = pattern.exec(lowerMessage); + if (match && !isNegativeContext(message, match[0])) { + scores[type] += score; + } } - if ( - /(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) || - /\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test( - lowerMessage, - ) || - /\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test( - lowerMessage, - ) - ) { - return LOG_STYLES.debug; + let maxScore = 0; + let detectedType: LogType = "info"; + + for (const [type, score] of Object.entries(scores)) { + const logType = type as LogType; + // Use explicit priority for tie-breaking when scores are equal + if ( + score > maxScore || + (score === maxScore && + score > 0 && + LOG_TYPE_PRIORITY[logType] > LOG_TYPE_PRIORITY[detectedType]) + ) { + maxScore = score; + detectedType = logType; + } } - return LOG_STYLES.info; + return LOG_STYLES[detectedType]; };