497 lines
19 KiB
C#
497 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
|
|
namespace JJServiceManager
|
|
{
|
|
/// <summary>서비스 한 개의 로그 파일 보기 (상단 정보줄 + 로그 목록)</summary>
|
|
public sealed class LogTailView : DockPanel, IDisposable
|
|
{
|
|
private readonly LogTailer _tailer;
|
|
private readonly TextBlock _path;
|
|
private readonly LogPanel _panel;
|
|
|
|
public ServiceEntry Entry { get; }
|
|
|
|
/// <summary>인코딩을 바꾸면 설정 저장용으로 호출됨</summary>
|
|
public event Action EntryChanged;
|
|
|
|
public LogTailView(ServiceEntry entry)
|
|
{
|
|
Entry = entry;
|
|
LastChildFill = true;
|
|
|
|
// ── 상단 정보줄
|
|
var bar = new Border
|
|
{
|
|
Background = Brushes2.Make("#F1F8FB"),
|
|
BorderBrush = Brushes2.Make("#B0BEC5"),
|
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
|
Padding = new Thickness(6, 4, 6, 4),
|
|
Margin = new Thickness(0, 0, 0, 4)
|
|
};
|
|
SetDock(bar, Dock.Top);
|
|
|
|
var grid = new Grid();
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
|
|
var title = new TextBlock
|
|
{
|
|
Text = entry.DisplayName,
|
|
FontWeight = FontWeights.Bold,
|
|
FontSize = 14,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 12, 0),
|
|
Foreground = Brushes2.Make("#155E75")
|
|
};
|
|
Grid.SetColumn(title, 0);
|
|
|
|
_path = new TextBlock
|
|
{
|
|
Text = (entry.LogIsFolder ? "폴더: " : "파일: ") + entry.LogPath,
|
|
Foreground = Brushes.Gray,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
TextTrimming = TextTrimming.CharacterEllipsis
|
|
};
|
|
Grid.SetColumn(_path, 1);
|
|
|
|
var encLabel = new TextBlock { Text = "인코딩", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(10, 0, 4, 0) };
|
|
Grid.SetColumn(encLabel, 2);
|
|
|
|
var enc = new ComboBox { Width = 90, VerticalAlignment = VerticalAlignment.Center };
|
|
enc.Items.Add(new ComboBoxItem { Content = "자동", Tag = "auto" });
|
|
enc.Items.Add(new ComboBoxItem { Content = "UTF-8", Tag = "utf-8" });
|
|
enc.Items.Add(new ComboBoxItem { Content = "CP949", Tag = "cp949" });
|
|
enc.SelectedIndex = (entry.LogEncoding ?? "auto") switch { "utf-8" => 1, "cp949" => 2, _ => 0 };
|
|
Grid.SetColumn(enc, 3);
|
|
|
|
var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) };
|
|
Grid.SetColumn(open, 4);
|
|
open.Click += (s, e) => OpenInExplorer();
|
|
|
|
grid.Children.Add(title);
|
|
grid.Children.Add(_path);
|
|
grid.Children.Add(encLabel);
|
|
grid.Children.Add(enc);
|
|
grid.Children.Add(open);
|
|
bar.Child = grid;
|
|
Children.Add(bar);
|
|
|
|
// ── 로그 목록
|
|
_panel = new LogPanel { ShowTimestamp = false, MaxLines = 50000 };
|
|
Children.Add(_panel);
|
|
|
|
_tailer = new LogTailer(entry, (lines, level) => _panel.AddMany(lines, level));
|
|
_tailer.FileChanged += f => Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
_path.Text = entry.LogIsFolder ? $"폴더: {entry.LogPath} → {Path.GetFileName(f)}" : "파일: " + f;
|
|
_path.ToolTip = f;
|
|
}));
|
|
|
|
enc.SelectionChanged += (s, e) =>
|
|
{
|
|
if (enc.SelectedItem is ComboBoxItem item)
|
|
{
|
|
entry.LogEncoding = (string)item.Tag;
|
|
EntryChanged?.Invoke();
|
|
_panel.Add("──────── 인코딩 변경: " + item.Content + " ────────", LogLevel.Notice);
|
|
_tailer.Reload();
|
|
}
|
|
};
|
|
|
|
_tailer.Start();
|
|
}
|
|
|
|
/// <summary>로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음</summary>
|
|
public void PathChanged()
|
|
{
|
|
_path.Text = (Entry.LogIsFolder ? "폴더: " : "파일: ") + Entry.LogPath;
|
|
_path.ToolTip = null;
|
|
_panel.Add("──────── 로그 경로 변경: " + Entry.LogPath + " ────────", LogLevel.Notice);
|
|
_tailer.Reload();
|
|
}
|
|
|
|
private void OpenInExplorer()
|
|
{
|
|
try
|
|
{
|
|
var f = _tailer.CurrentFile;
|
|
if (!string.IsNullOrEmpty(f) && File.Exists(f))
|
|
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{f}\"") { UseShellExecute = true });
|
|
else if (Directory.Exists(Entry.LogPath))
|
|
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Entry.LogPath}\"") { UseShellExecute = true });
|
|
else
|
|
{
|
|
var dir = Path.GetDirectoryName(Entry.LogPath);
|
|
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
|
|
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true });
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public void Dispose() => _tailer.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 로그창 띄우기:
|
|
/// 1. 개별 창 2. 하나의 창에 탭 3. 창을 좌우로 나누어 비교
|
|
/// </summary>
|
|
public sealed class LogWindowManager
|
|
{
|
|
private readonly Func<AppSettings> _settings;
|
|
private readonly Action _save;
|
|
|
|
// 개별 창
|
|
private readonly Dictionary<string, Window> _individual = new Dictionary<string, Window>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// 탭
|
|
private Window _tabWindow;
|
|
private TabControl _tabs;
|
|
|
|
// 좌우 분할
|
|
private Window _splitWindow;
|
|
private SplitPane _left, _right;
|
|
|
|
public LogWindowManager(Func<AppSettings> settings, Action save)
|
|
{
|
|
_settings = settings;
|
|
_save = save;
|
|
}
|
|
|
|
public void Open(ServiceEntry entry)
|
|
{
|
|
switch (_settings().LogWindowMode)
|
|
{
|
|
case LogWindowMode.Tabs: OpenTab(entry); break;
|
|
case LogWindowMode.Split: OpenSplit(entry); break;
|
|
default: OpenIndividual(entry); break;
|
|
}
|
|
}
|
|
|
|
/// <summary>서비스 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신)</summary>
|
|
public void ServicesChanged()
|
|
{
|
|
_left?.RefreshChoices(LogServices());
|
|
_right?.RefreshChoices(LogServices());
|
|
}
|
|
|
|
/// <summary>서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 서비스의 로그창을 닫음</summary>
|
|
public void EntryPathChanged(ServiceEntry entry)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entry.LogPath))
|
|
{
|
|
Close(entry.ServiceName);
|
|
ServicesChanged();
|
|
return;
|
|
}
|
|
foreach (var v in OpenViews(entry.ServiceName)) v.PathChanged();
|
|
ServicesChanged();
|
|
}
|
|
|
|
/// <summary>서비스가 목록에서 빠졌을 때 그 서비스의 로그창을 닫음</summary>
|
|
public void Close(string serviceName)
|
|
{
|
|
if (_individual.TryGetValue(serviceName, out var w)) w.Close();
|
|
|
|
if (_tabs != null)
|
|
{
|
|
foreach (var t in _tabs.Items.Cast<TabItem>().ToList())
|
|
{
|
|
if (t.Content is LogTailView v && Same(v.Entry.ServiceName, serviceName))
|
|
{
|
|
v.Dispose();
|
|
_tabs.Items.Remove(t);
|
|
}
|
|
}
|
|
if (_tabs.Items.Count == 0) _tabWindow?.Close();
|
|
}
|
|
|
|
if (_left != null && _left.ShowsName(serviceName)) _left.Clear();
|
|
if (_right != null && _right.ShowsName(serviceName)) _right.Clear();
|
|
}
|
|
|
|
private IEnumerable<LogTailView> OpenViews(string serviceName)
|
|
{
|
|
if (_individual.TryGetValue(serviceName, out var w) && w.Content is Border b && b.Child is LogTailView iv)
|
|
yield return iv;
|
|
if (_tabs != null)
|
|
foreach (TabItem t in _tabs.Items)
|
|
if (t.Content is LogTailView tv && Same(tv.Entry.ServiceName, serviceName)) yield return tv;
|
|
if (_left?.View != null && _left.ShowsName(serviceName)) yield return _left.View;
|
|
if (_right?.View != null && _right.ShowsName(serviceName)) yield return _right.View;
|
|
}
|
|
|
|
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
|
|
|
public void CloseAll()
|
|
{
|
|
foreach (var w in _individual.Values.ToList()) w.Close();
|
|
_tabWindow?.Close();
|
|
_splitWindow?.Close();
|
|
}
|
|
|
|
private List<ServiceEntry> LogServices()
|
|
=> _settings().Services.Where(s => !string.IsNullOrWhiteSpace(s.LogPath)).ToList();
|
|
|
|
private LogTailView NewView(ServiceEntry entry)
|
|
{
|
|
var v = new LogTailView(entry);
|
|
v.EntryChanged += _save;
|
|
return v;
|
|
}
|
|
|
|
private static Window NewWindow(string title, object content, double w = 960, double h = 620)
|
|
{
|
|
var win = new Window
|
|
{
|
|
Title = title,
|
|
Content = content,
|
|
Width = w,
|
|
Height = h,
|
|
FontFamily = new FontFamily("Malgun Gothic"),
|
|
FontSize = 13,
|
|
Background = Brushes2.Make("#FAFAFA"),
|
|
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
|
};
|
|
AppIcon.Apply(win);
|
|
return win;
|
|
}
|
|
|
|
private static void Activate(Window w)
|
|
{
|
|
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
|
|
w.Show();
|
|
w.Activate();
|
|
}
|
|
|
|
// ───────────────────────── 1. 개별 창 ─────────────────────────
|
|
|
|
private void OpenIndividual(ServiceEntry entry)
|
|
{
|
|
if (_individual.TryGetValue(entry.ServiceName, out var existing))
|
|
{
|
|
Activate(existing);
|
|
return;
|
|
}
|
|
var view = NewView(entry);
|
|
var win = NewWindow($"로그 - {entry.DisplayName}", new Border { Padding = new Thickness(6), Child = view });
|
|
win.Closed += (s, e) =>
|
|
{
|
|
view.Dispose();
|
|
_individual.Remove(entry.ServiceName);
|
|
};
|
|
_individual[entry.ServiceName] = win;
|
|
// 여러 창이 겹치지 않도록 조금씩 비켜서 띄움
|
|
int n = _individual.Count - 1;
|
|
win.WindowStartupLocation = WindowStartupLocation.Manual;
|
|
win.Left = Math.Max(0, (SystemParameters.WorkArea.Width - win.Width) / 2 + n * 28 % 200);
|
|
win.Top = Math.Max(0, (SystemParameters.WorkArea.Height - win.Height) / 2 + n * 28 % 200);
|
|
Activate(win);
|
|
}
|
|
|
|
// ───────────────────────── 2. 탭 ─────────────────────────
|
|
|
|
private void OpenTab(ServiceEntry entry)
|
|
{
|
|
if (_tabWindow == null)
|
|
{
|
|
_tabs = new TabControl { Margin = new Thickness(6) };
|
|
_tabWindow = NewWindow("로그 (탭)", _tabs);
|
|
_tabWindow.Closed += (s, e) =>
|
|
{
|
|
foreach (TabItem t in _tabs.Items)
|
|
(t.Content as LogTailView)?.Dispose();
|
|
_tabs = null;
|
|
_tabWindow = null;
|
|
};
|
|
}
|
|
|
|
foreach (TabItem t in _tabs.Items)
|
|
{
|
|
if (t.Content is LogTailView v && string.Equals(v.Entry.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_tabs.SelectedItem = t;
|
|
Activate(_tabWindow);
|
|
return;
|
|
}
|
|
}
|
|
|
|
var view = NewView(entry);
|
|
var tab = new TabItem { Content = view };
|
|
|
|
var header = new StackPanel { Orientation = Orientation.Horizontal };
|
|
header.Children.Add(new TextBlock { Text = entry.DisplayName, VerticalAlignment = VerticalAlignment.Center });
|
|
var close = new Button
|
|
{
|
|
Content = "✕",
|
|
MinWidth = 0,
|
|
MinHeight = 0,
|
|
Padding = new Thickness(4, 0, 4, 0),
|
|
Margin = new Thickness(8, 0, 0, 0),
|
|
FontSize = 10,
|
|
BorderThickness = new Thickness(0),
|
|
Background = Brushes.Transparent,
|
|
ToolTip = "탭 닫기"
|
|
};
|
|
close.Click += (s, e) =>
|
|
{
|
|
view.Dispose();
|
|
_tabs.Items.Remove(tab);
|
|
if (_tabs.Items.Count == 0) _tabWindow.Close();
|
|
};
|
|
header.Children.Add(close);
|
|
tab.Header = header;
|
|
|
|
_tabs.Items.Add(tab);
|
|
_tabs.SelectedItem = tab;
|
|
Activate(_tabWindow);
|
|
}
|
|
|
|
// ───────────────────────── 3. 좌우 분할 ─────────────────────────
|
|
|
|
private void OpenSplit(ServiceEntry entry)
|
|
{
|
|
if (_splitWindow == null)
|
|
{
|
|
var grid = new Grid { Margin = new Thickness(6) };
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(6) });
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
|
|
_left = new SplitPane(NewView, "왼쪽");
|
|
_right = new SplitPane(NewView, "오른쪽");
|
|
_left.RefreshChoices(LogServices());
|
|
_right.RefreshChoices(LogServices());
|
|
|
|
var splitter = new GridSplitter
|
|
{
|
|
Width = 6,
|
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
VerticalAlignment = VerticalAlignment.Stretch,
|
|
Background = Brushes2.Make("#CFD8DC"),
|
|
ResizeBehavior = GridResizeBehavior.PreviousAndNext
|
|
};
|
|
Grid.SetColumn(_left, 0);
|
|
Grid.SetColumn(splitter, 1);
|
|
Grid.SetColumn(_right, 2);
|
|
grid.Children.Add(_left);
|
|
grid.Children.Add(splitter);
|
|
grid.Children.Add(_right);
|
|
|
|
_splitWindow = NewWindow("로그 비교 (좌우)", grid, 1400, 700);
|
|
_splitWindow.Closed += (s, e) =>
|
|
{
|
|
_left.Dispose();
|
|
_right.Dispose();
|
|
_left = _right = null;
|
|
_splitWindow = null;
|
|
};
|
|
}
|
|
|
|
if (!_left.Shows(entry) && !_right.Shows(entry))
|
|
{
|
|
if (_left.IsEmpty) _left.Show(entry);
|
|
else if (_right.IsEmpty) _right.Show(entry);
|
|
else _right.Show(entry);
|
|
}
|
|
Activate(_splitWindow);
|
|
}
|
|
|
|
/// <summary>좌우 분할 창의 한쪽</summary>
|
|
private sealed class SplitPane : DockPanel, IDisposable
|
|
{
|
|
private readonly Func<ServiceEntry, LogTailView> _factory;
|
|
private readonly ComboBox _combo;
|
|
private readonly ContentControl _host;
|
|
private LogTailView _view;
|
|
private bool _updating;
|
|
|
|
public bool IsEmpty => _view == null;
|
|
public LogTailView View => _view;
|
|
|
|
private readonly UIElement _placeholder;
|
|
|
|
public SplitPane(Func<ServiceEntry, LogTailView> factory, string side)
|
|
{
|
|
_factory = factory;
|
|
|
|
var top = new DockPanel { Margin = new Thickness(0, 0, 0, 4) };
|
|
SetDock(top, Dock.Top);
|
|
var lbl = new TextBlock { Text = side + " 서비스:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) };
|
|
SetDock(lbl, Dock.Left);
|
|
_combo = new ComboBox { DisplayMemberPath = nameof(ServiceEntry.DisplayName), MinWidth = 200 };
|
|
top.Children.Add(lbl);
|
|
top.Children.Add(_combo);
|
|
Children.Add(top);
|
|
|
|
_placeholder = new TextBlock
|
|
{
|
|
Text = "위에서 서비스를 선택하거나, 초기화면에서 Log 버튼을 누르세요.",
|
|
Foreground = Brushes.Gray,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
};
|
|
_host = new ContentControl { Content = _placeholder };
|
|
Children.Add(_host);
|
|
|
|
_combo.SelectionChanged += (s, e) =>
|
|
{
|
|
if (_updating) return;
|
|
if (_combo.SelectedItem is ServiceEntry se) Show(se);
|
|
};
|
|
}
|
|
|
|
public bool Shows(ServiceEntry e) => ShowsName(e.ServiceName);
|
|
|
|
public bool ShowsName(string serviceName)
|
|
=> _view != null && string.Equals(_view.Entry.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase);
|
|
|
|
public void Clear()
|
|
{
|
|
_view?.Dispose();
|
|
_view = null;
|
|
_host.Content = _placeholder;
|
|
_updating = true;
|
|
_combo.SelectedItem = null;
|
|
_updating = false;
|
|
}
|
|
|
|
public void Show(ServiceEntry entry)
|
|
{
|
|
_view?.Dispose();
|
|
_view = _factory(entry);
|
|
_host.Content = _view;
|
|
SelectInCombo(entry);
|
|
}
|
|
|
|
public void RefreshChoices(List<ServiceEntry> services)
|
|
{
|
|
_updating = true;
|
|
_combo.ItemsSource = services;
|
|
_updating = false;
|
|
if (_view != null) SelectInCombo(_view.Entry);
|
|
}
|
|
|
|
private void SelectInCombo(ServiceEntry entry)
|
|
{
|
|
_updating = true;
|
|
_combo.SelectedItem = (_combo.ItemsSource as IEnumerable<ServiceEntry>)?
|
|
.FirstOrDefault(x => string.Equals(x.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase));
|
|
_updating = false;
|
|
}
|
|
|
|
public void Dispose() => _view?.Dispose();
|
|
}
|
|
}
|
|
}
|