using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Text.Json.Nodes; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Media; using JJServiceManager.Remote; namespace JJServiceManager { /// /// 설정 → [서버 관리] 탭 (중계 서버 관리자 계정으로 로그인했을 때만). /// 계정 / 권한 / 기기 / 등록 코드 / 감사 로그 / 연결 기록 /// public sealed class ServerAdminPanel : DockPanel { private readonly RelayManager _relay; private readonly Window _owner; private readonly TextBlock _info, _status; // 계정 private readonly ObservableCollection _users = new ObservableCollection(); private readonly ListView _userList; private readonly TextBox _newUser, _newNote; private readonly PasswordBox _newPw; private readonly ComboBox _newRole; // 권한 private readonly ComboBox _permUser; private readonly ObservableCollection _perms = new ObservableCollection(); private readonly TextBlock _permNote; private bool _permLoading; // 기기 private readonly ObservableCollection _devices = new ObservableCollection(); private readonly ListView _deviceList; // 등록 코드 private readonly ObservableCollection _codes = new ObservableCollection(); private readonly ListView _codeList; private readonly ComboBox _codeHours; private readonly TextBox _codeNote, _codeShown; // 감사 로그 / 연결 기록 private readonly ObservableCollection _audit = new ObservableCollection(); private readonly ObservableCollection _conns = new ObservableCollection(); private readonly DatePicker _aFrom, _aTo, _cFrom, _cTo; private readonly ComboBox _aUser, _aDevice, _aAction, _cUser, _cDevice; private long? _auditLastId, _connLastId; public ServerAdminPanel(RelayManager relay, Window owner) { _relay = relay; _owner = owner; Background = Brushes.White; // 위: 서버 정보 + 상태 var top = new DockPanel { Margin = new Thickness(10, 8, 10, 4) }; var refreshAll = Btn("모두 새로고침", async () => await LoadAllAsync()); DockPanel.SetDock(refreshAll, Dock.Right); top.Children.Add(refreshAll); _info = new TextBlock { Foreground = Brushes2.Make("#37474F"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; top.Children.Add(_info); DockPanel.SetDock(top, Dock.Top); Children.Add(top); _status = new TextBlock { Margin = new Thickness(10, 4, 10, 6), TextWrapping = TextWrapping.Wrap }; DockPanel.SetDock(_status, Dock.Bottom); Children.Add(_status); var tabs = new TabControl { Margin = new Thickness(6, 0, 6, 0) }; Children.Add(tabs); // ───────── 계정 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var form = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) }; _newUser = new TextBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center }; _newPw = new PasswordBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center }; _newRole = new ComboBox { Width = 100, Height = 26, ItemsSource = new[] { "일반 사용자", "관리자" }, SelectedIndex = 0 }; _newNote = new TextBox { Width = 140, Height = 26, VerticalContentAlignment = VerticalAlignment.Center }; form.Children.Add(Label("아이디")); form.Children.Add(_newUser); form.Children.Add(Label("처음 비밀번호")); form.Children.Add(_newPw); form.Children.Add(Label("역할")); form.Children.Add(_newRole); form.Children.Add(Label("메모")); form.Children.Add(_newNote); form.Children.Add(Btn("추가", AddUserAsync, bold: true)); DockPanel.SetDock(form, Dock.Bottom); p.Children.Add(form); var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) }; buttons.Children.Add(Btn("새로고침", LoadUsersAsync)); buttons.Children.Add(Btn("비밀번호 초기화", ResetPasswordAsync)); buttons.Children.Add(Btn("역할 바꾸기", ToggleRoleAsync)); buttons.Children.Add(Btn("사용 / 사용 안 함", ToggleUserAsync)); buttons.Children.Add(Btn("메모", EditUserNoteAsync)); buttons.Children.Add(Note("새 계정은 첫 로그인 때 비밀번호를 바꿔야 합니다. 계정은 지우지 않고 '사용 안 함'으로 합니다 (감사 로그 보존).")); DockPanel.SetDock(buttons, Dock.Bottom); p.Children.Add(buttons); _userList = Table(_users, ("아이디", nameof(UserRowItem.Username), 130), ("역할", nameof(UserRowItem.RoleText), 80), ("상태", nameof(UserRowItem.StateText), 110), ("마지막 로그인", nameof(UserRowItem.LastLogin), 140), ("만든 때", nameof(UserRowItem.Created), 140), ("메모", nameof(UserRowItem.Note), 160)); p.Children.Add(_userList); tabs.Items.Add(new TabItem { Header = " 계정 ", Content = p }); } // ───────── 권한 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var head = new DockPanel { Margin = new Thickness(0, 0, 0, 6) }; head.Children.Add(Label("사용자")); _permUser = new ComboBox { Width = 200, Height = 26, DisplayMemberPath = nameof(UserRowItem.Username) }; _permUser.SelectionChanged += async (s, e) => await LoadPermsAsync(); head.Children.Add(_permUser); _permNote = Note("체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다."); _permNote.Margin = new Thickness(12, 0, 0, 0); head.Children.Add(_permNote); DockPanel.SetDock(head, Dock.Top); p.Children.Add(head); var list = new ListView { ItemsSource = _perms }; var gv = new GridView(); gv.Columns.Add(new GridViewColumn { Header = "기기", Width = 170, DisplayMemberBinding = new Binding(nameof(PermRowItem.Name)) }); gv.Columns.Add(new GridViewColumn { Header = "hostname", Width = 130, DisplayMemberBinding = new Binding(nameof(PermRowItem.Hostname)) }); gv.Columns.Add(CheckColumn("보기", nameof(PermRowItem.View))); gv.Columns.Add(CheckColumn("서비스 제어", nameof(PermRowItem.Control))); gv.Columns.Add(CheckColumn("화면 보기", nameof(PermRowItem.ScreenView))); gv.Columns.Add(CheckColumn("화면 제어", nameof(PermRowItem.ScreenControl))); list.View = gv; list.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(async (s, e) => { if (e.OriginalSource is CheckBox cb && cb.DataContext is PermRowItem row) await SavePermAsync(row); })); p.Children.Add(list); tabs.Items.Add(new TabItem { Header = " 권한 ", Content = p }); } // ───────── 기기 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) }; buttons.Children.Add(Btn("새로고침", LoadDevicesAsync)); buttons.Children.Add(Btn("이름 바꾸기", RenameDeviceAsync)); buttons.Children.Add(Btn("메모", EditDeviceNoteAsync)); buttons.Children.Add(Btn("사용 안 함", () => SetDeviceEnabledAsync(false))); buttons.Children.Add(Btn("사용", () => SetDeviceEnabledAsync(true))); buttons.Children.Add(Btn("삭제", DeleteDeviceAsync)); buttons.Children.Add(Note("'사용 안 함'은 바로 접속이 끊기고, 다시 '사용'하면 그대로 접속됩니다. '삭제'하면 그 PC는 새 등록 코드로 다시 등록해야 합니다.")); DockPanel.SetDock(buttons, Dock.Bottom); p.Children.Add(buttons); _deviceList = Table(_devices, ("이름", nameof(DeviceRowItem.Name), 140), ("hostname", nameof(DeviceRowItem.Hostname), 110), ("상태", nameof(DeviceRowItem.StateText), 90), ("외부 IP", nameof(DeviceRowItem.PublicIp), 110), ("내부 IP", nameof(DeviceRowItem.LanIps), 130), ("Active 수신", nameof(DeviceRowItem.ActiveText), 100), ("버전", nameof(DeviceRowItem.Version), 60), ("마지막 접속", nameof(DeviceRowItem.LastSeen), 140), ("등록", nameof(DeviceRowItem.Enrolled), 140), ("메모", nameof(DeviceRowItem.Note), 140)); p.Children.Add(_deviceList); tabs.Items.Add(new TabItem { Header = " 기기 ", Content = p }); } // ───────── 등록 코드 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var form = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) }; _codeHours = new ComboBox { Width = 90, Height = 26, ItemsSource = new[] { "1시간", "24시간", "7일", "30일" }, SelectedIndex = 2 }; _codeNote = new TextBox { Width = 200, Height = 26, VerticalContentAlignment = VerticalAlignment.Center }; form.Children.Add(Label("유효 시간")); form.Children.Add(_codeHours); form.Children.Add(Label("메모 (어느 PC용)")); form.Children.Add(_codeNote); form.Children.Add(Btn("새 코드 발급", CreateCodeAsync, bold: true)); DockPanel.SetDock(form, Dock.Top); p.Children.Add(form); _codeShown = new TextBox { IsReadOnly = true, FontFamily = new FontFamily("Consolas"), FontSize = 22, FontWeight = FontWeights.Bold, Height = 40, VerticalContentAlignment = VerticalAlignment.Center, HorizontalContentAlignment = HorizontalAlignment.Center, Background = Brushes2.Make("#FFF8E1"), Visibility = Visibility.Collapsed, Margin = new Thickness(0, 0, 0, 6), ToolTip = "대상 PC의 설정 → 원격 접속 허용 → 중계 서버 접속 → [등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다." }; DockPanel.SetDock(_codeShown, Dock.Top); p.Children.Add(_codeShown); var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) }; buttons.Children.Add(Btn("새로고침", LoadCodesAsync)); buttons.Children.Add(Btn("선택한 코드 취소", RevokeCodeAsync)); buttons.Children.Add(Note("코드는 한 번만 쓸 수 있고, 발급할 때만 보입니다.")); DockPanel.SetDock(buttons, Dock.Bottom); p.Children.Add(buttons); _codeList = Table(_codes, ("상태", nameof(CodeRowItem.StateText), 80), ("만든 사람", nameof(CodeRowItem.CreatedBy), 100), ("만든 때", nameof(CodeRowItem.Created), 140), ("만료", nameof(CodeRowItem.Expires), 140), ("사용한 기기", nameof(CodeRowItem.UsedDevice), 130), ("사용한 때", nameof(CodeRowItem.Used), 140), ("메모", nameof(CodeRowItem.Note), 160)); p.Children.Add(_codeList); tabs.Items.Add(new TabItem { Header = " 등록 코드 ", Content = p }); } // ───────── 감사 로그 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) }; _aFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) }; _aTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today }; _aUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" }; _aDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" }; _aAction = new ComboBox { Width = 130, Height = 26, DisplayMemberPath = "Text", ItemsSource = AuditActions(), SelectedIndex = 0 }; f.Children.Add(Label("기간")); f.Children.Add(_aFrom); f.Children.Add(Label("~")); f.Children.Add(_aTo); f.Children.Add(Label("사용자")); f.Children.Add(_aUser); f.Children.Add(Label("기기")); f.Children.Add(_aDevice); f.Children.Add(Label("작업")); f.Children.Add(_aAction); f.Children.Add(Btn("조회", () => QueryAuditAsync(false), bold: true)); f.Children.Add(Btn("더 보기", () => QueryAuditAsync(true))); f.Children.Add(Btn("복사", () => { Copy(_audit.Select(a => a.ToLine())); return Task.CompletedTask; })); DockPanel.SetDock(f, Dock.Top); p.Children.Add(f); p.Children.Add(Table(_audit, ("시각", nameof(AuditRowItem.At), 140), ("사용자", nameof(AuditRowItem.User), 90), ("기기", nameof(AuditRowItem.Device), 120), ("작업", nameof(AuditRowItem.ActionText), 110), ("대상", nameof(AuditRowItem.Target), 120), ("결과", nameof(AuditRowItem.ResultText), 60), ("내용", nameof(AuditRowItem.Detail), 220), ("IP", nameof(AuditRowItem.Ip), 110))); tabs.Items.Add(new TabItem { Header = " 감사 로그 ", Content = p }); } // ───────── 연결 기록 ───────── { var p = new DockPanel { Margin = new Thickness(8) }; var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) }; _cFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) }; _cTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today }; _cUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" }; _cDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" }; f.Children.Add(Label("기간")); f.Children.Add(_cFrom); f.Children.Add(Label("~")); f.Children.Add(_cTo); f.Children.Add(Label("사용자")); f.Children.Add(_cUser); f.Children.Add(Label("기기")); f.Children.Add(_cDevice); f.Children.Add(Btn("조회", () => QueryConnsAsync(false), bold: true)); f.Children.Add(Btn("더 보기", () => QueryConnsAsync(true))); f.Children.Add(Btn("복사", () => { Copy(_conns.Select(c => c.ToLine())); return Task.CompletedTask; })); DockPanel.SetDock(f, Dock.Top); p.Children.Add(f); p.Children.Add(Table(_conns, ("시작", nameof(ConnRowItem.Started), 140), ("끝", nameof(ConnRowItem.Ended), 140), ("사용자", nameof(ConnRowItem.User), 90), ("기기", nameof(ConnRowItem.Device), 120), ("방식", nameof(ConnRowItem.ModeText), 60), ("보냄", nameof(ConnRowItem.ToDevice), 80), ("받음", nameof(ConnRowItem.FromDevice), 80), ("끝난 이유", nameof(ConnRowItem.Reason), 200), ("관리 PC IP", nameof(ConnRowItem.Ip), 110))); tabs.Items.Add(new TabItem { Header = " 연결 기록 ", Content = p }); } Loaded += async (s, e) => { if (_users.Count == 0) await LoadAllAsync(); }; } // ───────────────────────── 불러오기 ───────────────────────── private async Task LoadAllAsync() { await Run(async () => { var i = await _relay.RequestAsync("admin.server.info"); _info.Text = $"중계 서버 {Json.Str(i, "version")} · DB {(Json.Bool(i, "db") ? "정상" : "오류")} · 접속 중: 기기 {Json.Int(i, "onlineDevices")}대, 관리 PC {Json.Int(i, "onlineManagers")}곳" + $" · 시작 {Local(Json.Str(i, "startedAt"))} · 로그인: {_relay.Username}"; }); await LoadUsersAsync(); await LoadDevicesAsync(); await LoadCodesAsync(); await QueryAuditAsync(false); await QueryConnsAsync(false); } private async Task LoadUsersAsync() { await Run(async () => { var r = await _relay.RequestAsync("admin.users.list"); var keep = (_permUser.SelectedItem as UserRowItem)?.Id; _users.Clear(); foreach (var o in (r["users"] as JsonArray).OfType()) _users.Add(UserRowItem.From(o)); _permUser.ItemsSource = _users.Where(u => !u.IsAdmin).ToList(); var sel = ((IEnumerable)_permUser.ItemsSource).FirstOrDefault(u => u.Id == keep) ?? ((IEnumerable)_permUser.ItemsSource).FirstOrDefault(); _permUser.SelectedItem = sel; if (sel == null) { _perms.Clear(); _permNote.Text = "일반 사용자가 없습니다. [계정]에서 추가하세요. (관리자는 모든 기기에 모든 권한)"; } FillFilters(); }); } private async Task LoadDevicesAsync() { await Run(async () => { var r = await _relay.RequestAsync("admin.devices.list"); _devices.Clear(); foreach (var o in (r["devices"] as JsonArray).OfType()) _devices.Add(DeviceRowItem.From(o)); FillFilters(); }); } private async Task LoadCodesAsync() { await Run(async () => { var r = await _relay.RequestAsync("admin.codes.list"); _codes.Clear(); foreach (var o in (r["codes"] as JsonArray).OfType()) _codes.Add(CodeRowItem.From(o)); }); } private void FillFilters() { var users = new List { new Pick("(전체)", null) }; users.AddRange(_users.Select(u => new Pick(u.Username, u.Id.ToString()))); var devices = new List { new Pick("(전체)", null) }; devices.AddRange(_devices.Select(d => new Pick(d.Name, d.Id))); foreach (var cb in new[] { _aUser, _cUser }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = users; cb.SelectedItem = users.FirstOrDefault(x => x.Value == k) ?? users[0]; } foreach (var cb in new[] { _aDevice, _cDevice }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = devices; cb.SelectedItem = devices.FirstOrDefault(x => x.Value == k) ?? devices[0]; } } // ───────────────────────── 계정 ───────────────────────── private UserRowItem SelectedUser() { if (_userList.SelectedItem is UserRowItem u) return u; Say("목록에서 계정을 고르세요.", false); return null; } private async Task AddUserAsync() { var name = _newUser.Text.Trim(); if (name.Length == 0) { Say("아이디를 입력하세요.", false); return; } if (_newPw.Password.Length < 8) { Say("처음 비밀번호는 8자 이상이어야 합니다.", false); return; } bool asAdmin = _newRole.SelectedIndex == 1; await Run(async () => { await _relay.RequestAsync("admin.users.create", new JsonObject { ["username"] = name, ["password"] = _newPw.Password, ["role"] = asAdmin ? "admin" : "user", ["note"] = _newNote.Text.Trim(), ["mustChangePassword"] = true }); _newUser.Text = ""; _newPw.Password = ""; _newNote.Text = ""; _newRole.SelectedIndex = 0; Say($"계정 {name} 을(를) 만들었습니다. 첫 로그인 때 비밀번호를 바꾸게 됩니다." + (asAdmin ? "" : " [권한] 탭에서 볼 수 있는 기기를 정하세요."), true); await LoadUsersAsync(); }); } private async Task ResetPasswordAsync() { var u = SelectedUser(); if (u == null) return; var pw = TextPrompt.Ask(_owner, "비밀번호 초기화", $"{u.Username} 의 새 비밀번호 (8자 이상)\n다음 로그인 때 다시 바꾸게 되며, 접속 중인 연결은 끊깁니다.", "", password: true); if (string.IsNullOrEmpty(pw)) return; await Run(async () => { await _relay.RequestAsync("admin.users.resetPassword", new JsonObject { ["userId"] = u.Id, ["password"] = pw }); Say($"{u.Username} 의 비밀번호를 초기화했습니다.", true); await LoadUsersAsync(); }); } private async Task ToggleRoleAsync() { var u = SelectedUser(); if (u == null) return; var to = u.IsAdmin ? "user" : "admin"; if (!Dialogs.Confirm(_owner, $"{u.Username} 을(를) {(to == "admin" ? "관리자" : "일반 사용자")}로 바꿀까요?", "역할 바꾸기")) return; await Run(async () => { await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["role"] = to }); Say("역할을 바꿨습니다.", true); await LoadUsersAsync(); }); } private async Task ToggleUserAsync() { var u = SelectedUser(); if (u == null) return; bool to = !u.Enabled; if (!to && !Dialogs.Confirm(_owner, $"{u.Username} 을(를) 사용 안 함으로 할까요?\n접속 중인 연결은 바로 끊깁니다.", "사용 안 함")) return; await Run(async () => { await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["enabled"] = to }); Say(to ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true); await LoadUsersAsync(); }); } private async Task EditUserNoteAsync() { var u = SelectedUser(); if (u == null) return; var note = TextPrompt.Ask(_owner, "메모", $"{u.Username} 메모", u.Note); if (note == null) return; await Run(async () => { await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["note"] = note }); await LoadUsersAsync(); }); } // ───────────────────────── 권한 ───────────────────────── private async Task LoadPermsAsync() { if (_permUser.SelectedItem is not UserRowItem u) { _perms.Clear(); return; } await Run(async () => { var r = await _relay.RequestAsync("admin.perms.get", new JsonObject { ["userId"] = u.Id }); _permLoading = true; _perms.Clear(); foreach (var o in (r["devices"] as JsonArray).OfType()) _perms.Add(PermRowItem.From(o)); _permLoading = false; _permNote.Text = _perms.Count == 0 ? "등록된 기기가 없습니다. [등록 코드]에서 코드를 발급해 대상 PC를 등록하세요." : "체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다."; }); } private async Task SavePermAsync(PermRowItem row) { if (_permLoading || _permUser.SelectedItem is not UserRowItem u) return; await Run(async () => { var r = await _relay.RequestAsync("admin.perms.set", new JsonObject { ["userId"] = u.Id, ["deviceId"] = row.DeviceId, ["view"] = row.View, ["control"] = row.Control, ["screenView"] = row.ScreenView, ["screenControl"] = row.ScreenControl }); // 서버가 정리한 값으로 (예: 제어 → 보기 자동) row.Set(Json.Bool(r, "view"), Json.Bool(r, "control"), Json.Bool(r, "screenView"), Json.Bool(r, "screenControl")); Say($"{u.Username} → {row.Name}: {row.Summary}", true); }); } // ───────────────────────── 기기 ───────────────────────── private DeviceRowItem SelectedDevice() { if (_deviceList.SelectedItem is DeviceRowItem d) return d; Say("목록에서 기기를 고르세요.", false); return null; } private async Task RenameDeviceAsync() { var d = SelectedDevice(); if (d == null) return; var name = TextPrompt.Ask(_owner, "기기 이름 바꾸기", $"{d.Name} ({d.Hostname}) 의 새 이름", d.Name); if (string.IsNullOrWhiteSpace(name)) return; await Run(async () => { await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["name"] = name.Trim() }); Say("이름을 바꿨습니다.", true); await LoadDevicesAsync(); }); } private async Task EditDeviceNoteAsync() { var d = SelectedDevice(); if (d == null) return; var note = TextPrompt.Ask(_owner, "메모", $"{d.Name} 메모", d.Note); if (note == null) return; await Run(async () => { await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["note"] = note }); await LoadDevicesAsync(); }); } private async Task SetDeviceEnabledAsync(bool enable) { var d = SelectedDevice(); if (d == null) return; if (!enable && !Dialogs.Confirm(_owner, $"{d.Name} 을(를) 사용 안 함으로 할까요?\n접속이 바로 끊기고, 다시 '사용'할 때까지 연결할 수 없습니다.", "사용 안 함")) return; await Run(async () => { await _relay.RequestAsync(enable ? "admin.devices.enable" : "admin.devices.disable", new JsonObject { ["deviceId"] = d.Id }); Say(enable ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true); await LoadDevicesAsync(); }); } private async Task DeleteDeviceAsync() { var d = SelectedDevice(); if (d == null) return; if (!Dialogs.Confirm(_owner, $"{d.Name} 을(를) 서버에서 삭제할까요?\n\n그 PC는 새 등록 코드로 다시 등록해야 합니다.\n감사 로그·연결 기록은 그대로 남습니다.", "기기 삭제")) return; await Run(async () => { await _relay.RequestAsync("admin.devices.delete", new JsonObject { ["deviceId"] = d.Id }); Say("삭제했습니다.", true); await LoadDevicesAsync(); }); } // ───────────────────────── 등록 코드 ───────────────────────── private async Task CreateCodeAsync() { int hours = _codeHours.SelectedIndex switch { 0 => 1, 1 => 24, 3 => 24 * 30, _ => 24 * 7 }; await Run(async () => { var r = await _relay.RequestAsync("admin.codes.create", new JsonObject { ["validHours"] = hours, ["note"] = _codeNote.Text.Trim() }); _codeShown.Text = Json.Str(r, "code"); _codeShown.Visibility = Visibility.Visible; _codeNote.Text = ""; Say($"등록 코드 {Json.Str(r, "code")} (만료 {Local(Json.Str(r, "expiresAt"))}). 대상 PC의 [원격 접속 허용 → 중계 서버 접속 → 등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다.", true); await LoadCodesAsync(); }); } private async Task RevokeCodeAsync() { if (_codeList.SelectedItem is not CodeRowItem c) { Say("목록에서 코드를 고르세요.", false); return; } if (c.Status != "valid") { Say("사용 가능한 코드만 취소할 수 있습니다.", false); return; } await Run(async () => { await _relay.RequestAsync("admin.codes.revoke", new JsonObject { ["codeId"] = c.Id }); Say("코드를 취소했습니다.", true); await LoadCodesAsync(); }); } // ───────────────────────── 감사 로그 / 연결 기록 ───────────────────────── private static List AuditActions() => new List { new Pick("(전체)", null), new Pick("서비스 시작", "start"), new Pick("서비스 중지", "stop"), new Pick("원격 화면 시작", "screenStart"), new Pick("원격 화면 끝", "screenEnd"), new Pick("연결 요청", "connect"), new Pick("대상 PC 인증 실패", "authFail"), new Pick("로그인", "login"), new Pick("로그인 실패", "loginFail"), new Pick("로그아웃", "logout"), new Pick("비밀번호 변경", "passwordChange"), new Pick("비밀번호 초기화", "passwordReset"), new Pick("계정 만들기", "userCreate"), new Pick("계정 변경", "userUpdate"), new Pick("권한 변경", "permChange"), new Pick("등록 코드 발급", "codeCreate"), new Pick("등록 코드 취소", "codeRevoke"), new Pick("기기 등록", "deviceEnroll"), new Pick("기기 변경", "deviceUpdate"), new Pick("기기 사용 안 함", "deviceDisable"), new Pick("기기 사용", "deviceEnable"), new Pick("기기 삭제", "deviceDelete") }; private static JsonObject Filter(DatePicker from, DatePicker to, ComboBox user, ComboBox device) { var o = new JsonObject { ["limit"] = 200 }; if (from.SelectedDate is DateTime f) o["from"] = f.Date.ToUniversalTime().ToString("o"); if (to.SelectedDate is DateTime t) o["to"] = t.Date.AddDays(1).ToUniversalTime().ToString("o"); if ((user.SelectedItem as Pick)?.Value is string u && long.TryParse(u, out var uid)) o["userId"] = uid; if ((device.SelectedItem as Pick)?.Value is string d) o["deviceId"] = d; return o; } private async Task QueryAuditAsync(bool more) { var q = Filter(_aFrom, _aTo, _aUser, _aDevice); if ((_aAction.SelectedItem as Pick)?.Value is string a) q["action"] = a; if (more) { if (_auditLastId == null) return; q["beforeId"] = _auditLastId; } await Run(async () => { var r = await _relay.RequestAsync("admin.audit.query", q); if (!more) _audit.Clear(); var rows = (r["rows"] as JsonArray).OfType().Select(AuditRowItem.From).ToList(); foreach (var x in rows) _audit.Add(x); _auditLastId = rows.Count > 0 ? rows[^1].Id : _auditLastId; if (more || rows.Count == 0) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true); }); } private async Task QueryConnsAsync(bool more) { var q = Filter(_cFrom, _cTo, _cUser, _cDevice); if (more) { if (_connLastId == null) return; q["beforeId"] = _connLastId; } await Run(async () => { var r = await _relay.RequestAsync("admin.connections.query", q); if (!more) _conns.Clear(); var rows = (r["rows"] as JsonArray).OfType().Select(ConnRowItem.From).ToList(); foreach (var x in rows) _conns.Add(x); _connLastId = rows.Count > 0 ? rows[^1].Id : _connLastId; if (more) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true); }); } private void Copy(IEnumerable lines) { var text = string.Join(Environment.NewLine, lines); if (text.Length == 0) { Say("복사할 내용이 없습니다.", false); return; } try { Clipboard.SetText(text); Say("복사했습니다.", true); } catch (Exception ex) { Say("복사 실패: " + ex.Message, false); } } // ───────────────────────── 공통 ───────────────────────── private async Task Run(Func work) { try { await work(); } catch (Exception ex) { Say(RelayUrl.Friendly(ex), false); } } private void Say(string msg, bool ok) { _status.Text = msg; _status.Foreground = ok ? Brushes2.Running : Brushes2.Stopped; } public static string Local(string iso) { if (string.IsNullOrEmpty(iso)) return ""; return DateTime.TryParse(iso, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t) ? t.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") : iso; } public static string Bytes(long b) => b < 1024 ? $"{b}B" : b < 1024 * 1024 ? $"{b / 1024.0:0.0}KB" : b < 1024L * 1024 * 1024 ? $"{b / 1048576.0:0.0}MB" : $"{b / 1073741824.0:0.00}GB"; private static TextBlock Label(string text) => new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 4, 0) }; private static TextBlock Note(string text) => new TextBlock { Text = text, Foreground = Brushes2.Make("#78909C"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(8, 0, 0, 0) }; private static Button Btn(string text, Func click, bool bold = false) { var b = new Button { Content = text, FontWeight = bold ? FontWeights.Bold : FontWeights.Normal, Margin = new Thickness(4, 2, 0, 2), Padding = new Thickness(10, 2, 10, 2) }; b.Click += async (s, e) => { b.IsEnabled = false; try { await click(); } finally { b.IsEnabled = true; } }; return b; } private static ListView Table(ObservableCollection items, params (string header, string path, double width)[] cols) { var gv = new GridView(); foreach (var (h, p, w) in cols) gv.Columns.Add(new GridViewColumn { Header = h, Width = w, DisplayMemberBinding = new Binding(p) }); return new ListView { ItemsSource = items, View = gv, SelectionMode = SelectionMode.Single }; } private static GridViewColumn CheckColumn(string header, string path) { var f = new FrameworkElementFactory(typeof(CheckBox)); f.SetBinding(ToggleButton.IsCheckedProperty, new Binding(path) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }); f.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center); return new GridViewColumn { Header = header, Width = 90, CellTemplate = new DataTemplate { VisualTree = f } }; } private sealed class Pick { public string Text { get; } public string Value { get; } public Pick(string text, string value) { Text = text; Value = value; } } // ───────────────────────── 표 한 줄 ───────────────────────── private sealed class UserRowItem { public long Id { get; init; } public string Username { get; init; } public bool IsAdmin { get; init; } public bool Enabled { get; init; } public string RoleText => IsAdmin ? "관리자" : "일반"; public string StateText { get; init; } public string LastLogin { get; init; } public string Created { get; init; } public string Note { get; init; } public static UserRowItem From(JsonObject o) { bool enabled = Json.Bool(o, "enabled"); var state = !enabled ? "사용 안 함" : Json.Bool(o, "mustChangePassword") ? "비밀번호 변경 대기" : "사용"; if (Json.Bool(o, "online")) state += " · 접속 중"; return new UserRowItem { Id = o["id"]?.GetValue() ?? 0, Username = Json.Str(o, "username"), IsAdmin = Json.Str(o, "role") == "admin", Enabled = enabled, StateText = state, LastLogin = Local(Json.Str(o, "lastLoginAt")), Created = Local(Json.Str(o, "createdAt")), Note = Json.Str(o, "note") }; } } private sealed class PermRowItem : ObservableObject { private bool _view, _control, _sv, _sc; public string DeviceId { get; init; } public string Name { get; init; } public string Hostname { get; init; } public bool View { get => _view; set => Set(ref _view, value); } public bool Control { get => _control; set => Set(ref _control, value); } public bool ScreenView { get => _sv; set => Set(ref _sv, value); } public bool ScreenControl { get => _sc; set => Set(ref _sc, value); } public void Set(bool v, bool c, bool sv, bool sc) { View = v; Control = c; ScreenView = sv; ScreenControl = sc; } public string Summary { get { var p = new List(); if (View) p.Add("보기"); if (Control) p.Add("서비스 제어"); if (ScreenView) p.Add("화면 보기"); if (ScreenControl) p.Add("화면 제어"); return p.Count == 0 ? "권한 없음 (목록에 안 보임)" : string.Join(", ", p); } } public static PermRowItem From(JsonObject o) => new PermRowItem { DeviceId = Json.Str(o, "deviceId"), Name = Json.Str(o, "name") + (Json.Bool(o, "enabled") ? "" : " (사용 안 함)"), Hostname = Json.Str(o, "hostname"), _view = Json.Bool(o, "view"), _control = Json.Bool(o, "control"), _sv = Json.Bool(o, "screenView"), _sc = Json.Bool(o, "screenControl") }; } private sealed class DeviceRowItem { public string Id { get; init; } public string Name { get; init; } public string Hostname { get; init; } public string StateText { get; init; } public string PublicIp { get; init; } public string LanIps { get; init; } public string ActiveText { get; init; } public string Version { get; init; } public string LastSeen { get; init; } public string Enrolled { get; init; } public string Note { get; init; } public static DeviceRowItem From(JsonObject o) { var al = o["activeListen"] as JsonObject; var lan = (o["lanIps"] as JsonArray)?.Select(x => x?.ToString()).Where(x => !string.IsNullOrEmpty(x)) ?? Enumerable.Empty(); return new DeviceRowItem { Id = Json.Str(o, "id"), Name = Json.Str(o, "name"), Hostname = Json.Str(o, "hostname"), StateText = !Json.Bool(o, "enabled") ? "사용 안 함" : Json.Bool(o, "online") ? "● 온라인" : "오프라인", PublicIp = Json.Str(o, "publicIp"), LanIps = string.Join(", ", lan), ActiveText = al == null ? "꺼짐" : $"{(Json.Str(al, "mode") == "Ssh" ? "SSH" : "TLS")} {Json.Int(al, "port")}", Version = Json.Str(o, "appVersion"), LastSeen = Local(Json.Str(o, "lastSeenAt")), Enrolled = Local(Json.Str(o, "enrolledAt")), Note = Json.Str(o, "note") }; } } private sealed class CodeRowItem { public long Id { get; init; } public string Status { get; init; } public string StateText => Status switch { "valid" => "사용 가능", "used" => "사용됨", "revoked" => "취소됨", _ => "만료됨" }; public string CreatedBy { get; init; } public string Created { get; init; } public string Expires { get; init; } public string Used { get; init; } public string UsedDevice { get; init; } public string Note { get; init; } public static CodeRowItem From(JsonObject o) => new CodeRowItem { Id = o["id"]?.GetValue() ?? 0, Status = Json.Str(o, "status"), CreatedBy = Json.Str(o, "createdBy"), Created = Local(Json.Str(o, "createdAt")), Expires = Local(Json.Str(o, "expiresAt")), Used = Local(Json.Str(o, "usedAt")), UsedDevice = Json.Str(o, "usedDevice"), Note = Json.Str(o, "note") }; } private sealed class AuditRowItem { public long Id { get; init; } public string At { get; init; } public string User { get; init; } public string Device { get; init; } public string Action { get; init; } public string ActionText => AuditActions().FirstOrDefault(a => a.Value == Action)?.Text ?? Action; public string Target { get; init; } public string Result { get; init; } public string ResultText => Result switch { "ok" => "성공", "fail" => "실패", "denied" => "거부", _ => Result }; public string Detail { get; init; } public string Ip { get; init; } public string ToLine() => $"{At}\t{User}\t{Device}\t{ActionText}\t{Target}\t{ResultText}\t{Detail}\t{Ip}"; public static AuditRowItem From(JsonObject o) => new AuditRowItem { Id = o["id"]?.GetValue() ?? 0, At = Local(Json.Str(o, "at")), User = Json.Str(o, "username"), Device = Json.Str(o, "deviceName"), Action = Json.Str(o, "action"), Target = Json.Str(o, "target"), Result = Json.Str(o, "result"), Detail = Json.Str(o, "detail"), Ip = Json.Str(o, "ip") }; } private sealed class ConnRowItem { public long Id { get; init; } public string Started { get; init; } public string Ended { get; init; } public string User { get; init; } public string Device { get; init; } public string Mode { get; init; } public string ModeText => Mode switch { "direct" => "직접", "relay" => "중계", "failed" => "실패", "" => "진행 중", _ => Mode }; public string ToDevice { get; init; } public string FromDevice { get; init; } public string Reason { get; init; } public string Ip { get; init; } public string ToLine() => $"{Started}\t{Ended}\t{User}\t{Device}\t{ModeText}\t{ToDevice}\t{FromDevice}\t{Reason}\t{Ip}"; public static ConnRowItem From(JsonObject o) => new ConnRowItem { Id = o["id"]?.GetValue() ?? 0, Started = Local(Json.Str(o, "startedAt")), Ended = Local(Json.Str(o, "endedAt")), User = Json.Str(o, "username"), Device = Json.Str(o, "deviceName"), Mode = Json.Str(o, "mode"), ToDevice = Bytes(o["bytesToDevice"]?.GetValue() ?? 0), FromDevice = Bytes(o["bytesFromDevice"]?.GetValue() ?? 0), Reason = Json.Str(o, "endReason"), Ip = Json.Str(o, "managerIp") }; } } }