diff --git a/docs/example_custom_fields.log b/docs/example_custom_fields.log new file mode 100644 index 0000000..16a6a7c --- /dev/null +++ b/docs/example_custom_fields.log @@ -0,0 +1,10 @@ +[2025-01-15 08:00:01.123] [INFO] [C1 +0001] START TaskA modA : 初始化系统 +[2025-01-15 08:00:02.456] [DEBUG] [C1 +0002] WAIT TaskB modB : 等待资源分配 +[2025-01-15 08:00:03.789] [INFO] [C2 +0001] START TaskA modA : 开始处理请求 +[2025-01-15 08:00:04.012] [WARN] [C1 +0003] EMIT TaskC modC : 发送警告信号 +[2025-01-15 08:00:05.345] [ERROR] [C2 +0002] FAIL TaskB modB : 连接超时 +[2025-01-15 08:00:06.678] [INFO] [C1 +0004] PASS TaskA modA : 测试通过 +[2025-01-15 08:00:07.901] [DEBUG] [C3 +0001] READY TaskD modD : 就绪状态 +[2025-01-15 08:00:08.234] [INFO] [C2 +0003] END TaskC modC : 任务完成 +[2025-01-15 08:00:09.567] [ERROR] [C1 +0005] FAIL TaskB modB : 数据校验失败 +[2025-01-15 08:00:10.890] [INFO] [C3 +0002] START TaskA modA : 重新初始化 diff --git a/resources/icons/template.png b/resources/icons/template.png new file mode 100644 index 0000000..2417d36 Binary files /dev/null and b/resources/icons/template.png differ diff --git a/resources/resources.qrc b/resources/resources.qrc index 604ae44..3099923 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -6,6 +6,7 @@ icons/filter.png icons/toggle.png icons/export.png + icons/template.png diff --git a/src/core/logentry.h b/src/core/logentry.h index f85b0bd..9138376 100644 --- a/src/core/logentry.h +++ b/src/core/logentry.h @@ -14,6 +14,7 @@ #define LOGENTRY_H #include +#include #include #include @@ -75,10 +76,17 @@ struct LogEntry */ QString message; + QMap extraFields; + + QString rawLine; // 原始行文本(未匹配时用于显示) + bool matched = true; // 是否匹配模板 + bool operator==(const LogEntry& other) const { return timestamp == other.timestamp && level == other.level && - module == other.module && message == other.message; + module == other.module && message == other.message && + extraFields == other.extraFields && rawLine == other.rawLine && + matched == other.matched; } }; diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp index ffa79ba..8c02f5a 100644 --- a/src/core/logexporter.cpp +++ b/src/core/logexporter.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) #include @@ -218,8 +219,12 @@ bool LogExporter::exportToTxt(const QList& logs, // Write each log entry as a formatted line for (int i = 0; i < logs.size(); ++i) { const LogEntry& entry = logs[i]; - QString line = formatLogEntry(entry, config, ExportConfig::TXT); - out << line << "\n"; + if (!entry.matched) { + out << entry.rawLine << "\n"; + } else { + QString line = formatLogEntry(entry, config, ExportConfig::TXT); + out << line << "\n"; + } if (i % 1000 == 0 || i == logs.size() - 1) emitProgress(i + 1, logs.size()); @@ -272,13 +277,34 @@ bool LogExporter::exportToCsv(const QList& logs, if (config.includeContent) headers << QObject::tr("内容"); + // Collect extra field names from all entries + QSet extraFieldNameSet; + for (const LogEntry& entry : logs) { + for (auto it = entry.extraFields.constBegin(); + it != entry.extraFields.constEnd(); ++it) { + extraFieldNameSet.insert(it.key()); + } + } + QStringList extraFieldNames(extraFieldNameSet.constBegin(), extraFieldNameSet.constEnd()); + extraFieldNames.sort(); + headers << extraFieldNames; + out << headers.join(",") << "\n"; // Write each log entry as a CSV row + int totalColumns = headers.size(); for (int i = 0; i < logs.size(); ++i) { const LogEntry& entry = logs[i]; - QString line = formatLogEntry(entry, config, ExportConfig::CSV); - out << line << "\n"; + if (!entry.matched) { + // Unmatched line: rawLine in first column, rest empty + out << escapeForCsv(entry.rawLine); + for (int c = 1; c < totalColumns; ++c) + out << ","; + out << "\n"; + } else { + QString line = formatLogEntry(entry, config, ExportConfig::CSV, extraFieldNames); + out << line << "\n"; + } if (i % 1000 == 0 || i == logs.size() - 1) emitProgress(i + 1, logs.size()); @@ -337,26 +363,36 @@ bool LogExporter::exportToJson(const QList& logs, return json.mid(1, json.size() - 2); // strip [ and ] }; - bool first = true; - auto writeField = [&](const QString& key, const QString& value) { - if (!first) - out << ",\n"; - first = false; - out << " " << jsonEscape(key) << ": " << jsonEscape(value); - }; - - if (config.includeTimestamp) { - writeField("timestamp", - entry.timestamp.toString("yyyy-MM-dd HH:mm:ss.zzz")); - } - if (config.includeLevel) { - writeField("level", entry.level); - } - if (config.includeModule) { - writeField("module", entry.module); - } - if (config.includeContent) { - writeField("content", entry.message); + if (!entry.matched) { + out << " " << jsonEscape("raw") << ": " << jsonEscape(entry.rawLine); + } else { + bool first = true; + auto writeField = [&](const QString& key, const QString& value) { + if (!first) + out << ",\n"; + first = false; + out << " " << jsonEscape(key) << ": " << jsonEscape(value); + }; + + if (config.includeTimestamp) { + writeField("timestamp", + entry.timestamp.toString("yyyy-MM-dd HH:mm:ss.zzz")); + } + if (config.includeLevel) { + writeField("level", entry.level); + } + if (config.includeModule) { + writeField("module", entry.module); + } + if (config.includeContent) { + writeField("content", entry.message); + } + + // Add extra fields + for (auto it = entry.extraFields.constBegin(); + it != entry.extraFields.constEnd(); ++it) { + writeField(it.key(), it.value()); + } } out << "\n }"; @@ -386,7 +422,8 @@ bool LogExporter::exportToJson(const QList& logs, */ QString LogExporter::formatLogEntry(const LogEntry& entry, const ExportConfig& config, - ExportConfig::Format format) + ExportConfig::Format format, + const QStringList& extraFieldNames) { QStringList fields; @@ -422,6 +459,18 @@ QString LogExporter::formatLogEntry(const LogEntry& entry, fields << content; } + // Add extra fields (use known field names for consistent column count) + const QStringList& names = extraFieldNames.isEmpty() + ? QStringList(entry.extraFields.keys()) + : extraFieldNames; + for (const QString& name : names) { + QString value = entry.extraFields.value(name); + if (format == ExportConfig::CSV) { + value = escapeForCsv(value); + } + fields << value; + } + // Join fields with appropriate separator if (format == ExportConfig::CSV) { return fields.join(","); diff --git a/src/core/logexporter.h b/src/core/logexporter.h index 6da3766..382d42c 100644 --- a/src/core/logexporter.h +++ b/src/core/logexporter.h @@ -269,7 +269,8 @@ class LogExporter : public QObject * settings. */ QString formatLogEntry(const LogEntry& entry, const ExportConfig& config, - ExportConfig::Format format); + ExportConfig::Format format, + const QStringList& extraFieldNames = QStringList()); /** * @brief Escape special characters for CSV format diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index 53b7972..19608b7 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -16,7 +16,7 @@ const QStringList LogFormatTemplate::KNOWN_FIELDS = { // Common log level names static const QSet LOG_LEVELS = { "TRACE", "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "FATAL", "SEVERE", - "START", "END", "EMIT", "WAIT", "READY", "PASS", "FAIL" + "START", "END", "EMIT", "WAIT", "READY", "PASS", "FAIL", "FAULT" }; // Timestamp patterns @@ -67,6 +67,22 @@ QString LogFormatTemplate::errorMessage() const return m_errorMessage; } +QStringList LogFormatTemplate::allFieldNames() const +{ + return m_captureMap.keys(); +} + +QStringList LogFormatTemplate::extraFieldNames() const +{ + QStringList result; + for (auto it = m_captureMap.constBegin(); it != m_captureMap.constEnd(); ++it) { + if (!KNOWN_FIELDS.contains(it.key())) { + result.append(it.key()); + } + } + return result; +} + void LogFormatTemplate::compile() { m_captureMap.clear(); @@ -83,6 +99,7 @@ void LogFormatTemplate::compile() int captureCount = 0; int i = 0; const int len = m_template.length(); + bool prevWasPlaceholder = false; while (i < len) { QChar ch = m_template[i]; @@ -92,6 +109,7 @@ void LogFormatTemplate::compile() QChar next = m_template[i + 1]; if (next == '{' || next == '}' || next == '\\') { regexStr += QRegularExpression::escape(QString(next)); + prevWasPlaceholder = false; i += 2; continue; } @@ -106,11 +124,6 @@ void LogFormatTemplate::compile() } QString fieldName = m_template.mid(i + 1, closeBrace - i - 1); - if (!KNOWN_FIELDS.contains(fieldName)) { - m_errorMessage = - QStringLiteral("Unknown placeholder '{%1}'").arg(fieldName); - return; - } if (m_captureMap.contains(fieldName)) { m_errorMessage = QStringLiteral("Duplicate placeholder '{%1}'").arg(fieldName); @@ -125,15 +138,66 @@ void LogFormatTemplate::compile() } else if (fieldName == QStringLiteral("timestamp")) { regexStr += QStringLiteral("(.*?)"); } else { - regexStr += QStringLiteral("(\\S+)"); + // Check if this placeholder is inside brackets + // by looking at the preceding non-whitespace in the regex + bool insideBrackets = false; + for (int j = regexStr.length() - 1; j >= 0; --j) { + if (regexStr[j].isSpace()) + continue; + if (regexStr[j] == QLatin1Char('[') && + j > 0 && regexStr[j - 1] == QLatin1Char('\\')) { + insideBrackets = true; + } + break; + } + if (insideBrackets) { + // Inside brackets: match everything except ']' + // PCRE: [^]] means "not ]", the first ] closes the class + regexStr += QChar::fromLatin1('('); + regexStr += QChar::fromLatin1('['); + regexStr += QChar::fromLatin1('^'); + regexStr += QChar::fromLatin1(']'); + regexStr += QChar::fromLatin1(']'); + regexStr += QChar::fromLatin1('+'); + regexStr += QChar::fromLatin1(')'); + } else { + // Match non-whitespace: need \\S in the regex string + regexStr += QChar::fromLatin1('('); + regexStr += QChar::fromLatin1('\\'); + regexStr += QChar::fromLatin1('S'); + regexStr += QChar::fromLatin1('+'); + regexStr += QChar::fromLatin1(')'); + } } + prevWasPlaceholder = true; i = closeBrace + 1; continue; } + // Handle whitespace: use \s+ between placeholders for flexible spacing + if (ch.isSpace()) { + // Collect the run of whitespace + int spaceStart = i; + while (i < len && m_template[i].isSpace()) + i++; + + // Check if next non-space char starts a placeholder + bool nextIsPlaceholder = (i < len && m_template[i] == '{'); + + if (prevWasPlaceholder && nextIsPlaceholder) { + // Between two placeholders: use \s+ for flexible matching + regexStr += QStringLiteral("\\s+"); + } else { + // Literal whitespace: preserve as-is + regexStr += QRegularExpression::escape(m_template.mid(spaceStart, i - spaceStart)); + } + continue; + } + // Literal character: escape for regex regexStr += QRegularExpression::escape(QString(ch)); + prevWasPlaceholder = false; i++; } @@ -155,11 +219,39 @@ void LogFormatTemplate::compile() LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) { + return LogFormatTemplate(detectWithInfo(sampleLines).templateStr); +} + +LogFormatTemplate::DetectInfo LogFormatTemplate::detectWithInfo(const QStringList& sampleLines) +{ + DetectInfo info; if (sampleLines.isEmpty()) { - return LogFormatTemplate(); + info.reason = QStringLiteral("无样本行"); + return info; + } + + int threshold = qMax(1, sampleLines.size() / 5); + int totalLines = sampleLines.size(); + + // Step 1: Smart analysis - detect field positions dynamically. + QString smartTemplate = analyzeLineStructure(sampleLines); + int smartMatchCount = 0; + int smartFieldCount = 0; + if (!smartTemplate.isEmpty()) { + LogFormatTemplate fmt(smartTemplate); + if (fmt.isValid()) { + smartFieldCount = fmt.allFieldNames().size(); + for (const QString& line : sampleLines) { + if (fmt.regex().match(line).hasMatch()) { + smartMatchCount++; + } + } + } } - // Step 1: Try preset templates + // Step 2: Find best preset (for fallback comparison) + static const QRegularExpression tsValidation( + R"(\d{4}[-/]\d{2}[-/]\d{2})"); const auto presetList = presets(); int bestPresetIndex = -1; int bestPresetCount = 0; @@ -169,11 +261,18 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) if (!fmt.isValid()) continue; + int tsIdx = fmt.captureIndex("timestamp"); int matchCount = 0; for (const QString& line : sampleLines) { - if (fmt.regex().match(line).hasMatch()) { - matchCount++; + QRegularExpressionMatch m = fmt.regex().match(line); + if (!m.hasMatch()) + continue; + if (tsIdx >= 0) { + QString ts = m.captured(tsIdx).trimmed(); + if (!tsValidation.match(ts).hasMatch()) + continue; } + matchCount++; } if (matchCount > bestPresetCount) { @@ -182,50 +281,102 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - int threshold = qMax(1, sampleLines.size() / 5); - if (bestPresetIndex >= 0 && bestPresetCount >= threshold) { - return LogFormatTemplate(presetList[bestPresetIndex].templateStr); - } + // Step 3: Choose the best template. + if (smartMatchCount >= threshold && smartFieldCount > 0) { + int presetFieldCount = (bestPresetIndex >= 0) + ? LogFormatTemplate(presetList[bestPresetIndex].templateStr).allFieldNames().size() + : 0; + + // Prefer preset when it matches well — it preserves semantic field + // names (e.g. module) that smart detection may replace with generic + // placeholders (e.g. field1). + if (bestPresetIndex >= 0 && bestPresetCount >= threshold + && presetFieldCount >= smartFieldCount) { + info.templateStr = presetList[bestPresetIndex].templateStr; + info.reason = QStringLiteral("使用预设'%1', 匹配%2/%3行") + .arg(presetList[bestPresetIndex].name) + .arg(bestPresetCount) + .arg(totalLines); + return info; + } - // Step 2: Smart analysis - try to detect field positions dynamically - QString smartTemplate = analyzeLineStructure(sampleLines); - if (!smartTemplate.isEmpty()) { - LogFormatTemplate fmt(smartTemplate); - if (fmt.isValid()) { - int matchCount = 0; - for (const QString& line : sampleLines) { - if (fmt.regex().match(line).hasMatch()) { - matchCount++; - } - } - if (matchCount >= threshold) { - return fmt; - } + if (smartFieldCount > presetFieldCount) { + info.templateStr = smartTemplate; + int extra = smartFieldCount - 4; // subtract known fields + info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行") + .arg(smartFieldCount) + .arg(smartMatchCount) + .arg(totalLines); + if (extra > 0) + info.reason += QStringLiteral(", 含%1个自定义字段").arg(extra); + return info; } + info.templateStr = smartTemplate; + info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行") + .arg(smartFieldCount) + .arg(smartMatchCount) + .arg(totalLines); + return info; + } + + // Step 4: Smart didn't work — use best preset + if (bestPresetIndex >= 0 && bestPresetCount >= threshold) { + info.templateStr = presetList[bestPresetIndex].templateStr; + info.reason = QStringLiteral("使用预设'%1', 匹配%2/%3行") + .arg(presetList[bestPresetIndex].name) + .arg(bestPresetCount) + .arg(totalLines); + return info; } - // Step 3: Fallback - timestamp only + // Step 5: Fallback - timestamp only (with validation) LogFormatTemplate fallback(QStringLiteral("[{timestamp}] {message}")); + int fbTsIdx = fallback.captureIndex("timestamp"); int fallbackCount = 0; for (const QString& line : sampleLines) { - if (fallback.regex().match(line).hasMatch()) { - fallbackCount++; + QRegularExpressionMatch m = fallback.regex().match(line); + if (!m.hasMatch()) + continue; + if (fbTsIdx >= 0) { + QString ts = m.captured(fbTsIdx).trimmed(); + if (!tsValidation.match(ts).hasMatch()) + continue; } + fallbackCount++; } if (fallbackCount >= threshold) { - return fallback; + info.templateStr = QStringLiteral("[{timestamp}] {message}"); + info.reason = QStringLiteral("回退: 仅检测到时间戳, 匹配%1/%2行") + .arg(fallbackCount) + .arg(totalLines); + return info; } - return LogFormatTemplate(); + info.reason = QStringLiteral("无法识别日志格式"); + return info; } QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) { - // Find lines that look like actual log entries (with timestamp) + // Find lines that look like actual log entries (with timestamp), + // skipping separator/banner lines (e.g. "===...", "---...") QStringList logLines; for (const QString& line : lines) { if (TS_BRACKETED.match(line).hasMatch() || TS_BARE.match(line).hasMatch()) { - logLines.append(line); + // Skip separator lines: content after timestamp has no letters + QRegularExpressionMatch tsMatch = TS_BRACKETED.match(line); + if (!tsMatch.hasMatch()) + tsMatch = TS_BARE.match(line); + QString afterTs = line.mid(tsMatch.capturedEnd()).trimmed(); + bool hasLetter = false; + for (const QChar& c : afterTs) { + if (c.isLetter()) { + hasLetter = true; + break; + } + } + if (hasLetter) + logLines.append(line); } } @@ -233,87 +384,114 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) return QString(); } - // Analyze first few log lines to determine structure int analyzeCount = qMin(10, logLines.size()); - QMap fieldPositions; // field name -> position count - for (int i = 0; i < analyzeCount; ++i) { - const QString& line = logLines[i]; - - // Find timestamp - QRegularExpressionMatch tsMatch; - bool tsBracketed = true; - tsMatch = TS_BRACKETED.match(line); - if (!tsMatch.hasMatch()) { - tsMatch = TS_BARE.match(line); - tsBracketed = false; + // Determine timestamp format + bool useBracketedTs = false; + for (int i = 0; i < qMin(5, logLines.size()); ++i) { + if (TS_BRACKETED.match(logLines[i]).hasMatch()) { + useBracketedTs = true; + break; } + } - if (!tsMatch.hasMatch()) - continue; + // Parse first line to determine structure + QRegularExpressionMatch firstTsMatch; + firstTsMatch = useBracketedTs ? TS_BRACKETED.match(logLines[0]) + : TS_BARE.match(logLines[0]); + if (!firstTsMatch.hasMatch()) + return QString(); - int tsStart = tsMatch.capturedStart(); - int tsEnd = tsMatch.capturedEnd(); + QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed(); - // Get content after timestamp - QString afterTs = line.mid(tsEnd).trimmed(); + // Collect bracketed fields + QRegularExpression bracketRe(R"(\[([^\]]+)\])"); + int pos = 0; + struct BracketInfo { QString content; bool isLevel; }; + QList bracketFields; - // Check if there's a second bracketed field (level or cycle info) - QRegularExpression bracketField(R"(^\s*\[([^\]]+)\])"); - QRegularExpressionMatch bracketMatch = bracketField.match(afterTs); + while (pos < afterTs.length()) { + QRegularExpressionMatch m = bracketRe.match(afterTs, pos); + if (!m.hasMatch()) + break; - QString level; - QString rest; + // Check there's only whitespace between previous end and this match + QString between = afterTs.mid(pos, m.capturedStart() - pos).trimmed(); + if (!between.isEmpty()) + break; // Non-bracket content found, stop - if (bracketMatch.hasMatch()) { - // Has bracketed field after timestamp - level = bracketMatch.captured(1).trimmed(); - rest = afterTs.mid(bracketMatch.capturedEnd()).trimmed(); - } else { - // No bracket - check for bare level word - QStringList words = afterTs.split(QRegularExpression("\\s+"), - Qt::SkipEmptyParts); - if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) { - level = words[0]; - rest = words.mid(1).join(" "); - } - } + QString content = m.captured(1).trimmed(); + bool isLevel = !content.contains(' ') && + LOG_LEVELS.contains(content.toUpper()); + bracketFields.append({content, isLevel}); + pos = m.capturedEnd(); + } - // Try to detect module name (first word of rest that looks like identifier) - if (!rest.isEmpty()) { - QStringList words = rest.split(QRegularExpression("\\s+"), - Qt::SkipEmptyParts); - if (!words.isEmpty()) { - QString firstWord = words[0]; - // Check if it looks like a module name (alphanumeric, no special chars) - if (QRegularExpression("^[A-Za-z][A-Za-z0-9_]*$").match(firstWord) - .hasMatch()) { - // Likely a module name - if (!level.isEmpty()) { - fieldPositions["has_module"]++; - } - } - } + // Get remaining text after all brackets + QString afterBrackets = afterTs.mid(pos).trimmed(); + QStringList remainingWords = afterBrackets.split(QRegularExpression("\\s+"), + Qt::SkipEmptyParts); + + // Analyze remaining words to identify fields + // Strategy: check multiple lines to find consistent word count before key=value pairs + int wordsBeforeKV = remainingWords.size(); // default: all words are fields + for (int wi = 0; wi < remainingWords.size(); ++wi) { + if (remainingWords[wi].contains('=')) { + wordsBeforeKV = wi; + break; } + } - if (!level.isEmpty()) { - fieldPositions["has_level"]++; - } + // Check if the first remaining word is a known log level/state + bool hasBareLevel = false; + if (!remainingWords.isEmpty() && + LOG_LEVELS.contains(remainingWords[0].toUpper())) { + hasBareLevel = true; } - // Build template based on analysis - bool hasLevel = fieldPositions.value("has_level", 0) > analyzeCount / 2; - bool hasModule = fieldPositions.value("has_module", 0) > analyzeCount / 2; + // Validate across multiple lines: count how many have the same bracket count + int bracketCount = bracketFields.size(); + int consistentBracketCount = 0; + int consistentBareLevel = 0; + int consistentWordsBeforeKV = 0; - // Determine timestamp format - bool useBracketedTs = false; - for (int i = 0; i < qMin(5, logLines.size()); ++i) { - if (TS_BRACKETED.match(logLines[i]).hasMatch()) { - useBracketedTs = true; - break; + for (int i = 0; i < analyzeCount; ++i) { + QRegularExpressionMatch tsM = useBracketedTs + ? TS_BRACKETED.match(logLines[i]) + : TS_BARE.match(logLines[i]); + if (!tsM.hasMatch()) + continue; + QString after = logLines[i].mid(tsM.capturedEnd()).trimmed(); + + // Count brackets + int bc = 0; + int p = 0; + while (p < after.length()) { + QRegularExpressionMatch bm = bracketRe.match(after, p); + if (!bm.hasMatch()) break; + QString gap = after.mid(p, bm.capturedStart() - p).trimmed(); + if (!gap.isEmpty()) break; + bc++; + p = bm.capturedEnd(); + } + if (bc == bracketCount) consistentBracketCount++; + + // Check bare level + QString rest = after.mid(p).trimmed(); + QStringList words = rest.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) { + consistentBareLevel++; } + + // Count words before first key=value + int wkv = words.size(); + for (int wi = 0; wi < words.size(); ++wi) { + if (words[wi].contains('=')) { wkv = wi; break; } + } + if (wkv == wordsBeforeKV) consistentWordsBeforeKV++; } + // Build template QString templateStr; if (useBracketedTs) { templateStr = "[{timestamp}]"; @@ -321,36 +499,65 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) templateStr = "{timestamp}"; } - if (hasLevel) { - // Check if level is in brackets - QRegularExpression bracketLevel(R"(\[\s*\w+\s*\])"); - bool levelInBrackets = false; - for (int i = 0; i < qMin(5, logLines.size()); ++i) { - QRegularExpressionMatch tsMatch = useBracketedTs - ? TS_BRACKETED.match(logLines[i]) - : TS_BARE.match(logLines[i]); - if (tsMatch.hasMatch()) { - QString afterTs = logLines[i].mid(tsMatch.capturedEnd()).trimmed(); - if (bracketLevel.match(afterTs).hasMatch()) { - levelInBrackets = true; - break; - } - } - } - - if (levelInBrackets) { + // Add bracketed fields + int extraFieldIdx = 1; + bool levelPlaced = false; + for (const BracketInfo& bi : bracketFields) { + if (bi.isLevel && !levelPlaced) { templateStr += " [{level}]"; + levelPlaced = true; } else { - templateStr += " {level}"; + templateStr += QString(" [{field%1}]").arg(extraFieldIdx); + extraFieldIdx++; } } - if (hasModule) { - templateStr += " {module}"; + // Build a base template (bracket fields only, no bare word fields) + QString baseTemplate = templateStr + " {message}"; + + // Try adding bare level if found consistently + if (!levelPlaced && consistentBareLevel > analyzeCount / 2) { + templateStr += " {level}"; + levelPlaced = true; + } + + // Add remaining words as fields (before key=value part) + if (consistentWordsBeforeKV > analyzeCount / 2) { + int startWord = hasBareLevel && levelPlaced ? 1 : 0; + // First word after level is typically the module/task ID + if (startWord < wordsBeforeKV) { + templateStr += " {module}"; + startWord++; + } + for (int wi = startWord; wi < wordsBeforeKV; ++wi) { + templateStr += QString(" {field%1}").arg(extraFieldIdx); + extraFieldIdx++; + } } templateStr += " {message}"; + // Validate: if the detailed template doesn't match well due to + // multi-space alignment or other issues, fall back to the simpler base + if (!bracketFields.isEmpty()) { + LogFormatTemplate detailed(templateStr); + LogFormatTemplate simple(baseTemplate); + if (detailed.isValid() && simple.isValid()) { + int detailedCount = 0; + int simpleCount = 0; + for (int i = 0; i < analyzeCount; ++i) { + if (detailed.regex().match(logLines[i]).hasMatch()) + detailedCount++; + if (simple.regex().match(logLines[i]).hasMatch()) + simpleCount++; + } + // Use the simpler template if it matches significantly better + if (simpleCount > detailedCount) { + return baseTemplate; + } + } + } + return templateStr; } diff --git a/src/core/logformattemplate.h b/src/core/logformattemplate.h index 365799b..9c58f58 100644 --- a/src/core/logformattemplate.h +++ b/src/core/logformattemplate.h @@ -13,6 +13,11 @@ class LogFormatTemplate { QString templateStr; }; + struct DetectInfo { + QString templateStr; + QString reason; + }; + LogFormatTemplate(); explicit LogFormatTemplate(const QString& templateStr); @@ -23,14 +28,18 @@ class LogFormatTemplate { bool isValid() const; QString errorMessage() const; + QStringList allFieldNames() const; + QStringList extraFieldNames() const; + static LogFormatTemplate detect(const QStringList& sampleLines); + static DetectInfo detectWithInfo(const QStringList& sampleLines); static QList presets(); + static QString analyzeLineStructure(const QStringList& lines); static const QString DEFAULT_TEMPLATE; static const QStringList KNOWN_FIELDS; private: void compile(); - static QString analyzeLineStructure(const QStringList& lines); QString m_template; QRegularExpression m_regex; diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp index eff98f8..07897ab 100644 --- a/src/core/logloader.cpp +++ b/src/core/logloader.cpp @@ -62,9 +62,17 @@ void LogLoader::process() sampleLines.append(in.readLine()); } in.seek(startPos); - fmt = LogFormatTemplate::detect(sampleLines); + LogFormatTemplate::DetectInfo detectInfo = + LogFormatTemplate::detectWithInfo(sampleLines); + if (detectInfo.templateStr.isEmpty()) { + detectInfo.templateStr = LogFormatTemplate::DEFAULT_TEMPLATE; + detectInfo.reason = QObject::tr("自动检测失败,使用默认模板"); + } + fmt = LogFormatTemplate(detectInfo.templateStr); + emit detectInfoReady(detectInfo.templateStr, detectInfo.reason); } else { fmt = LogFormatTemplate(m_formatTemplate); + emit detectInfoReady(m_formatTemplate, QString()); } if (!fmt.isValid()) { @@ -78,11 +86,24 @@ void LogLoader::process() QVector buffer; buffer.reserve(m_chunkSize); + // Cache capture indices outside the loop + int tsIdx = fmt.captureIndex("timestamp"); + int lvIdx = fmt.captureIndex("level"); + int modIdx = fmt.captureIndex("module"); + int msgIdx = fmt.captureIndex("message"); + QStringList extraFieldNames = fmt.extraFieldNames(); + QVector extraIdxList; + extraIdxList.reserve(extraFieldNames.size()); + for (const QString& fn : extraFieldNames) { + extraIdxList.append(fmt.captureIndex(fn)); + } + bool hasTime = false; QDateTime minTime; QDateTime maxTime; QSet modulesSet; QSet levelsSet; + QMap> extraFieldSets; qint64 totalBytes = file.size(); qint64 processedBytes = 0; @@ -98,19 +119,14 @@ void LogLoader::process() QRegularExpressionMatch match = regex.match(line); if (match.hasMatch()) { LogEntry entry; - int tsIdx = fmt.captureIndex("timestamp"); - int lvIdx = fmt.captureIndex("level"); - int modIdx = fmt.captureIndex("module"); - int msgIdx = fmt.captureIndex("message"); + entry.rawLine = line; + entry.matched = true; if (tsIdx >= 0) { - entry.timestamp = QDateTime::fromString( - match.captured(tsIdx).trimmed(), - "yyyy-MM-dd HH:mm:ss.zzz"); + QString tsStr = match.captured(tsIdx).trimmed(); + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss.zzz"); if (!entry.timestamp.isValid()) { - entry.timestamp = QDateTime::fromString( - match.captured(tsIdx).trimmed(), - "yyyy-MM-dd HH:mm:ss"); + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss"); } } entry.level = (lvIdx >= 0) ? match.captured(lvIdx).trimmed() @@ -120,6 +136,15 @@ void LogLoader::process() entry.message = (msgIdx >= 0) ? match.captured(msgIdx) : QString(); + for (int ei = 0; ei < extraFieldNames.size(); ++ei) { + int idx = extraIdxList[ei]; + if (idx >= 0) { + QString value = match.captured(idx).trimmed(); + entry.extraFields[extraFieldNames[ei]] = value; + extraFieldSets[extraFieldNames[ei]].insert(value); + } + } + if (entry.timestamp.isValid()) { if (!hasTime) { minTime = maxTime = entry.timestamp; @@ -131,19 +156,27 @@ void LogLoader::process() maxTime = entry.timestamp; } } - modulesSet.insert(entry.module); - levelsSet.insert(entry.level); + if (!entry.module.isEmpty()) + modulesSet.insert(entry.module); + if (!entry.level.isEmpty()) + levelsSet.insert(entry.level); buffer.append(entry); - if (buffer.size() >= m_chunkSize) { - emit chunkReady(buffer); - buffer.clear(); - // 仅在块处理完成时计算和发射进度,减少计算频率 - if (totalBytes > 0) { - int percent = - static_cast((processedBytes * 100) / totalBytes); - emit progress(percent); - } + } else { + // Unmatched line: preserve as raw text + LogEntry entry; + entry.rawLine = line; + entry.matched = false; + buffer.append(entry); + } + + if (buffer.size() >= m_chunkSize) { + emit chunkReady(buffer); + buffer.clear(); + if (totalBytes > 0) { + int percent = + static_cast((processedBytes * 100) / totalBytes); + emit progress(percent); } } } @@ -160,7 +193,15 @@ void LogLoader::process() QStringList levels = QStringList(levelsSet.cbegin(), levelsSet.cend()); modules.sort(Qt::CaseInsensitive); levels.sort(Qt::CaseInsensitive); - emit summaryReady(minTime, maxTime, modules, levels); + + QMap extraFieldValues; + for (auto it = extraFieldSets.constBegin(); it != extraFieldSets.constEnd(); ++it) { + QStringList values = QStringList(it.value().cbegin(), it.value().cend()); + values.sort(Qt::CaseInsensitive); + extraFieldValues[it.key()] = values; + } + + emit summaryReady(minTime, maxTime, modules, levels, extraFieldNames, extraFieldValues); emit progress(100); emit finished(); } diff --git a/src/core/logloader.h b/src/core/logloader.h index 7688bda..cf8e894 100644 --- a/src/core/logloader.h +++ b/src/core/logloader.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -31,7 +32,10 @@ public slots: void chunkReady(QVector chunk); void progress(int percentage); void summaryReady(const QDateTime& minTime, const QDateTime& maxTime, - const QStringList& modules, const QStringList& levels); + const QStringList& modules, const QStringList& levels, + const QStringList& extraFieldNames, + const QMap& extraFieldValues); + void detectInfoReady(const QString& templateStr, const QString& reason); void finished(); void error(const QString& message); diff --git a/src/main.cpp b/src/main.cpp index 8e26cba..cc440ce 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -70,6 +70,7 @@ int main(int argc, char* argv[]) // Register metatypes for cross-thread signal-slot connections qRegisterMetaType("LogEntry"); qRegisterMetaType>("QVector"); + qRegisterMetaType>("QMap"); // Initialize language management system LanguageManager::instance().initialize(); diff --git a/src/ui/highlightdelegate.cpp b/src/ui/highlightdelegate.cpp index 2199477..f42a664 100644 --- a/src/ui/highlightdelegate.cpp +++ b/src/ui/highlightdelegate.cpp @@ -24,8 +24,8 @@ void HighlightDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opt QStyleOptionViewItem opt(option); initStyleOption(&opt, index); - // Only highlight on the content column (last column) - if (m_text.isEmpty() || index.column() != index.model()->columnCount() - 1) { + // Only highlight on the message column (fixed index 4) + if (m_text.isEmpty() || index.column() != 4) { QStyledItemDelegate::paint(painter, opt, index); return; } diff --git a/src/ui/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp index 181672c..9bcae50 100644 --- a/src/ui/logfilterproxymodel.cpp +++ b/src/ui/logfilterproxymodel.cpp @@ -1,5 +1,6 @@ #include "logfilterproxymodel.h" +#include #include #include "logtablemodel.h" @@ -20,17 +21,43 @@ void LogFilterProxyModel::setTimeRange(const QDateTime& start, const QDateTime& void LogFilterProxyModel::setLevels(const QStringList& levels) { m_levelSet = QSet(levels.cbegin(), levels.cend()); + m_levelFilterActive = true; invalidateFilter(); } void LogFilterProxyModel::setModules(const QStringList& modules) { m_moduleSet = QSet(modules.cbegin(), modules.cend()); + m_moduleFilterActive = true; + invalidateFilter(); +} + +void LogFilterProxyModel::setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues) +{ + m_extraFilters[fieldName] = acceptedValues; + invalidateFilter(); +} + +void LogFilterProxyModel::clearExtraFieldFilters() +{ + m_extraFilters.clear(); + invalidateFilter(); +} + +void LogFilterProxyModel::setHideUnmatched(bool hide) +{ + m_hideUnmatched = hide; invalidateFilter(); } bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& source_parent) const { + // Unmatched lines: hide if m_hideUnmatched is set, otherwise always pass + QModelIndex msgIndex = sourceModel()->index(source_row, LogTableModel::ColumnMessage, source_parent); + bool matched = sourceModel()->data(msgIndex, LogTableModel::MatchedRole).toBool(); + if (!matched) + return !m_hideUnmatched; + QModelIndex tsIndex = sourceModel()->index(source_row, LogTableModel::ColumnTimestamp, source_parent); QModelIndex lvlIndex = sourceModel()->index(source_row, LogTableModel::ColumnLevel, source_parent); QModelIndex modIndex = sourceModel()->index(source_row, LogTableModel::ColumnModule, source_parent); @@ -45,12 +72,22 @@ bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& so return false; } - if (!m_levelSet.isEmpty() && !m_levelSet.contains(level)) + if (m_levelFilterActive && !m_levelSet.contains(level)) return false; - if (!m_moduleSet.isEmpty() && !m_moduleSet.contains(module)) + if (m_moduleFilterActive && !m_moduleSet.contains(module)) return false; + if (!m_extraFilters.isEmpty()) { + QModelIndex extraIndex = sourceModel()->index(source_row, LogTableModel::ColumnMessage, source_parent); + QMap extraFields = sourceModel()->data(extraIndex, LogTableModel::ExtraFieldsRole).value>(); + for (auto it = m_extraFilters.constBegin(); it != m_extraFilters.constEnd(); ++it) { + QString value = extraFields.value(it.key()); + if (!it.value().contains(value)) + return false; + } + } + return true; } diff --git a/src/ui/logfilterproxymodel.h b/src/ui/logfilterproxymodel.h index 26295da..a935980 100644 --- a/src/ui/logfilterproxymodel.h +++ b/src/ui/logfilterproxymodel.h @@ -2,6 +2,7 @@ #define LOGFILTERPROXYMODEL_H #include +#include #include #include #include @@ -20,6 +21,9 @@ class LogFilterProxyModel : public QSortFilterProxyModel void setTimeRange(const QDateTime& start, const QDateTime& end); void setLevels(const QStringList& levels); void setModules(const QStringList& modules); + void setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues); + void clearExtraFieldFilters(); + void setHideUnmatched(bool hide); protected: bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override; @@ -28,7 +32,11 @@ class LogFilterProxyModel : public QSortFilterProxyModel QDateTime m_start; QDateTime m_end; QSet m_levelSet; + bool m_levelFilterActive = false; QSet m_moduleSet; + bool m_moduleFilterActive = false; + QMap> m_extraFilters; + bool m_hideUnmatched = false; }; #endif // LOGFILTERPROXYMODEL_H diff --git a/src/ui/logtablemodel.cpp b/src/ui/logtablemodel.cpp index e26c08f..263738d 100644 --- a/src/ui/logtablemodel.cpp +++ b/src/ui/logtablemodel.cpp @@ -19,7 +19,7 @@ int LogTableModel::columnCount(const QModelIndex& parent) const { if (parent.isValid()) return 0; - return ColumnCount; + return ColumnCount + m_extraColumns.size(); } QVariant LogTableModel::data(const QModelIndex& index, int role) const @@ -34,6 +34,21 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const const LogEntry& entry = m_entries.at(row); + if (role == MatchedRole) + return entry.matched; + + if (!entry.matched) { + // Unmatched line: show rawLine in message column, line number only + if (role == Qt::DisplayRole) { + if (col == ColumnLine) + return row + 1; + if (col == ColumnMessage) + return entry.rawLine; + return QVariant(); + } + return QVariant(); + } + if (role == Qt::DisplayRole) { switch (col) { case ColumnLine: @@ -47,6 +62,10 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const case ColumnMessage: return entry.message; default: + if (col >= ColumnCount && col < ColumnCount + m_extraColumns.size()) { + int extraIdx = col - ColumnCount; + return entry.extraFields.value(m_extraColumns[extraIdx]); + } return QVariant(); } } @@ -59,6 +78,8 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const return entry.module; if (role == MessageRole) return entry.message; + if (role == ExtraFieldsRole) + return QVariant::fromValue(entry.extraFields); return QVariant(); } @@ -78,6 +99,9 @@ QVariant LogTableModel::headerData(int section, Qt::Orientation orientation, int case ColumnMessage: return QObject::tr("内容"); default: + if (section >= ColumnCount && section < ColumnCount + m_extraColumns.size()) { + return m_extraColumns[section - ColumnCount]; + } break; } } @@ -116,4 +140,11 @@ const LogEntry& LogTableModel::at(int row) const return m_entries.at(row); } +void LogTableModel::setExtraColumns(const QStringList& fieldNames) +{ + beginResetModel(); + m_extraColumns = fieldNames; + endResetModel(); +} + diff --git a/src/ui/logtablemodel.h b/src/ui/logtablemodel.h index d8f95cc..5cc6724 100644 --- a/src/ui/logtablemodel.h +++ b/src/ui/logtablemodel.h @@ -2,6 +2,7 @@ #define LOGTABLEMODEL_H #include +#include #include #include @@ -35,7 +36,9 @@ class LogTableModel : public QAbstractTableModel TimestampRole = Qt::UserRole + 1, LevelRole, ModuleRole, - MessageRole + MessageRole, + ExtraFieldsRole, + MatchedRole }; explicit LogTableModel(QObject* parent = nullptr); @@ -53,8 +56,13 @@ class LogTableModel : public QAbstractTableModel const LogEntry& at(int row) const; int size() const { return m_entries.size(); } + // Extra columns + void setExtraColumns(const QStringList& fieldNames); + QStringList extraColumns() const { return m_extraColumns; } + private: QVector m_entries; + QStringList m_extraColumns; }; #endif // LOGTABLEMODEL_H diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp index f247bee..e0c200f 100644 --- a/src/ui/logviewer.cpp +++ b/src/ui/logviewer.cpp @@ -45,6 +45,7 @@ #include "logtablemodel.h" #include "../core/logloader.h" +#include "../core/logformattemplate.h" #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) #include #else @@ -82,6 +83,10 @@ LogViewer::LogViewer(QWidget* parent) sourceModel(nullptr), proxyModel(nullptr), highlightDelegate(nullptr), + formatModeCombo(nullptr), + formatModeLabel(nullptr), + hideUnmatchedCheckBox(nullptr), + templateInfoLabel(nullptr), currentSearchIndex(-1), searchDebounceTimer(nullptr) { @@ -202,62 +207,6 @@ void LogViewer::setupUI() timeGroupBox = new QGroupBox(tr("时间范围"), this); timeGroupBox->setLayout(timeLayout); - // Log level selection controls - levelGroupBox = new QGroupBox(tr("日志等级"), this); - QHBoxLayout* levelLayout = new QHBoxLayout(); - levelGroupBox->setLayout(levelLayout); - - // Create log level checkboxes with sorted order - QStringList levels = {"DEBUG", "ERROR", "INFO", "WARN"}; - levels.sort(Qt::CaseInsensitive); // Sort alphabetically - - for (const QString& level : levels) { - QCheckBox* checkBox = new QCheckBox(level, this); - checkBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - levelCheckBoxes.append(checkBox); - levelLayout->addWidget(checkBox); - } - - // Add flexible space for left alignment - levelLayout->addStretch(); - - // Module selection controls - moduleLayout = new QHBoxLayout(); - - // Add "Select All" and "Deselect All" buttons for module selection - selectAllModulesButton = new QPushButton(tr("全选"), this); - deselectAllModulesButton = new QPushButton(tr("全不选"), this); - connect(selectAllModulesButton, &QPushButton::clicked, this, - &LogViewer::selectAllModules); - connect(deselectAllModulesButton, &QPushButton::clicked, this, - &LogViewer::deselectAllModules); - - QHBoxLayout* moduleButtonLayout = new QHBoxLayout(); - moduleButtonLayout->addWidget(selectAllModulesButton); - moduleButtonLayout->addWidget(deselectAllModulesButton); - moduleButtonLayout->addStretch(); - - // Add scroll area to support cases with many modules - QScrollArea* moduleScrollArea = new QScrollArea(this); - QWidget* moduleContainer = new QWidget(this); - moduleLayout->setContentsMargins(5, 5, 5, 5); - moduleContainer->setLayout(moduleLayout); - moduleScrollArea->setWidgetResizable(true); - moduleScrollArea->setWidget(moduleContainer); - - // Configure horizontal scrolling for module selection - moduleScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); - moduleScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - // Set fixed height to prevent vertical scrollbar - moduleScrollArea->setFixedHeight(60); // Adjust height as needed - - moduleGroupBox = new QGroupBox(tr("模块选择"), this); - QVBoxLayout* moduleGroupLayout = new QVBoxLayout(); - moduleGroupLayout->addLayout(moduleButtonLayout); // Add button layout - moduleGroupLayout->addWidget(moduleScrollArea); - moduleGroupBox->setLayout(moduleGroupLayout); - // Encoding selection encodingLabel = new QLabel(tr("文件编码:"), this); encodingComboBox = new QComboBox(this); @@ -281,8 +230,11 @@ void LogViewer::setupUI() // Combine filter area QVBoxLayout* filterAreaLayout = new QVBoxLayout(); filterAreaLayout->addWidget(timeGroupBox); - filterAreaLayout->addWidget(levelGroupBox); - filterAreaLayout->addWidget(moduleGroupBox); + + // Dynamic field filters container (populated by rebuildFieldFilterUI) + fieldFiltersLayout = new QVBoxLayout(); + + filterAreaLayout->addLayout(fieldFiltersLayout); filterAreaLayout->addLayout(encodingLayout); filterAreaLayout->addLayout(filterLayout); filterWidget->setLayout(filterAreaLayout); @@ -391,6 +343,40 @@ void LogViewer::setupUI() connect(formatTemplateAction, &QAction::triggered, this, &LogViewer::onFormatTemplateAction); + // Format mode combo + formatModeLabel = new QLabel(tr("格式模式:"), this); + formatModeCombo = new QComboBox(this); + formatModeCombo->addItem(tr("自动识别")); // index 0 + const auto presetList = LogFormatTemplate::presets(); + for (const auto& p : presetList) { + formatModeCombo->addItem(p.name); // index 1..N + } + formatModeCombo->addItem(tr("自定义...")); // last index + // Restore saved mode + int savedMode = AppSettings::instance().getFormatMode(); + if (savedMode >= 0 && savedMode < formatModeCombo->count() - 1) { + formatModeCombo->setCurrentIndex(savedMode); + } else if (savedMode < 0) { + formatModeCombo->setCurrentIndex(formatModeCombo->count() - 1); + } + connect(formatModeCombo, + QOverload::of(&QComboBox::currentIndexChanged), this, + &LogViewer::onFormatModeChanged); + + toolBar->addWidget(formatModeLabel); + toolBar->addWidget(formatModeCombo); + + // Hide unmatched lines checkbox + hideUnmatchedCheckBox = new QCheckBox(tr("显示不匹配行"), this); + hideUnmatchedCheckBox->setChecked(!AppSettings::instance().getHideUnmatched()); + connect(hideUnmatchedCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + bool hide = !checked; + AppSettings::instance().setHideUnmatched(hide); + if (proxyModel) + proxyModel->setHideUnmatched(hide); + }); + toolBar->addWidget(hideUnmatchedCheckBox); + // Add separator toolBar->addSeparator(); @@ -424,6 +410,14 @@ void LogViewer::setupUI() toolBar->addWidget(languageComboBox); // Status bar + templateInfoLabel = new QLabel(this); + templateInfoLabel->setVisible(false); + templateInfoLabel->setStyleSheet( + "color: #555; padding: 2px 8px; font-size: 9pt;"); + templateInfoLabel->setWordWrap(false); + templateInfoLabel->setMaximumWidth(600); + statusBar()->addPermanentWidget(templateInfoLabel); + progressBar = new QProgressBar(this); progressBar->setVisible(false); statusBar()->addPermanentWidget(progressBar); @@ -455,8 +449,10 @@ void LogViewer::setupUI() // Initialize models sourceModel = new LogTableModel(this); proxyModel = new LogFilterProxyModel(this); + proxyModel->setHideUnmatched(AppSettings::instance().getHideUnmatched()); proxyModel->setSourceModel(sourceModel); logTreeView->setModel(proxyModel); + logTreeView->header()->setStretchLastSection(true); // Delegate for highlight on content column highlightDelegate = new HighlightDelegate(this); logTreeView->setItemDelegateForColumn(LogTableModel::ColumnMessage, @@ -478,11 +474,30 @@ void LogViewer::openLogFile() } } -void LogViewer::loadLogFile(const QString& filePath) +void LogViewer::loadLogFile(const QString& filePath, + const QString& formatTemplate) { currentFilePath = filePath; QString encoding = encodingComboBox->currentText(); + // Determine effective template: explicit > formatMode setting + QString effectiveTemplate = formatTemplate; + if (effectiveTemplate.isEmpty()) { + int mode = AppSettings::instance().getFormatMode(); + if (mode > 0) { + // Preset mode: get preset template by index (1-based) + const auto presets = LogFormatTemplate::presets(); + int presetIdx = mode - 1; + if (presetIdx >= 0 && presetIdx < presets.size()) { + effectiveTemplate = presets[presetIdx].templateStr; + } + } else if (mode < 0) { + // Custom mode: use saved custom template + effectiveTemplate = AppSettings::instance().getLogFormatTemplate(); + } + // mode == 0: auto-detect (pass empty) + } + // Show progress bar progressBar->setVisible(true); progressBar->setRange(0, 100); @@ -490,11 +505,24 @@ void LogViewer::loadLogFile(const QString& filePath) // Clear previous data sourceModel->clear(); + // Reset dynamic columns from previous file + sourceModel->setExtraColumns(QStringList()); + pendingExtraColumns.clear(); + pendingExtraFieldValues.clear(); + // Clear extra field filter UI + QLayoutItem* child; + while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) { + QWidget* widget = child->widget(); + if (widget) + widget->deleteLater(); + delete child; + } + fieldCheckBoxes.clear(); - // Background loader + // Background loader — auto-detect format by default. + // A saved template is only used when explicitly passed (e.g. from format dialog reload). QThread* thread = new QThread(this); - QString formatTemplate = AppSettings::instance().getLogFormatTemplate(); - LogLoader* loader = new LogLoader(filePath, encoding, 5000, formatTemplate); + LogLoader* loader = new LogLoader(filePath, encoding, 5000, effectiveTemplate); loader->moveToThread(thread); connect(thread, &QThread::started, loader, &LogLoader::process); @@ -503,11 +531,11 @@ void LogViewer::loadLogFile(const QString& filePath) connect(loader, &LogLoader::error, this, [this](const QString& msg) { QMessageBox::warning(this, tr("错误"), msg); }); - // 发布构建下,加载期间冻结视图更新以减少重绘 -#ifdef NDEBUG + connect(loader, &LogLoader::detectInfoReady, this, + &LogViewer::updateTemplateInfo); + // 加载期间冻结视图更新以减少重绘 logTreeView->setUpdatesEnabled(false); logTreeView->viewport()->setUpdatesEnabled(false); -#endif connect(loader, &LogLoader::chunkReady, this, [this](QVector chunk) { @@ -515,41 +543,54 @@ void LogViewer::loadLogFile(const QString& filePath) }); connect(loader, &LogLoader::summaryReady, this, [this](const QDateTime& minTime, const QDateTime& maxTime, - const QStringList& modules, const QStringList& levels) { + const QStringList& modules, const QStringList& levels, + const QStringList& extraFieldNames, + const QMap& extraFieldValues) { startTimeEdit->setDateTime(minTime); endTimeEdit->setDateTime(maxTime); - allModules = modules; - allLevels = levels; - // Rebuild module checkboxes UI - QLayoutItem* child; - while ((child = moduleLayout->takeAt(0)) != nullptr) { - QWidget* widget = child->widget(); - if (widget) - widget->deleteLater(); - delete child; + + // Merge level, module, and extra fields into unified field data + // (level/module are already fixed columns, only add to filter values) + QStringList allFieldNames = extraFieldNames; + QMap allFieldValues = extraFieldValues; + + if (!levels.isEmpty()) { + allFieldValues[QStringLiteral("level")] = levels; } - moduleCheckBoxes.clear(); - for (const QString& module : allModules) { - QCheckBox* checkBox = new QCheckBox(module, this); - checkBox->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Preferred); - moduleCheckBoxes.append(checkBox); - moduleLayout->addWidget(checkBox); + if (!modules.isEmpty()) { + allFieldValues[QStringLiteral("module")] = modules; } - moduleLayout->addStretch(); - for (QCheckBox* checkBox : levelCheckBoxes) - checkBox->setChecked(true); - for (QCheckBox* checkBox : moduleCheckBoxes) - checkBox->setChecked(true); + + // Store for use in finished handler + pendingExtraColumns = allFieldNames; + pendingExtraFieldValues = allFieldValues; }); connect(loader, &LogLoader::finished, this, [this, loader, thread, filePath]() { - // 发布构建下,加载结束后恢复视图更新并进行一次性刷新 -#ifdef NDEBUG + // 恢复视图更新并进行一次性刷新 logTreeView->setUpdatesEnabled(true); logTreeView->viewport()->setUpdatesEnabled(true); + + // Apply extra columns after view is re-enabled (triggers model reset) + sourceModel->setExtraColumns(pendingExtraColumns); + + // Build filter field names: level/module (fixed columns) + extra fields + QStringList filterFieldNames; + if (pendingExtraFieldValues.contains(QStringLiteral("level"))) + filterFieldNames.append(QStringLiteral("level")); + if (pendingExtraFieldValues.contains(QStringLiteral("module"))) + filterFieldNames.append(QStringLiteral("module")); + filterFieldNames.append(pendingExtraColumns); + rebuildFieldFilterUI(filterFieldNames, pendingExtraFieldValues); + + // Move message column to rightmost (it has the most content) + QHeaderView* hdr = logTreeView->header(); + int msgVis = hdr->visualIndex(LogTableModel::ColumnMessage); + int lastVis = hdr->count() - 1; + if (msgVis != lastVis) + hdr->moveSection(msgVis, lastVis); + logTreeView->viewport()->update(); -#endif progressBar->setVisible(false); exportAction->setEnabled(true); @@ -575,23 +616,22 @@ void LogViewer::onFilterButtonClicked() QDateTime startTime = startTimeEdit->dateTime(); QDateTime endTime = endTimeEdit->dateTime(); + + // Read level and module from unified fieldCheckBoxes QStringList selectedLevels; - for (QCheckBox* checkBox : levelCheckBoxes) { - if (checkBox->isChecked()) { - selectedLevels.append(checkBox->text()); + if (fieldCheckBoxes.contains(QStringLiteral("level"))) { + for (QCheckBox* cb : fieldCheckBoxes[QStringLiteral("level")]) { + if (cb->isChecked()) + selectedLevels.append(cb->text()); } } QStringList selectedModules; - for (QCheckBox* checkBox : moduleCheckBoxes) { - if (checkBox->isChecked()) { - selectedModules.append(checkBox->text()); + if (fieldCheckBoxes.contains(QStringLiteral("module"))) { + for (QCheckBox* cb : fieldCheckBoxes[QStringLiteral("module")]) { + if (cb->isChecked()) + selectedModules.append(cb->text()); } } - if (selectedLevels.isEmpty() || selectedModules.isEmpty()) { - QMessageBox::warning(this, tr("错误"), - tr("请至少选择一个日志等级和模块。")); - return; - } // Show progress bar progressBar->setVisible(true); @@ -599,8 +639,29 @@ void LogViewer::onFilterButtonClicked() if (proxyModel) { proxyModel->setTimeRange(startTime, endTime); - proxyModel->setLevels(selectedLevels); - proxyModel->setModules(selectedModules); + // Only activate level/module filter when checkboxes exist for that field + if (fieldCheckBoxes.contains(QStringLiteral("level"))) + proxyModel->setLevels(selectedLevels); + if (fieldCheckBoxes.contains(QStringLiteral("module"))) + proxyModel->setModules(selectedModules); + + // Apply extra field filters (level and module already handled above) + proxyModel->clearExtraFieldFilters(); + for (auto it = fieldCheckBoxes.constBegin(); + it != fieldCheckBoxes.constEnd(); ++it) { + if (it.key() == QStringLiteral("level") || + it.key() == QStringLiteral("module")) + continue; + QSet accepted; + for (QCheckBox* cb : it.value()) { + if (cb->isChecked()) { + accepted.insert(cb->text()); + } + } + if (accepted.size() < it.value().size()) { + proxyModel->setExtraFieldFilter(it.key(), accepted); + } + } } // Hide progress bar after filtering is complete @@ -610,6 +671,87 @@ void LogViewer::onFilterButtonClicked() .arg(proxyModel ? proxyModel->rowCount() : 0)); } +void LogViewer::rebuildFieldFilterUI( + const QStringList& fieldNames, + const QMap& fieldValues) +{ + // Clear existing field filter UI + QLayoutItem* child; + while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) { + QWidget* widget = child->widget(); + if (widget) + widget->deleteLater(); + delete child; + } + fieldCheckBoxes.clear(); + + // Create a GroupBox for each field + for (const QString& fieldName : fieldNames) { + QStringList values = fieldValues.value(fieldName); + if (values.isEmpty()) + continue; + + QGroupBox* groupBox = new QGroupBox(fieldName, this); + QVBoxLayout* groupLayout = new QVBoxLayout(); + + // Select All / Deselect All buttons + QHBoxLayout* buttonLayout = new QHBoxLayout(); + QPushButton* selectAllBtn = new QPushButton(tr("全选"), this); + QPushButton* deselectAllBtn = new QPushButton(tr("取消全选"), this); + selectAllBtn->setFixedHeight(24); + deselectAllBtn->setFixedHeight(24); + buttonLayout->addWidget(selectAllBtn); + buttonLayout->addWidget(deselectAllBtn); + buttonLayout->addStretch(); + groupLayout->addLayout(buttonLayout); + + // For fields with many values, use a scroll area + bool useScrollArea = (values.size() > 8); + + QHBoxLayout* checkBoxLayout = new QHBoxLayout(); + QList checkBoxes; + + for (const QString& value : values) { + QCheckBox* cb = new QCheckBox(value, this); + cb->setChecked(true); + cb->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + checkBoxes.append(cb); + checkBoxLayout->addWidget(cb); + } + checkBoxLayout->addStretch(); + + if (useScrollArea) { + QWidget* container = new QWidget(this); + container->setLayout(checkBoxLayout); + QScrollArea* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setWidget(container); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setFixedHeight(60); + groupLayout->addWidget(scrollArea); + } else { + groupLayout->addLayout(checkBoxLayout); + } + + // Connect select all / deselect all buttons + connect(selectAllBtn, &QPushButton::clicked, this, + [checkBoxes]() { + for (QCheckBox* cb : checkBoxes) + cb->setChecked(true); + }); + connect(deselectAllBtn, &QPushButton::clicked, this, + [checkBoxes]() { + for (QCheckBox* cb : checkBoxes) + cb->setChecked(false); + }); + + groupBox->setLayout(groupLayout); + fieldFiltersLayout->addWidget(groupBox); + fieldCheckBoxes[fieldName] = checkBoxes; + } +} + void LogViewer::toggleFilterArea() { if (filterWidget->isVisible()) { @@ -621,20 +763,6 @@ void LogViewer::toggleFilterArea() } } -void LogViewer::selectAllModules() -{ - for (QCheckBox* checkBox : moduleCheckBoxes) { - checkBox->setChecked(true); - } -} - -void LogViewer::deselectAllModules() -{ - for (QCheckBox* checkBox : moduleCheckBoxes) { - checkBox->setChecked(false); - } -} - void LogViewer::onSearchTextChanged(const QString& text) { currentSearchText = text; @@ -903,18 +1031,57 @@ void LogViewer::onFormatTemplateAction() if (dialog.exec() == QDialog::Accepted) { QString newTemplate = dialog.getTemplate(); AppSettings::instance().setLogFormatTemplate(newTemplate); + // Switch combo to "Custom" (last item) + formatModeCombo->blockSignals(true); + formatModeCombo->setCurrentIndex(formatModeCombo->count() - 1); + formatModeCombo->blockSignals(false); + AppSettings::instance().setFormatMode(-1); // If a file is already loaded, offer to reload with new format if (!currentFilePath.isEmpty()) { QMessageBox::StandardButton reply = QMessageBox::question( this, tr("Reload"), tr("Format changed. Reload the current file with the new format?")); if (reply == QMessageBox::Yes) { - loadLogFile(currentFilePath); + loadLogFile(currentFilePath, newTemplate); } } } } +void LogViewer::onFormatModeChanged(int index) +{ + int lastIndex = formatModeCombo->count() - 1; + if (index == lastIndex) { + // "Custom..." selected — open format template dialog + AppSettings::instance().setFormatMode(-1); + onFormatTemplateAction(); + return; + } + + // Save mode: 0=auto, 1+=preset index + AppSettings::instance().setFormatMode(index); + + // Reload current file with new mode if a file is loaded + if (!currentFilePath.isEmpty()) { + loadLogFile(currentFilePath); + } +} + +void LogViewer::updateTemplateInfo(const QString& tmpl, const QString& reason) +{ + if (tmpl.isEmpty()) { + templateInfoLabel->setVisible(false); + return; + } + QString text = tr("模板: %1").arg(tmpl); + if (!reason.isEmpty()) { + text += QStringLiteral(" | %1").arg(reason); + } + templateInfoLabel->setText(text); + templateInfoLabel->setVisible(true); + templateInfoLabel->setToolTip(text); +} + QList LogViewer::getCurrentFilteredLogs() const { if (!proxyModel || !sourceModel) { @@ -1035,28 +1202,7 @@ void LogViewer::retranslateUI() << timeGroupBox->title(); #endif } - if (levelGroupBox) { - QString oldTitle = levelGroupBox->title(); - levelGroupBox->setTitle(tr("日志等级")); -#ifdef LOG_DEBUG_ENABLED - qDebug() << "Level group title changed from" << oldTitle << "to" - << levelGroupBox->title(); -#endif - } - if (moduleGroupBox) { - QString oldTitle = moduleGroupBox->title(); - moduleGroupBox->setTitle(tr("模块选择")); -#ifdef LOG_DEBUG_ENABLED - qDebug() << "Module group title changed from" << oldTitle << "to" - << moduleGroupBox->title(); -#endif - } - // Re-set button text - if (selectAllModulesButton) - selectAllModulesButton->setText(tr("全选")); - if (deselectAllModulesButton) - deselectAllModulesButton->setText(tr("全不选")); if (searchPreviousButton) searchPreviousButton->setText(tr("上一条")); if (searchNextButton) @@ -1077,6 +1223,26 @@ void LogViewer::retranslateUI() encodingLabel->setText(tr("文件编码:")); if (languageLabel) languageLabel->setText(tr("语言:")); + if (formatModeLabel) + formatModeLabel->setText(tr("格式模式:")); + if (hideUnmatchedCheckBox) + hideUnmatchedCheckBox->setText(tr("显示不匹配行")); + // Update format mode combo items (presets may have translations) + if (formatModeCombo) { + formatModeCombo->blockSignals(true); + int savedIndex = formatModeCombo->currentIndex(); + formatModeCombo->clear(); + formatModeCombo->addItem(tr("自动识别")); + const auto presetList = LogFormatTemplate::presets(); + for (const auto& p : presetList) { + formatModeCombo->addItem(p.name); + } + formatModeCombo->addItem(tr("自定义...")); + if (savedIndex >= 0 && savedIndex < formatModeCombo->count()) { + formatModeCombo->setCurrentIndex(savedIndex); + } + formatModeCombo->blockSignals(false); + } // Re-set table headers (via view header, since we use custom model) if (logTreeView && logTreeView->header()) { diff --git a/src/ui/logviewer.h b/src/ui/logviewer.h index 354c4dd..ceefc97 100644 --- a/src/ui/logviewer.h +++ b/src/ui/logviewer.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -126,20 +127,6 @@ private slots: */ void toggleFilterArea(); - /** - * @brief Select all module checkboxes - * @details Convenience function to quickly select all available log modules - * for filtering. Useful when user wants to see all modules. - */ - void selectAllModules(); - - /** - * @brief Deselect all module checkboxes - * @details Convenience function to quickly clear all module selections. - * Useful for starting fresh with module filtering. - */ - void deselectAllModules(); - // Search operations /** * @brief Handle search text changes @@ -198,6 +185,7 @@ private slots: // Format template operations void onFormatTemplateAction(); + void onFormatModeChanged(int index); // Language operations /** @@ -233,7 +221,8 @@ private slots: * extracts unique modules and levels, and displays results in tree * view. */ - void loadLogFile(const QString& filePath); + void loadLogFile(const QString& filePath, + const QString& formatTemplate = QString()); /** * @brief Set up the user interface @@ -257,6 +246,9 @@ private slots: */ void highlightSearchMatches(); void flushSearchDebounce(); + void rebuildFieldFilterUI(const QStringList& fieldNames, + const QMap& fieldValues); + void updateTemplateInfo(const QString& tmpl, const QString& reason); /** * @brief Expand tree view to show specified item @@ -286,8 +278,6 @@ private slots: QDateTimeEdit* startTimeEdit; ///< Start time selector for time range filtering QDateTimeEdit* endTimeEdit; ///< End time selector for time range filtering - QList levelCheckBoxes; ///< Checkboxes for log level selection - QList moduleCheckBoxes; ///< Checkboxes for module selection QComboBox* encodingComboBox; ///< Dropdown for file encoding selection QComboBox* languageComboBox; ///< Dropdown for language selection @@ -301,9 +291,8 @@ private slots: // UI Controls - Layout containers QGroupBox* timeGroupBox; ///< Container for time range controls - QGroupBox* levelGroupBox; ///< Container for level selection controls - QGroupBox* moduleGroupBox; ///< Container for module selection controls - QHBoxLayout* moduleLayout; ///< Layout for module checkboxes + QVBoxLayout* fieldFiltersLayout; ///< Layout for dynamic field filter groups + QMap> fieldCheckBoxes; ///< All field checkboxes (level, module, extras) QProgressBar* progressBar; ///< Progress indicator for long operations QWidget* filterWidget; ///< Container for all filter controls QSplitter* mainSplitter; ///< Splitter between filter area and main view @@ -314,8 +303,9 @@ private slots: QAction* toggleFilterAction; ///< Action to toggle filter area visibility QAction* exportAction; ///< Export action for menu and toolbar QAction* formatTemplateAction; ///< Format template action for toolbar - QPushButton* selectAllModulesButton; ///< Button to select all modules - QPushButton* deselectAllModulesButton; ///< Button to deselect all modules + QComboBox* formatModeCombo; ///< Format mode selector (auto/preset/custom) + QLabel* formatModeLabel; ///< Label for format mode combo + QCheckBox* hideUnmatchedCheckBox; ///< Toggle to show/hide unmatched lines // UI Controls - Search QLineEdit* searchLineEdit; ///< Text input for search terms @@ -336,9 +326,12 @@ private slots: // UI Controls - GitHub link QLabel* githubLinkLabel; ///< Clickable GitHub link in status bar + // UI Controls - Template info + QLabel* templateInfoLabel; ///< Template info label in status bar + // Data storage - QStringList allModules; ///< List of all unique modules found in logs - QStringList allLevels; ///< List of all unique log levels found + QStringList pendingExtraColumns; ///< Extra column names from last summaryReady + QMap pendingExtraFieldValues; ///< Extra field values from last summaryReady QString currentFilePath; ///< Path of currently loaded log file QString currentSearchText; ///< Current search term QVector searchResults; ///< Row indices in proxy model matching search diff --git a/src/utils/appsettings.cpp b/src/utils/appsettings.cpp index f12bdb5..534b79f 100644 --- a/src/utils/appsettings.cpp +++ b/src/utils/appsettings.cpp @@ -24,6 +24,8 @@ const QString AppSettings::KEY_INCLUDE_MODULE = "export/includeModule"; const QString AppSettings::KEY_INCLUDE_CONTENT = "export/includeContent"; const QString AppSettings::KEY_LANGUAGE = "ui/language"; const QString AppSettings::KEY_LOG_FORMAT_TEMPLATE = "log/formatTemplate"; +const QString AppSettings::KEY_FORMAT_MODE = "log/formatMode"; +const QString AppSettings::KEY_HIDE_UNMATCHED = "log/hideUnmatched"; AppSettings& AppSettings::instance() { @@ -140,4 +142,26 @@ QString AppSettings::getLogFormatTemplate() const { // 空字符串表示自动识别 return settings->value(KEY_LOG_FORMAT_TEMPLATE, QString()).toString(); +} + +void AppSettings::setFormatMode(int mode) +{ + settings->setValue(KEY_FORMAT_MODE, mode); +} + +int AppSettings::getFormatMode() const +{ + // 0 = 自动识别 (默认) + return settings->value(KEY_FORMAT_MODE, 0).toInt(); +} + +void AppSettings::setHideUnmatched(bool hide) +{ + settings->setValue(KEY_HIDE_UNMATCHED, hide); +} + +bool AppSettings::getHideUnmatched() const +{ + // 默认显示所有行(不隐藏) + return settings->value(KEY_HIDE_UNMATCHED, false).toBool(); } \ No newline at end of file diff --git a/src/utils/appsettings.h b/src/utils/appsettings.h index 3e89516..e6cac32 100644 --- a/src/utils/appsettings.h +++ b/src/utils/appsettings.h @@ -89,6 +89,14 @@ class AppSettings void setLogFormatTemplate(const QString& formatTemplate); QString getLogFormatTemplate() const; + // 格式模式:0=自动识别, 1+=预设索引, -1=自定义 + void setFormatMode(int mode); + int getFormatMode() const; + + // 隐藏不匹配格式的行 + void setHideUnmatched(bool hide); + bool getHideUnmatched() const; + private: /** * @brief Private constructor for singleton pattern @@ -121,6 +129,8 @@ class AppSettings static const QString KEY_LANGUAGE; ///< Configuration key for language preference static const QString KEY_LOG_FORMAT_TEMPLATE; + static const QString KEY_FORMAT_MODE; + static const QString KEY_HIDE_UNMATCHED; }; #endif // APPSETTINGS_H \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 856d439..0fa490c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -61,6 +61,43 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_logentry.cpp") set(TEST_TARGETS test_logentry) endif() +# 格式检测测试(不需要 QtTest,使用普通 main) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_format_detect.cpp") + add_executable(test_format_detect test_format_detect.cpp + ${CMAKE_SOURCE_DIR}/src/core/logformattemplate.cpp + ) + if(Qt6_FOUND) + target_link_libraries(test_format_detect Qt6::Core) + else() + target_link_libraries(test_format_detect Qt5::Core) + endif() + target_include_directories(test_format_detect PRIVATE + ${CMAKE_SOURCE_DIR}/src/core + ) + add_test(NAME test_format_detect COMMAND test_format_detect) + set_tests_properties(test_format_detect PROPERTIES TIMEOUT 15) + list(APPEND TEST_TARGETS test_format_detect) +endif() + +# 解析诊断工具(独立命令行程序,不做ctest) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_parse_diag.cpp") + add_executable(test_parse_diag test_parse_diag.cpp + ${CMAKE_SOURCE_DIR}/src/core/logformattemplate.cpp + ) + if(Qt6_FOUND) + target_link_libraries(test_parse_diag Qt6::Core) + else() + target_link_libraries(test_parse_diag Qt5::Core) + endif() + target_include_directories(test_parse_diag PRIVATE + ${CMAKE_SOURCE_DIR}/src/core + ) + # 设置工作目录为项目根目录,方便使用相对路径访问 docs/ + set_target_properties(test_parse_diag PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools" + ) +endif() + # 以下测试文件暂未实现,注释掉避免CMake错误 # if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_logexporter.cpp") # add_logviewer_test(test_logexporter diff --git a/tests/test_format_detect.cpp b/tests/test_format_detect.cpp new file mode 100644 index 0000000..f82b0ce --- /dev/null +++ b/tests/test_format_detect.cpp @@ -0,0 +1,220 @@ +#include +#include "logformattemplate.h" + +static int failures = 0; + +void check(const char* name, bool condition, const char* detail = "") +{ + if (condition) { + fprintf(stderr, " PASS: %s\n", name); + } else { + fprintf(stderr, " FAIL: %s %s\n", name, detail); + failures++; + } +} + +int main(int argc, char* argv[]) +{ + QStringList predictLines = { + "[2026-04-30 15:59:57.934] [C0 +0000] CYCLE_START warmup cold-start warmup", + "[2026-04-30 15:59:57.990] [C1 +0000] START M21 test_1/光盘抓手 plan=0-6000 actual=0 rel=1/4", + "[2026-04-30 15:59:57.990] [C1 +0000] START N1 test_1/孵育盘-in plan=0-2000 actual=0 rel=1/4", + "[2026-04-30 15:59:58.803] [C1 +0813] END M1 test_1/磁分离盘 plan=0-1000 actual=0-813 startDelay=+0", + "[2026-04-30 15:59:58.803] [C1 +0813] EMIT M1 -> S(M1.done) wakes=[-]", + "[2026-04-30 16:00:06.994] [C1 +9004] START T1 test_1/孵育盘-out plan=9000-11000 actual=9004 rel=1/4" + }; + + QStringList defaultLines = { + "[2025-06-27 08:36:19.123] [INFO] [ModuleName] : 正常信息日志", + "[2025-06-27 08:36:20.456] [ERROR] [Database] : 数据库连接失败", + "[2025-06-27 08:36:21.789] [WARN] [Network] : 网络延迟过高", + "[2025-06-27 08:36:22.012] [DEBUG] [Parser] : 解析详细信息" + }; + + QStringList customLines = { + "[2025-01-15 08:00:01.123] [INFO] [C1 +0001] START TaskA modA : 初始化系统", + "[2025-01-15 08:00:02.456] [DEBUG] [C1 +0002] WAIT TaskB modB : 等待资源分配", + "[2025-01-15 08:00:03.789] [INFO] [C2 +0001] START TaskA modA : 开始处理请求", + "[2025-01-15 08:00:05.345] [ERROR] [C2 +0002] FAIL TaskB modB : 连接超时" + }; + + // Test 1: Predict log detection + fprintf(stderr, "=== Test 1: Predict log (extra fields) ===\n"); + { + LogFormatTemplate fmt = LogFormatTemplate::detect(predictLines); + check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData()); + check("has field1 extra", fmt.extraFieldNames().contains("field1")); + + int mc = 0; + for (const QString& line : predictLines) { + if (fmt.regex().match(line).hasMatch()) mc++; + } + check("all lines match", mc == predictLines.size(), + QString("%1/%2").arg(mc).arg(predictLines.size()).toUtf8().constData()); + + // Verify field1 captures cycle info + QRegularExpressionMatch m = fmt.regex().match(predictLines[1]); + if (m.hasMatch()) { + int idx = fmt.captureIndex("field1"); + QString val = (idx >= 0) ? m.captured(idx) : ""; + check("field1 has value", !val.isEmpty(), val.toUtf8().constData()); + fprintf(stderr, " field1 = '%s'\n", val.toUtf8().constData()); + } + } + + // Test 2: Default log detection (standard fields) + fprintf(stderr, "\n=== Test 2: Default log (standard fields) ===\n"); + { + LogFormatTemplate fmt = LogFormatTemplate::detect(defaultLines); + check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData()); + check("has timestamp", fmt.allFieldNames().contains("timestamp")); + check("has level", fmt.allFieldNames().contains("level")); + check("has message", fmt.allFieldNames().contains("message")); + + int mc = 0; + for (const QString& line : defaultLines) { + if (fmt.regex().match(line).hasMatch()) mc++; + } + check("all lines match", mc == defaultLines.size(), + QString("%1/%2").arg(mc).arg(defaultLines.size()).toUtf8().constData()); + } + + // Test 3: Custom fields log detection + fprintf(stderr, "\n=== Test 3: Custom fields log ===\n"); + { + LogFormatTemplate fmt = LogFormatTemplate::detect(customLines); + check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData()); + + int mc = 0; + for (const QString& line : customLines) { + if (fmt.regex().match(line).hasMatch()) mc++; + } + check("all lines match", mc == customLines.size(), + QString("%1/%2").arg(mc).arg(customLines.size()).toUtf8().constData()); + + fprintf(stderr, " template: %s\n", fmt.templateString().toUtf8().constData()); + fprintf(stderr, " extra fields: "); + for (const QString& f : fmt.extraFieldNames()) fprintf(stderr, "%s ", f.toUtf8().constData()); + fprintf(stderr, "\n"); + } + + // Test 4: Task scheduler log (the problematic format) + fprintf(stderr, "\n=== Test 4: Task scheduler log ===\n"); + QStringList schedulerLines = { + "[2026-05-06 09:50:34.726] ================================================================================", + "[CYCLE 1] BEGIN budget=30000ms activeTests=-", + "[modA]", + " Task Test Rel Action Plan State WaitFor Emit", + " ---- -------- ----- ---------- ----------- ------ ------------------------ ----------------", + "[2026-05-06 09:50:34.726] [C1 +0001] WAIT B1 modB plan=0-100 missing=[S(A1.done)]", + "[2026-05-06 09:50:34.726] [C1 +0001] START A1 modA plan=0-100 actual=1", + "[2026-05-06 09:50:34.726] [C1 +0001] END A1 modA plan=0-100 actual=1-1 startDelay=+1", + "[2026-05-06 09:50:34.726] [C1 +0001] READY B1 modB <- S(A1.done) [final]", + "[2026-05-06 09:50:34.726] [C1 +0001] EMIT A1 -> S(A1.done) wakes=[B1]", + "[2026-05-06 09:50:40.799] [C1 +0000] FAULT fault_task#71 unknown Test fault", + "[2026-05-06 09:50:40.800] [C1 +0000] START normal_task#72 unknown plan=0-0 actual=0", + "[2026-05-06 09:50:46.504] [C1 +0000] START high_priority#147 unknown plan=0-0 actual=0", + "[2026-05-06 09:50:48.283] [C1 +0061] START offset_task#171 unknown plan=50-50 actual=61", + }; + { + LogFormatTemplate fmt = LogFormatTemplate::detect(schedulerLines); + check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData()); + fprintf(stderr, " template: %s\n", fmt.templateString().toUtf8().constData()); + + QRegularExpression tsRe(R"(\[\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?\])"); + int mc = 0; + int unmatchedTimestamped = 0; + for (const QString& line : schedulerLines) { + if (tsRe.match(line).hasMatch()) { + if (fmt.regex().match(line).hasMatch()) { + mc++; + } else { + fprintf(stderr, " UNMATCHED timestamped: %s\n", line.left(80).toUtf8().constData()); + unmatchedTimestamped++; + } + } + } + fprintf(stderr, " matched: %d, unmatched timestamped: %d\n", mc, unmatchedTimestamped); + + // Check that FAULT is recognized as a level + check("FAULT in LOG_LEVELS", + fmt.allFieldNames().contains("level"), + "level field should exist"); + + // Verify specific field extraction + QRegularExpression regex = fmt.regex(); + QRegularExpressionMatch m = regex.match(schedulerLines[5]); // WAIT line + if (m.hasMatch()) { + int lvIdx = fmt.captureIndex("level"); + int msgIdx = fmt.captureIndex("message"); + QString level = (lvIdx >= 0) ? m.captured(lvIdx) : ""; + QString message = (msgIdx >= 0) ? m.captured(msgIdx) : ""; + fprintf(stderr, " WAIT line: level='%s' message='%s'\n", + level.toUtf8().constData(), message.left(60).toUtf8().constData()); + check("WAIT level", level == "WAIT", + QString("got '%1'").arg(level).toUtf8().constData()); + } + + // Check FAULT line + QRegularExpressionMatch fm = regex.match(schedulerLines[10]); // FAULT line + if (fm.hasMatch()) { + int lvIdx = fmt.captureIndex("level"); + QString level = (lvIdx >= 0) ? fm.captured(lvIdx) : ""; + fprintf(stderr, " FAULT line: level='%s'\n", level.toUtf8().constData()); + check("FAULT matched", true); + check("FAULT level", level == "FAULT", + QString("got '%1'").arg(level).toUtf8().constData()); + } else { + check("FAULT matched", false, "FAULT line did not match template"); + } + + // Check EMIT line + QRegularExpressionMatch em = regex.match(schedulerLines[9]); // EMIT line + if (em.hasMatch()) { + int lvIdx = fmt.captureIndex("level"); + QString level = (lvIdx >= 0) ? em.captured(lvIdx) : ""; + fprintf(stderr, " EMIT line: level='%s'\n", level.toUtf8().constData()); + check("EMIT matched", true); + } else { + check("EMIT matched", false, "EMIT line did not match template"); + } + } + + // Test 5: Flexible spacing - verify template handles multi-space alignment + fprintf(stderr, "\n=== Test 5: Flexible spacing ===\n"); + QStringList spacedLines = { + "[2026-05-06 09:50:34.726] [C1 +0001] START A1 modA plan=0-100 actual=1", + "[2026-05-06 09:50:34.726] [C1 +0001] END A1 modA plan=0-100 actual=1-1 startDelay=+1", + "[2026-05-06 09:50:34.726] [C1 +0001] WAIT B1 modB plan=0-100 missing=[S(A1.done)]", + "[2026-05-06 09:50:40.799] [C1 +0000] FAULT fault_task#71 unknown Test fault", + }; + { + // Test with a known template + LogFormatTemplate fmt("[{timestamp}] [{field1}] {level} {field2} {field3} {message}"); + check("flex template valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData()); + + int mc = 0; + for (const QString& line : spacedLines) { + QRegularExpressionMatch m = fmt.regex().match(line); + if (m.hasMatch()) { + mc++; + int lvIdx = fmt.captureIndex("level"); + int f2Idx = fmt.captureIndex("field2"); + int f3Idx = fmt.captureIndex("field3"); + int msgIdx = fmt.captureIndex("message"); + fprintf(stderr, " level='%s' field2='%s' field3='%s' msg='%s'\n", + m.captured(lvIdx).toUtf8().constData(), + m.captured(f2Idx).toUtf8().constData(), + m.captured(f3Idx).toUtf8().constData(), + m.captured(msgIdx).left(40).toUtf8().constData()); + } else { + fprintf(stderr, " NO MATCH: %s\n", line.left(60).toUtf8().constData()); + } + } + check("all spaced lines match", mc == spacedLines.size(), + QString("%1/%2").arg(mc).arg(spacedLines.size()).toUtf8().constData()); + } + + fprintf(stderr, "\n=== Results: %s ===\n", failures == 0 ? "ALL PASSED" : QString("%1 FAILED").arg(failures).toUtf8().constData()); + return failures; +}