Files
tbf/AppDiagnostic/MainForm.cs
T
marekf 8c77df207a <Feat>: Add universal NLog diagnostics and improve AppDiagnostic UI
Cause:

- Live diagnostics required logging from multiple applications without blocking TBF.
- Shared nlog.config files caused configuration conflicts.
- The original viewer lacked configurable colors and practical log controls.
- Inconsistent diagnostic deployment caused log transfer failures and locked shared DLLs during builds.

Solution:

1. Added universal diagnostic transport
   - Added AppDiagnostic.Logging with an NLog target and named-pipe communication.
   - Added background buffering and automatic reconnection.
   - Registered the diagnostic connector programmatically.

2. Separated diagnostic window lifecycle
   - Run AppDiagnostic as an independent process.
   - Activate the existing window on repeated launches.
   - Keep TBF running when the diagnostic window closes.

3. Corrected diagnostic deployment
   - Copy the complete diagnostic output into a dedicated AppDiagnostic subdirectory during build.
   - Overwrite existing deployment files to prevent inconsistent versions.
   - Validate and launch the executable from that subdirectory with an explicit working directory.

4. Improved the log viewer
   - Added configurable substring colors with a palette and live preview.
   - Added text, level and application filters.
   - Added line wrapping, clipboard actions and log export.
   - Added Pause/Resume, Refresh, Clear, auto-scroll and Always on top.
   - Added connection and log counters.
   - Persist color rules between sessions.

5. Removed legacy dependencies
   - Replaced LiveLogCache with structured diagnostic storage.
   - Removed AppDiagnostic dependencies on log4net and SharedComponents.

Validation:

- Confirmed successful log transfer when launching AppDiagnostic from TBF.
2026-09-10 17:45:29 +02:00

447 lines
14 KiB
C#

using AppDiagnostic.Logging;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace AppDiagnostic
{
public partial class MainForm : Form
{
private readonly Func<int> connectedClientCountProvider;
private readonly Func<bool> activationRequestProvider;
private readonly Timer refreshTimer;
private readonly Font regularLogFont;
private readonly Font boldLogFont;
private List<LogColorRule> colorRules;
private long lastRenderedVersion = -1;
private int observedEntryCount;
private int visibleEntryCount;
private bool loadingFilters;
public MainForm() : this(null, null)
{
}
public MainForm(Func<int> connectedClientCountProvider, Func<bool> activationRequestProvider)
{
this.connectedClientCountProvider = connectedClientCountProvider;
this.activationRequestProvider = activationRequestProvider;
InitializeComponent();
Icon = Properties.Resources.TBF_icon;
regularLogFont = new Font(logView.Font, FontStyle.Regular);
boldLogFont = new Font(logView.Font, FontStyle.Bold);
colorRules = LogColorRuleRepository.Load();
InitializeFilters();
refreshTimer = new Timer { Interval = 250 };
refreshTimer.Tick += refreshTimer_Tick;
refreshTimer.Start();
RefreshView(true);
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
refreshTimer.Stop();
refreshTimer.Dispose();
regularLogFont.Dispose();
boldLogFont.Dispose();
base.OnFormClosing(e);
}
private void InitializeFilters()
{
loadingFilters = true;
levelComboBox.Items.AddRange(new object[] { "All levels", "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL" });
levelComboBox.SelectedIndex = 0;
applicationComboBox.Items.Add("All applications");
applicationComboBox.SelectedIndex = 0;
loadingFilters = false;
}
private void refreshTimer_Tick(object sender, EventArgs e)
{
ProcessActivationRequest();
RefreshView(false);
}
private void ProcessActivationRequest()
{
if (activationRequestProvider == null || !activationRequestProvider())
{
return;
}
if (WindowState == FormWindowState.Minimized)
{
WindowState = FormWindowState.Normal;
}
Show();
BringToFront();
Activate();
}
private void RefreshView(bool forceFullRefresh)
{
List<DiagnosticLogEntry> snapshot = DiagnosticLogStore.Instance.GetSnapshot();
UpdateApplicationFilter(snapshot);
if (pauseButton.Checked)
{
UpdateStatus(snapshot.Count);
return;
}
long currentVersion = DiagnosticLogStore.Instance.Version;
if (!forceFullRefresh && currentVersion == lastRenderedVersion)
{
UpdateStatus(snapshot.Count);
return;
}
bool canAppend = !forceFullRefresh && snapshot.Count > observedEntryCount;
if (canAppend)
{
AppendEntries(snapshot.Skip(observedEntryCount));
}
else
{
RebuildView(snapshot);
}
observedEntryCount = snapshot.Count;
lastRenderedVersion = currentVersion;
UpdateStatus(snapshot.Count);
}
private void RebuildView(IEnumerable<DiagnosticLogEntry> entries)
{
logView.SuspendLayout();
logView.Clear();
visibleEntryCount = 0;
AppendEntries(entries, false);
logView.ResumeLayout();
if (autoScrollButton.Checked)
{
ScrollToEnd();
}
}
private void AppendEntries(IEnumerable<DiagnosticLogEntry> entries, bool scrollAfterAppend = true)
{
int originalSelectionStart = logView.SelectionStart;
int originalSelectionLength = logView.SelectionLength;
foreach (DiagnosticLogEntry entry in entries)
{
if (!MatchesFilter(entry))
{
continue;
}
AppendEntry(entry);
visibleEntryCount++;
}
if (scrollAfterAppend && autoScrollButton.Checked)
{
ScrollToEnd();
}
else if (!autoScrollButton.Checked)
{
int safeStart = Math.Min(originalSelectionStart, logView.TextLength);
int safeLength = Math.Min(originalSelectionLength, logView.TextLength - safeStart);
logView.Select(safeStart, safeLength);
}
}
private void AppendEntry(DiagnosticLogEntry entry)
{
string text = DiagnosticLogFormatter.Format(entry);
if (logView.TextLength > 0)
{
logView.AppendText(Environment.NewLine);
}
int startIndex = logView.TextLength;
logView.AppendText(text);
LogColorizer.Apply(logView, startIndex, text.Length, text, colorRules, regularLogFont, boldLogFont);
logView.Select(logView.TextLength, 0);
}
private bool MatchesFilter(DiagnosticLogEntry entry)
{
string selectedLevel = levelComboBox.SelectedItem as string;
if (!string.IsNullOrEmpty(selectedLevel) && selectedLevel != "All levels" && !string.Equals(entry.Level, selectedLevel, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string selectedApplication = applicationComboBox.SelectedItem as string;
if (!string.IsNullOrEmpty(selectedApplication) && selectedApplication != "All applications" && !string.Equals(entry.ApplicationName, selectedApplication, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string filter = filterTextBox.Text.Trim();
return filter.Length == 0 || DiagnosticLogFormatter.Format(entry).IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
}
private void UpdateApplicationFilter(IEnumerable<DiagnosticLogEntry> entries)
{
string selectedApplication = applicationComboBox.SelectedItem as string ?? "All applications";
List<string> applications = entries.Select(entry => entry.ApplicationName).Where(name => !string.IsNullOrWhiteSpace(name)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList();
List<string> currentApplications = applicationComboBox.Items.Cast<string>().Skip(1).ToList();
if (applications.SequenceEqual(currentApplications, StringComparer.OrdinalIgnoreCase))
{
return;
}
loadingFilters = true;
applicationComboBox.BeginUpdate();
applicationComboBox.Items.Clear();
applicationComboBox.Items.Add("All applications");
applicationComboBox.Items.AddRange(applications.Cast<object>().ToArray());
applicationComboBox.SelectedItem = applicationComboBox.Items.Cast<string>().FirstOrDefault(item => string.Equals(item, selectedApplication, StringComparison.OrdinalIgnoreCase)) ?? "All applications";
applicationComboBox.EndUpdate();
loadingFilters = false;
}
private void UpdateStatus(int storedCount)
{
int clients = connectedClientCountProvider?.Invoke() ?? 0;
receiverStatusLabel.Text = pauseButton.Checked ? $"Paused | Connected sources: {clients}" : $"Listening | Connected sources: {clients}";
countStatusLabel.Text = $"Received: {DiagnosticLogStore.Instance.TotalReceived:N0} | Stored: {storedCount:N0} | Visible: {visibleEntryCount:N0}";
}
private void ScrollToEnd()
{
logView.Select(logView.TextLength, 0);
logView.ScrollToCaret();
}
private void pauseButton_Click(object sender, EventArgs e)
{
pauseButton.Text = pauseButton.Checked ? "Resume" : "Pause";
if (!pauseButton.Checked)
{
RefreshView(true);
}
else
{
UpdateStatus(DiagnosticLogStore.Instance.GetSnapshot().Count);
}
}
private void refreshButton_Click(object sender, EventArgs e)
{
RefreshView(true);
}
private void clearButton_Click(object sender, EventArgs e)
{
DiagnosticLogStore.Instance.Clear();
observedEntryCount = 0;
visibleEntryCount = 0;
RefreshView(true);
}
private void saveButton_Click(object sender, EventArgs e)
{
using (var dialog = new SaveFileDialog())
{
dialog.Filter = "Text files (*.txt)|*.txt|Log files (*.log)|*.log|All files (*.*)|*.*";
dialog.Title = "Save visible diagnostic logs";
dialog.DefaultExt = "log";
dialog.FileName = $"AppDiagnostic_{DateTime.Now:yyyyMMdd_HHmmss}.log";
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
try
{
File.WriteAllText(dialog.FileName, logView.Text);
}
catch (Exception exception)
{
MessageBox.Show(this, $"Unable to save diagnostic logs: {exception.Message}", "Save error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
private void copyButton_Click(object sender, EventArgs e)
{
CopySelectionOrCurrentLine();
}
private void copySelectionMenuItem_Click(object sender, EventArgs e)
{
if (logView.SelectionLength > 0)
{
CopyText(logView.SelectedText);
}
}
private void copyLineMenuItem_Click(object sender, EventArgs e)
{
CopyCurrentLine();
}
private void copyAllButton_Click(object sender, EventArgs e)
{
CopyText(logView.Text);
}
private void CopySelectionOrCurrentLine()
{
if (logView.SelectionLength > 0)
{
CopyText(logView.SelectedText);
}
else
{
CopyCurrentLine();
}
}
private void CopyCurrentLine()
{
if (logView.Lines.Length == 0)
{
return;
}
int lineIndex = logView.GetLineFromCharIndex(logView.SelectionStart);
if (lineIndex >= 0 && lineIndex < logView.Lines.Length)
{
CopyText(logView.Lines[lineIndex]);
}
}
private void CopyText(string text)
{
if (string.IsNullOrEmpty(text))
{
return;
}
try
{
Clipboard.SetText(text);
}
catch (Exception exception)
{
MessageBox.Show(this, $"Unable to copy text: {exception.Message}", "Clipboard error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void wrapButton_Click(object sender, EventArgs e)
{
logView.WordWrap = wrapButton.Checked;
}
private void alwaysOnTopButton_Click(object sender, EventArgs e)
{
TopMost = alwaysOnTopButton.Checked;
}
private void colorRulesButton_Click(object sender, EventArgs e)
{
using (var editor = new ColorRuleEditorForm(colorRules))
{
if (editor.ShowDialog(this) != DialogResult.OK)
{
return;
}
colorRules = editor.Rules;
try
{
LogColorRuleRepository.Save(colorRules);
}
catch (Exception exception)
{
MessageBox.Show(this, $"Unable to save color rules: {exception.Message}", "Settings error", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
RefreshView(true);
}
}
private void applyFilterButton_Click(object sender, EventArgs e)
{
RefreshView(true);
}
private void clearFilterButton_Click(object sender, EventArgs e)
{
loadingFilters = true;
filterTextBox.Clear();
levelComboBox.SelectedIndex = 0;
applicationComboBox.SelectedIndex = 0;
loadingFilters = false;
RefreshView(true);
}
private void filterValueChanged(object sender, EventArgs e)
{
if (!loadingFilters)
{
RefreshView(true);
}
}
private void filterTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
RefreshView(true);
e.SuppressKeyPress = true;
}
else if (e.KeyCode == Keys.Escape)
{
clearFilterButton_Click(sender, EventArgs.Empty);
e.SuppressKeyPress = true;
}
}
private void closeButton_Click(object sender, EventArgs e)
{
Close();
}
private void MainForm_KeyDown(object sender, KeyEventArgs e)
{
if (e.Control && e.KeyCode == Keys.F)
{
filterTextBox.Focus();
filterTextBox.SelectAll();
e.SuppressKeyPress = true;
}
else if (e.Control && e.KeyCode == Keys.S)
{
saveButton_Click(sender, EventArgs.Empty);
e.SuppressKeyPress = true;
}
else if (e.KeyCode == Keys.F5)
{
RefreshView(true);
e.SuppressKeyPress = true;
}
}
}
}