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();
}
}
}