891 lines
46 KiB
C#
891 lines
46 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 설정 → [서버 관리] 탭 (중계 서버 관리자 계정으로 로그인했을 때만).
|
|
/// 계정 / 권한 / 기기 / 등록 코드 / 감사 로그 / 연결 기록
|
|
/// </summary>
|
|
public sealed class ServerAdminPanel : DockPanel
|
|
{
|
|
private readonly RelayManager _relay;
|
|
private readonly Window _owner;
|
|
private readonly TextBlock _info, _status;
|
|
|
|
// 계정
|
|
private readonly ObservableCollection<UserRowItem> _users = new ObservableCollection<UserRowItem>();
|
|
private readonly ListView _userList;
|
|
private readonly TextBox _newUser, _newNote;
|
|
private readonly PasswordBox _newPw;
|
|
private readonly ComboBox _newRole;
|
|
|
|
// 권한
|
|
private readonly ComboBox _permUser;
|
|
private readonly ObservableCollection<PermRowItem> _perms = new ObservableCollection<PermRowItem>();
|
|
private readonly TextBlock _permNote;
|
|
private bool _permLoading;
|
|
|
|
// 기기
|
|
private readonly ObservableCollection<DeviceRowItem> _devices = new ObservableCollection<DeviceRowItem>();
|
|
private readonly ListView _deviceList;
|
|
|
|
// 등록 코드
|
|
private readonly ObservableCollection<CodeRowItem> _codes = new ObservableCollection<CodeRowItem>();
|
|
private readonly ListView _codeList;
|
|
private readonly ComboBox _codeHours;
|
|
private readonly TextBox _codeNote, _codeShown;
|
|
|
|
// 감사 로그 / 연결 기록
|
|
private readonly ObservableCollection<AuditRowItem> _audit = new ObservableCollection<AuditRowItem>();
|
|
private readonly ObservableCollection<ConnRowItem> _conns = new ObservableCollection<ConnRowItem>();
|
|
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<JsonObject>()) _users.Add(UserRowItem.From(o));
|
|
_permUser.ItemsSource = _users.Where(u => !u.IsAdmin).ToList();
|
|
var sel = ((IEnumerable<UserRowItem>)_permUser.ItemsSource).FirstOrDefault(u => u.Id == keep)
|
|
?? ((IEnumerable<UserRowItem>)_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<JsonObject>()) _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<JsonObject>()) _codes.Add(CodeRowItem.From(o));
|
|
});
|
|
}
|
|
|
|
private void FillFilters()
|
|
{
|
|
var users = new List<Pick> { new Pick("(전체)", null) };
|
|
users.AddRange(_users.Select(u => new Pick(u.Username, u.Id.ToString())));
|
|
var devices = new List<Pick> { 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<JsonObject>()) _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<Pick> AuditActions() => new List<Pick>
|
|
{
|
|
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<JsonObject>().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<JsonObject>().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<string> 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<Task> 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<Task> 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<T>(ObservableCollection<T> 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<long>() ?? 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<string>();
|
|
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<string>();
|
|
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<long>() ?? 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<long>() ?? 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<long>() ?? 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<long>() ?? 0),
|
|
FromDevice = Bytes(o["bytesFromDevice"]?.GetValue<long>() ?? 0),
|
|
Reason = Json.Str(o, "endReason"),
|
|
Ip = Json.Str(o, "managerIp")
|
|
};
|
|
}
|
|
}
|
|
}
|