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 { /// 서비스 한 개의 로그 파일 보기 (상단 정보줄 + 로그 목록) public sealed class LogTailView : DockPanel, IDisposable { private readonly LogTailer _tailer; private readonly TextBlock _path; private readonly LogPanel _panel; public ServiceEntry Entry { get; } /// 인코딩을 바꾸면 설정 저장용으로 호출됨 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(); } /// 로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음 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(); } /// /// 로그창 띄우기: /// 1. 개별 창 2. 하나의 창에 탭 3. 창을 좌우로 나누어 비교 /// public sealed class LogWindowManager { private readonly Func _settings; private readonly Action _save; // 개별 창 private readonly Dictionary _individual = new Dictionary(StringComparer.OrdinalIgnoreCase); // 탭 private Window _tabWindow; private TabControl _tabs; // 좌우 분할 private Window _splitWindow; private SplitPane _left, _right; public LogWindowManager(Func 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; } } /// 서비스 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신) public void ServicesChanged() { _left?.RefreshChoices(LogServices()); _right?.RefreshChoices(LogServices()); } /// 서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 서비스의 로그창을 닫음 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(); } /// 서비스가 목록에서 빠졌을 때 그 서비스의 로그창을 닫음 public void Close(string serviceName) { if (_individual.TryGetValue(serviceName, out var w)) w.Close(); if (_tabs != null) { foreach (var t in _tabs.Items.Cast().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 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 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); } /// 좌우 분할 창의 한쪽 private sealed class SplitPane : DockPanel, IDisposable { private readonly Func _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 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 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)? .FirstOrDefault(x => string.Equals(x.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase)); _updating = false; } public void Dispose() => _view?.Dispose(); } } }