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 { /// /// 로그창에 띄울 대상 하나 (내 PC 서비스 로그 파일 또는 원격 PC 서비스 로그) /// public sealed class LogTarget { /// 창/탭을 구분하는 키 ("local|서비스명" / "remote|대상Id|서비스명") public string Key { get; init; } = ""; /// 제목 (원격이면 "IP / hostname - 서비스명") public string Title { get; init; } = ""; /// 경로 표시 public Func PathText { get; init; } = () => ""; public bool IsLocal { get; init; } /// 내 PC 서비스일 때 (인코딩 저장, 탐색기 열기용) public ServiceEntry LocalEntry { get; init; } /// 인코딩 (내 PC는 설정에 저장, 원격은 창을 연 동안만) public string Encoding { get => LocalEntry?.LogEncoding ?? _encoding; set { if (LocalEntry != null) LocalEntry.LogEncoding = value; else _encoding = value; } } private string _encoding = "auto"; /// 로그 공급원 만들기 (출력 콜백을 받아서) public Func, LogLevel>, ILogSource> CreateSource { get; init; } public override string ToString() => Title; public static LogTarget ForLocal(ServiceEntry e) => new LogTarget { Key = "local|" + e.ServiceName, Title = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName, PathText = () => (e.LogIsFolder ? "폴더: " : "파일: ") + e.LogPath, IsLocal = true, LocalEntry = e, CreateSource = (t, output) => new LogTailer(e, output) }; } /// 로그 보기 하나 (상단 정보줄 + 로그 목록) public sealed class LogTailView : DockPanel, IDisposable { private ILogSource _source; private readonly TextBlock _path; private readonly LogPanel _panel; public LogTarget Target { get; } /// 인코딩을 바꾸면 설정 저장용으로 호출됨 public event Action TargetChanged; public LogTailView(LogTarget target) { Target = target; LastChildFill = true; // ── 상단 정보줄 var bar = new Border { Background = Brushes2.Make(target.IsLocal ? "#F1F8FB" : "#F3E5F5"), 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 = target.Title, FontWeight = FontWeights.Bold, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 12, 0), Foreground = Brushes2.Make(target.IsLocal ? "#155E75" : "#6A1B9A") }; Grid.SetColumn(title, 0); _path = new TextBlock { Text = target.PathText(), 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 = (target.Encoding ?? "auto") switch { "utf-8" => 1, "cp949" => 2, _ => 0 }; Grid.SetColumn(enc, 3); grid.Children.Add(title); grid.Children.Add(_path); grid.Children.Add(encLabel); grid.Children.Add(enc); if (target.IsLocal) { var open = new Button { Content = "탐색기에서 열기", Margin = new Thickness(6, 0, 0, 0) }; Grid.SetColumn(open, 4); open.Click += (s, e) => OpenInExplorer(); grid.Children.Add(open); } bar.Child = grid; Children.Add(bar); // ── 로그 목록 _panel = new LogPanel { ShowTimestamp = false, MaxLines = 50000 }; Children.Add(_panel); CreateSource(); enc.SelectionChanged += (s, e) => { if (enc.SelectedItem is ComboBoxItem item) { target.Encoding = (string)item.Tag; TargetChanged?.Invoke(); _panel.Add("──────── 인코딩 변경: " + item.Content + " ────────", LogLevel.Notice); _source.Reload(); } }; } private void CreateSource() { _source = Target.CreateSource(Target, (lines, level) => _panel.AddMany(lines, level)); _source.FileChanged += f => Dispatcher.BeginInvoke(new Action(() => { var e = Target.LocalEntry; _path.Text = e != null && e.LogIsFolder ? $"폴더: {e.LogPath} → {Path.GetFileName(f)}" : (Target.IsLocal ? "파일: " : "대상 PC 파일: ") + f; _path.ToolTip = f; })); _source.Start(); } /// 로그 경로가 바뀌면 표시를 갱신하고 새 경로로 다시 읽음 public void PathChanged() { _path.Text = Target.PathText(); _path.ToolTip = null; _panel.Add("──────── 로그 경로 변경: " + Target.PathText() + " ────────", LogLevel.Notice); _source.Reload(); } private void OpenInExplorer() { var entry = Target.LocalEntry; if (entry == null) return; try { var f = _source.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() => _source?.Dispose(); } /// /// 로그창 띄우기: /// 1. 개별 창 2. 하나의 창에 탭 3. 창을 좌우로 나누어 비교 /// public sealed class LogWindowManager { private readonly Func _settings; private readonly Action _save; private readonly Func> _choices; // 개별 창 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, Func> choices) { _settings = settings; _save = save; _choices = choices; } public void Open(LogTarget target) { switch (_settings().LogWindowMode) { case LogWindowMode.Tabs: OpenTab(target); break; case LogWindowMode.Split: OpenSplit(target); break; default: OpenIndividual(target); break; } } /// 목록이 바뀌었을 때 (분할 창의 선택 목록 갱신) public void ChoicesChanged() { if (_left == null) return; var list = _choices(); _left.RefreshChoices(list); _right.RefreshChoices(list); } /// 내 PC 서비스의 로그 경로가 바뀌었을 때. 경로가 비었으면 그 로그창을 닫음 public void LocalPathChanged(ServiceEntry entry) { var key = "local|" + entry.ServiceName; if (string.IsNullOrWhiteSpace(entry.LogPath)) Close(key); else foreach (var v in OpenViews(key)) v.PathChanged(); ChoicesChanged(); } /// 키가 같은 로그창을 닫음. prefix=true면 키가 그 문자열로 시작하는 것 모두 public void Close(string key, bool prefix = false) { bool Match(string k) => prefix ? k.StartsWith(key, StringComparison.OrdinalIgnoreCase) : Same(k, key); foreach (var k in _individual.Keys.Where(Match).ToList()) _individual[k].Close(); if (_tabs != null) { foreach (var t in _tabs.Items.Cast().ToList()) { if (t.Content is LogTailView v && Match(v.Target.Key)) { v.Dispose(); _tabs.Items.Remove(t); } } if (_tabs.Items.Count == 0) _tabWindow?.Close(); } if (_left?.View != null && Match(_left.View.Target.Key)) _left.Clear(); if (_right?.View != null && Match(_right.View.Target.Key)) _right.Clear(); } private IEnumerable OpenViews(string key) { if (_individual.TryGetValue(key, 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.Target.Key, key)) yield return tv; if (_left?.View != null && Same(_left.View.Target.Key, key)) yield return _left.View; if (_right?.View != null && Same(_right.View.Target.Key, key)) 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 LogTailView NewView(LogTarget target) { var v = new LogTailView(target); if (target.IsLocal) v.TargetChanged += _save; return v; } private static Window NewWindow(string title, object content, double w = 960, double h = 620) { var win = new Window { Title = title + AppInstance.TitleTag, 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(LogTarget target) { if (_individual.TryGetValue(target.Key, out var existing)) { Activate(existing); return; } var view = NewView(target); var win = NewWindow($"로그 - {target.Title}", new Border { Padding = new Thickness(6), Child = view }); win.Closed += (s, e) => { view.Dispose(); _individual.Remove(target.Key); }; _individual[target.Key] = 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(LogTarget target) { 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 && Same(v.Target.Key, target.Key)) { _tabs.SelectedItem = t; Activate(_tabWindow); return; } } var view = NewView(target); var tab = new TabItem { Content = view }; tab.Header = TabHeader(target.Title, () => { view.Dispose(); _tabs.Items.Remove(tab); if (_tabs.Items.Count == 0) _tabWindow.Close(); }); _tabs.Items.Add(tab); _tabs.SelectedItem = tab; Activate(_tabWindow); } /// 제목 + ✕ 닫기 버튼이 있는 탭 머리글 public static StackPanel TabHeader(object title, Action onClose) { var header = new StackPanel { Orientation = Orientation.Horizontal }; if (title is UIElement el) header.Children.Add(el); else header.Children.Add(new TextBlock { Text = title?.ToString() ?? "", 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) => onClose(); header.Children.Add(close); return header; } // ───────────────────────── 3. 좌우 분할 ───────────────────────── private void OpenSplit(LogTarget target) { 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, "오른쪽"); var list = _choices(); _left.RefreshChoices(list); _right.RefreshChoices(list); 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(target.Key) && !_right.Shows(target.Key)) { if (_left.IsEmpty) _left.Show(target); else if (_right.IsEmpty) _right.Show(target); else _right.Show(target); } Activate(_splitWindow); } /// 좌우 분할 창의 한쪽 private sealed class SplitPane : DockPanel, IDisposable { private readonly Func _factory; private readonly ComboBox _combo; private readonly ContentControl _host; private readonly UIElement _placeholder; private LogTailView _view; private bool _updating; public bool IsEmpty => _view == null; public LogTailView View => _view; 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(LogTarget.Title), 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 LogTarget t) Show(t); }; } public bool Shows(string key) => _view != null && string.Equals(_view.Target.Key, key, StringComparison.OrdinalIgnoreCase); public void Clear() { _view?.Dispose(); _view = null; _host.Content = _placeholder; _updating = true; _combo.SelectedItem = null; _updating = false; } public void Show(LogTarget target) { _view?.Dispose(); _view = _factory(target); _host.Content = _view; SelectInCombo(target.Key); } public void RefreshChoices(List list) { _updating = true; _combo.ItemsSource = list; _updating = false; if (_view != null) SelectInCombo(_view.Target.Key); } private void SelectInCombo(string key) { _updating = true; _combo.SelectedItem = (_combo.ItemsSource as IEnumerable)? .FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); _updating = false; } public void Dispose() => _view?.Dispose(); } } }