Replace DB-to-device page with device-to-device UI

Replace the old DB → Device page with the new Device → Device page.

- Remove the old DbToDevice view and code-behind
- Add the new DeviceToDevice view and code-behind
- Add source/target device selection
- Add connection checks, user loading, selection, progress, and sync action UI
main
SYED MUSTUFA AHMED NAQVI 2026-08-28 12:46:03 +05:00
parent 78b8a9bc86
commit b73476c5b3
3 changed files with 492 additions and 198 deletions

View File

@ -1,136 +0,0 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using HikvisionAttendanceManager.App.Models;
using HikvisionAttendanceManager.App.Services;
namespace HikvisionAttendanceManager.App;
public partial class DbToDeviceView : UserControl
{
private readonly DeviceService _devices = new();
private readonly AttendanceMachineUserRepository _machineUsers = new();
private readonly AttendanceMachineFaceTemplateRepository _faceTemplates = new();
private readonly TemplateSyncService _service;
private readonly ObservableCollection<SelectableMachineUser> _rows = [];
private bool _initialized;
public DbToDeviceView()
{
InitializeComponent();
_service = new TemplateSyncService(new HikvisionIsapiClient(), _machineUsers, _faceTemplates, new OperationHistoryService());
UsersGrid.ItemsSource = _rows;
UpdateUsersEmptyState();
}
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
await RefreshAsync();
}
public async Task RefreshAsync()
{
var originalContent = RefreshButton.Content;
try
{
RefreshButton.IsEnabled = false;
RefreshButton.Content = "Refreshing...";
SourceCombo.SelectedItem = null;
TargetCombo.SelectedItem = null;
_rows.Clear();
UpdateUsersEmptyState();
ProgressCard.Visibility = Visibility.Collapsed;
ProgressBar.Value = 0;
ProgressText.Text = "";
CounterText.Text = "";
UsersGrid.Items.Refresh();
var devices = await _devices.LoadUnifiedAsync();
SourceCombo.ItemsSource = devices;
TargetCombo.ItemsSource = devices;
}
finally
{
RefreshButton.IsEnabled = true;
RefreshButton.Content = originalContent;
}
}
private async void Refresh_Click(object sender, RoutedEventArgs e) => await RefreshAsync();
private void SourceChanged(object sender, SelectionChangedEventArgs e)
{
_rows.Clear();
UpdateUsersEmptyState();
}
private async void LoadUsers_Click(object sender, RoutedEventArgs e)
{
if (SourceCombo.SelectedItem is not Device source) { MessageBox.Show("Select a source machine."); return; }
_rows.Clear();
var users = await _machineUsers.GetActiveByMachineAsync(source.MachineId, CancellationToken.None);
foreach (var user in users)
{
var template = await _faceTemplates.TryGetActiveAsync(user.SerialNumber, CancellationToken.None);
var hasFace = template?.Template is { Length: > 0 } && HikvisionIsapiClient.IsUploadableFaceTemplate(template.Template);
_rows.Add(new SelectableMachineUser(user.SerialNumber, user.EmployeeName, hasFace));
}
UpdateUsersEmptyState();
}
private void SelectAll_Click(object sender, RoutedEventArgs e)
{
foreach (var row in _rows) row.IsSelected = true;
UsersGrid.Items.Refresh();
}
private void ClearSelection_Click(object sender, RoutedEventArgs e)
{
foreach (var row in _rows) row.IsSelected = false;
UsersGrid.Items.Refresh();
}
private void UpdateUsersEmptyState()
{
UsersEmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private async void Transfer_Click(object sender, RoutedEventArgs e)
{
if (SourceCombo.SelectedItem is not Device source || TargetCombo.SelectedItem is not Device target)
{
MessageBox.Show("Select source and target machines.");
return;
}
if (string.Equals(source.MachineId, target.MachineId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(source.IpAddress, target.IpAddress, StringComparison.OrdinalIgnoreCase))
{
MessageBox.Show("Source and target must be different devices.");
return;
}
var selected = _rows.Where(r => r.IsSelected).Select(r => r.SerialNumber).ToList();
if (selected.Count == 0) { MessageBox.Show("Select at least one user."); return; }
ProgressCard.Visibility = Visibility.Visible;
var progress = new Progress<UserSyncProgress>(p =>
{
ProgressBar.Maximum = Math.Max(1, p.Total);
ProgressBar.Value = p.Completed;
ProgressText.Text = $"{p.Completed}/{p.Total} · {p.CurrentEmployee} · {p.CurrentOperation}";
CounterText.Text = $"Users: {p.Created} · Faces: {p.FacesUploaded} · Skipped: {p.Skipped} · Failed: {p.Failed}";
});
try
{
var entry = await _service.DbToDeviceAsync(source, target, selected, progress, CancellationToken.None);
ProgressText.Text = $"Completed — {entry.Status}";
CounterText.Text = entry.Context;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "DB → Device", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}

View File

@ -1,4 +1,4 @@
<UserControl x:Class="HikvisionAttendanceManager.App.DbToDeviceView" <UserControl x:Class="HikvisionAttendanceManager.App.DeviceToDeviceView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources> <UserControl.Resources>
@ -171,8 +171,8 @@
<StackPanel Margin="0,2,0,0"> <StackPanel Margin="0,2,0,0">
<DockPanel LastChildFill="False"> <DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Left"> <StackPanel DockPanel.Dock="Left">
<TextBlock Text="DB -> Device" Style="{StaticResource PageTitle}"/> <TextBlock Text="Device → Device" Style="{StaticResource PageTitle}"/>
<TextBlock Text="Transfer users registered to a source machine in DB onto a different target Hikvision device (JPEG face templates only)." Style="{StaticResource PageSubtitle}"/> <TextBlock Text="Transfer users from one Hikvision device to another." Style="{StaticResource PageSubtitle}"/>
</StackPanel> </StackPanel>
<Button x:Name="RefreshButton" Content="↻ Refresh" Style="{StaticResource HeaderRefreshButton}" DockPanel.Dock="Right" VerticalAlignment="Top" Click="Refresh_Click"/> <Button x:Name="RefreshButton" Content="↻ Refresh" Style="{StaticResource HeaderRefreshButton}" DockPanel.Dock="Right" VerticalAlignment="Top" Click="Refresh_Click"/>
</DockPanel> </DockPanel>
@ -187,20 +187,43 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Text="Source Machine (DB)" Style="{StaticResource FieldLabel}"/> <TextBlock Text="Source Machine" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="SourceCombo" Style="{StaticResource ModernComboBox}" SelectionChanged="SourceChanged"/> <ComboBox x:Name="SourceCombo" Style="{StaticResource ModernComboBox}" SelectionChanged="SourceChanged"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Ellipse x:Name="SourceStatusDot" Width="8" Height="8" Fill="#9CA3AF" VerticalAlignment="Center"/>
<TextBlock x:Name="SourceStatusText" Margin="8,0,0,0" FontSize="12" Foreground="#64748B" Text="Select a device"/>
</StackPanel>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2"> <StackPanel Grid.Column="2">
<TextBlock Text="Target Machine" Style="{StaticResource FieldLabel}"/> <TextBlock Text="Target Machine" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="TargetCombo" Style="{StaticResource ModernComboBox}"/> <ComboBox x:Name="TargetCombo" Style="{StaticResource ModernComboBox}" SelectionChanged="TargetChanged"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Ellipse x:Name="TargetStatusDot" Width="8" Height="8" Fill="#9CA3AF" VerticalAlignment="Center"/>
<TextBlock x:Name="TargetStatusText" Margin="8,0,0,0" FontSize="12" Foreground="#64748B" Text="Select a device"/>
</StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Orientation="Horizontal" Margin="0,24,0,0"> <Border x:Name="SourceInfoBanner" Visibility="Collapsed" Margin="0,18,0,0" Padding="14,10" CornerRadius="6" BorderThickness="1">
<Button Style="{StaticResource OutlineButton}" Click="LoadUsers_Click"> <TextBlock x:Name="SourceInfoText" TextWrapping="Wrap" FontSize="12"/>
</Border>
<TextBlock x:Name="SameDeviceWarning" Visibility="Collapsed" Margin="0,14,0,0" FontSize="12" Foreground="#DC2626"
Text="Source and target devices must be different."/>
<TextBlock x:Name="TargetOfflineWarning" Visibility="Collapsed" Margin="0,10,0,0" FontSize="12" Foreground="#DC2626"
Text="Target device is not connected. Connect the target device before transferring users."/>
</StackPanel>
</Border>
<Border Style="{StaticResource ModernCard}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="USERS" Style="{StaticResource SectionLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,18,0,0">
<Button x:Name="LoadUsersButton" Style="{StaticResource OutlineButton}" Click="LoadUsers_Click">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Stroke="#2563EB" StrokeThickness="1.7" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" Fill="Transparent" Margin="0,0,8,0" Data="M 3 4 C 3 2.9 11 2.9 11 4 V 12 C 11 13.1 3 13.1 3 12 Z M 3 4 C 3 5.1 11 5.1 11 4 M 3 8 C 3 9.1 11 9.1 11 8"/> <Path Width="15" Height="15" Stretch="Uniform" Stroke="#2563EB" StrokeThickness="1.7" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" Fill="Transparent" Margin="0,0,8,0" Data="M 3 4 C 3 2.9 11 2.9 11 4 V 12 C 11 13.1 3 13.1 3 12 Z M 3 4 C 3 5.1 11 5.1 11 4 M 3 8 C 3 9.1 11 9.1 11 8"/>
<TextBlock Text="Load Users from Source DB"/> <TextBlock Text="Load Users"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource NeutralButton}" Margin="16,0,0,0" Click="SelectAll_Click"> <Button Style="{StaticResource NeutralButton}" Margin="16,0,0,0" Click="SelectAll_Click">
@ -216,63 +239,65 @@
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> </StackPanel>
<TextBlock x:Name="LoadStatusText" Margin="0,14,0,0" FontSize="12" Foreground="#475569" TextWrapping="Wrap"/>
<TextBlock x:Name="SelectionCountText" Margin="0,8,0,14" FontSize="12" Foreground="#1F2A44" FontWeight="SemiBold" Text="0 user(s) loaded • 0 selected"/>
<Grid>
<DataGrid x:Name="UsersGrid"
AutoGenerateColumns="False"
CanUserAddRows="False"
IsReadOnly="False"
HeadersVisibility="Column"
RowHeight="42"
MinHeight="220"
MaxHeight="280"
BorderBrush="#DDE5F0"
BorderThickness="1"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#EEF2F7"
Background="White"
RowBackground="White"
AlternatingRowBackground="White"
ColumnHeaderStyle="{StaticResource UsersGridHeaderStyle}"
CellStyle="{StaticResource UsersGridCellStyle}">
<DataGrid.Columns>
<DataGridTemplateColumn Width="90" MinWidth="80" IsReadOnly="False">
<DataGridTemplateColumn.Header>
<StackPanel Orientation="Horizontal">
<CheckBox Click="SelectAll_Click" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Select"/>
</StackPanel>
</DataGridTemplateColumn.Header>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid HorizontalAlignment="Stretch">
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Employee / Serial" Binding="{Binding SerialNumber}" Width="140" MinWidth="110" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
<DataGridTextColumn Header="Name" Binding="{Binding EmployeeName}" Width="*" MinWidth="200" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
<DataGridTextColumn Header="Source" Binding="{Binding SourceDisplay}" Width="90" MinWidth="70" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
<DataGridTextColumn Header="Face" Binding="{Binding FaceDisplay}" Width="70" MinWidth="60" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel x:Name="UsersEmptyState" HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False">
<Border Width="68" Height="68" CornerRadius="7" Background="#EEF4FB" HorizontalAlignment="Center">
<Grid>
<Path Width="35" Height="35" Stretch="Uniform" Stroke="#A7BCE0" StrokeThickness="1.9" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Fill="Transparent" HorizontalAlignment="Center" VerticalAlignment="Center" Data="M 11 13 A 5 5 0 1 1 11 3 A 5 5 0 0 1 11 13 M 3 24 C 4.2 17 17.8 17 19 24 M 1 29 H 20 M 1 34 H 13 M 17 33 H 19"/>
</Grid>
</Border>
<TextBlock Text="No users loaded." Margin="0,16,0,0" Foreground="#10203D" FontSize="13" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Select a source machine, then click &quot;Load Users&quot;." Margin="0,8,0,0" Foreground="#718096" FontSize="12" HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<Border Style="{StaticResource ModernCard}" Padding="16" Margin="0,0,0,16"> <Button x:Name="TransferButton" Style="{StaticResource TransferButton}" IsEnabled="False" Click="Transfer_Click">
<Grid>
<DataGrid x:Name="UsersGrid"
AutoGenerateColumns="False"
CanUserAddRows="False"
IsReadOnly="False"
HeadersVisibility="Column"
RowHeight="42"
MinHeight="220"
MaxHeight="280"
BorderBrush="#DDE5F0"
BorderThickness="1"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#EEF2F7"
Background="White"
RowBackground="White"
AlternatingRowBackground="White"
ColumnHeaderStyle="{StaticResource UsersGridHeaderStyle}"
CellStyle="{StaticResource UsersGridCellStyle}">
<DataGrid.Columns>
<DataGridTemplateColumn Width="110" MinWidth="95" IsReadOnly="False">
<DataGridTemplateColumn.Header>
<StackPanel Orientation="Horizontal">
<CheckBox Click="SelectAll_Click" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Select"/>
</StackPanel>
</DataGridTemplateColumn.Header>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid HorizontalAlignment="Stretch">
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Serial" Binding="{Binding SerialNumber}" Width="140" MinWidth="110" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
<DataGridTextColumn Header="Name" Binding="{Binding EmployeeName}" Width="*" MinWidth="260" ElementStyle="{StaticResource UsersTextCell}" IsReadOnly="True"/>
<DataGridCheckBoxColumn Header="Face in DB" Binding="{Binding HasFaceTemplate, Mode=OneWay}" Width="120" MinWidth="100" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel x:Name="UsersEmptyState" HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False">
<Border Width="68" Height="68" CornerRadius="7" Background="#EEF4FB" HorizontalAlignment="Center">
<Grid>
<Path Width="35" Height="35" Stretch="Uniform" Stroke="#A7BCE0" StrokeThickness="1.9" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Fill="Transparent" HorizontalAlignment="Center" VerticalAlignment="Center" Data="M 11 13 A 5 5 0 1 1 11 3 A 5 5 0 0 1 11 13 M 3 24 C 4.2 17 17.8 17 19 24 M 1 29 H 20 M 1 34 H 13 M 17 33 H 19"/>
</Grid>
</Border>
<TextBlock Text="No users loaded." Margin="0,16,0,0" Foreground="#10203D" FontSize="13" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Click &quot;Load Users from Source DB&quot; to fetch users." Margin="0,8,0,0" Foreground="#718096" FontSize="12" HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<Button Style="{StaticResource TransferButton}" Click="Transfer_Click">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Stroke="White" StrokeThickness="1.7" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" Fill="Transparent" Margin="0,0,8,0" Data="M 8 13 V 4 M 4 8 L 8 4 L 12 8 M 3 13 V 16 H 13 V 13"/> <Path Width="15" Height="15" Stretch="Uniform" Stroke="White" StrokeThickness="1.7" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" Fill="Transparent" Margin="0,0,8,0" Data="M 8 13 V 4 M 4 8 L 8 4 L 12 8 M 3 13 V 16 H 13 V 13"/>
<TextBlock Text="Transfer Selected to Target"/> <TextBlock Text="Transfer Selected to Target"/>

View File

@ -0,0 +1,405 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using HikvisionAttendanceManager.App.Models;
using HikvisionAttendanceManager.App.Services;
namespace HikvisionAttendanceManager.App;
public partial class DeviceToDeviceView : UserControl
{
private readonly DeviceService _devices = new();
private readonly DeviceConnectionService _connections = new();
private readonly AttendanceMachineUserRepository _machineUsers = new();
private readonly AttendanceMachineFaceTemplateRepository _faceTemplates = new();
private readonly HikvisionIsapiClient _hikvision = new();
private readonly TemplateSyncService _service;
private readonly ObservableCollection<SelectableMachineUser> _rows = [];
private CancellationTokenSource? _sourceStatusCts;
private CancellationTokenSource? _targetStatusCts;
private bool? _sourceConnected;
private bool? _targetConnected;
private bool _initialized;
public DeviceToDeviceView()
{
InitializeComponent();
_service = new TemplateSyncService(_hikvision, _machineUsers, _faceTemplates, new OperationHistoryService());
UsersGrid.ItemsSource = _rows;
UpdateUsersEmptyState();
UpdateSelectionCount();
UpdateActionState();
}
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
await RefreshAsync();
}
public async Task RefreshAsync()
{
var originalContent = RefreshButton.Content;
try
{
RefreshButton.IsEnabled = false;
RefreshButton.Content = "Refreshing...";
_sourceStatusCts?.Cancel();
_targetStatusCts?.Cancel();
SourceCombo.SelectedItem = null;
TargetCombo.SelectedItem = null;
ClearUserList();
ProgressCard.Visibility = Visibility.Collapsed;
ProgressBar.Value = 0;
ProgressText.Text = "";
CounterText.Text = "";
LoadStatusText.Text = "";
SourceInfoBanner.Visibility = Visibility.Collapsed;
SameDeviceWarning.Visibility = Visibility.Collapsed;
TargetOfflineWarning.Visibility = Visibility.Collapsed;
_sourceConnected = null;
_targetConnected = null;
SetConnectionStatus(SourceStatusDot, SourceStatusText, null);
SetConnectionStatus(TargetStatusDot, TargetStatusText, null);
var devices = await _devices.LoadUnifiedAsync();
SourceCombo.ItemsSource = devices;
TargetCombo.ItemsSource = devices;
}
finally
{
RefreshButton.IsEnabled = true;
RefreshButton.Content = originalContent;
}
}
private async void Refresh_Click(object sender, RoutedEventArgs e) => await RefreshAsync();
private async void SourceChanged(object sender, SelectionChangedEventArgs e)
{
ClearUserList();
LoadStatusText.Text = "";
SourceInfoBanner.Visibility = Visibility.Collapsed;
await CheckSourceConnectionAsync();
UpdateSourceInfoBanner();
UpdateActionState();
}
private async void TargetChanged(object sender, SelectionChangedEventArgs e)
{
await CheckTargetConnectionAsync();
UpdateActionState();
}
private async Task CheckSourceConnectionAsync()
{
_sourceStatusCts?.Cancel();
_sourceStatusCts = new CancellationTokenSource();
var token = _sourceStatusCts.Token;
if (SourceCombo.SelectedItem is not Device device)
{
_sourceConnected = null;
SetConnectionStatus(SourceStatusDot, SourceStatusText, null);
return;
}
SetConnectionStatus(SourceStatusDot, SourceStatusText, null, "Checking connection...");
try
{
var result = await _connections.TestConnectivityAsync(device, token).ConfigureAwait(true);
if (token.IsCancellationRequested) return;
_sourceConnected = result.IsConnected;
SetConnectionStatus(SourceStatusDot, SourceStatusText, result.IsConnected,
result.IsConnected ? "Connected" : "Not Connected");
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
if (token.IsCancellationRequested) return;
_sourceConnected = false;
SetConnectionStatus(SourceStatusDot, SourceStatusText, false, "Not Connected");
AppLogger.Warning($"Device→Device source connection check failed: {ex.Message}");
}
}
private async Task CheckTargetConnectionAsync()
{
_targetStatusCts?.Cancel();
_targetStatusCts = new CancellationTokenSource();
var token = _targetStatusCts.Token;
if (TargetCombo.SelectedItem is not Device device)
{
_targetConnected = null;
SetConnectionStatus(TargetStatusDot, TargetStatusText, null);
TargetOfflineWarning.Visibility = Visibility.Collapsed;
return;
}
SetConnectionStatus(TargetStatusDot, TargetStatusText, null, "Checking connection...");
try
{
var result = await _connections.TestConnectivityAsync(device, token).ConfigureAwait(true);
if (token.IsCancellationRequested) return;
_targetConnected = result.IsConnected;
SetConnectionStatus(TargetStatusDot, TargetStatusText, result.IsConnected,
result.IsConnected ? "Connected" : "Not Connected");
TargetOfflineWarning.Visibility = result.IsConnected ? Visibility.Collapsed : Visibility.Visible;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
if (token.IsCancellationRequested) return;
_targetConnected = false;
SetConnectionStatus(TargetStatusDot, TargetStatusText, false, "Not Connected");
TargetOfflineWarning.Visibility = Visibility.Visible;
AppLogger.Warning($"Device→Device target connection check failed: {ex.Message}");
}
}
private void UpdateSourceInfoBanner()
{
if (SourceCombo.SelectedItem is null || _sourceConnected is null)
{
SourceInfoBanner.Visibility = Visibility.Collapsed;
return;
}
SourceInfoBanner.Visibility = Visibility.Visible;
if (_sourceConnected == true)
{
SourceInfoBanner.Background = new SolidColorBrush(Color.FromRgb(236, 253, 245));
SourceInfoBanner.BorderBrush = new SolidColorBrush(Color.FromRgb(167, 243, 208));
SourceInfoText.Foreground = new SolidColorBrush(Color.FromRgb(4, 120, 87));
SourceInfoText.Text = "Source device connected. Users will be loaded directly from the device.";
}
else
{
SourceInfoBanner.Background = new SolidColorBrush(Color.FromRgb(255, 251, 235));
SourceInfoBanner.BorderBrush = new SolidColorBrush(Color.FromRgb(253, 230, 138));
SourceInfoText.Foreground = new SolidColorBrush(Color.FromRgb(180, 83, 9));
SourceInfoText.Text = "Source device is offline. Users will be loaded from the database for the selected source device.";
}
}
private async void LoadUsers_Click(object sender, RoutedEventArgs e)
{
if (SourceCombo.SelectedItem is not Device source)
{
MessageBox.Show("Select a source machine.", "Device → Device", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
if (_sourceConnected is null)
await CheckSourceConnectionAsync();
UpdateSourceInfoBanner();
_rows.Clear();
LoadStatusText.Text = "Loading users...";
LoadUsersButton.IsEnabled = false;
try
{
if (_sourceConnected == true)
{
var users = await _hikvision.GetUsersAsync(source, CancellationToken.None);
foreach (var user in users.OrderBy(u => u.EmployeeNo, StringComparer.OrdinalIgnoreCase))
AddRow(user.EmployeeNo, user.Name, user.FaceCount > 0, "Device");
LoadStatusText.Text = $"Loaded {users.Count} user(s) from source Hikvision device.";
}
else
{
var users = await _machineUsers.GetActiveByMachineAsync(source.MachineId, CancellationToken.None);
foreach (var user in users.OrderBy(u => u.SerialNumber, StringComparer.OrdinalIgnoreCase))
{
var template = await _faceTemplates.TryGetActiveAsync(user.SerialNumber, CancellationToken.None);
var hasFace = template?.Template is { Length: > 0 } &&
HikvisionIsapiClient.IsUploadableFaceTemplate(template.Template);
AddRow(user.SerialNumber, user.EmployeeName, hasFace, "DB");
}
LoadStatusText.Text = $"Source device offline — loaded {users.Count} user(s) from DB.";
}
}
catch (Exception ex)
{
LoadStatusText.Text = "Unable to load users: " + ex.Message;
MessageBox.Show(ex.Message, "Device → Device", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
LoadUsersButton.IsEnabled = true;
UpdateUsersEmptyState();
UpdateSelectionCount();
}
}
private void AddRow(string serialNumber, string employeeName, bool hasFace, string sourceDisplay)
{
var row = new SelectableMachineUser(serialNumber, employeeName, hasFace, sourceDisplay);
row.PropertyChanged += RowSelectionChanged;
_rows.Add(row);
}
private void RowSelectionChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SelectableMachineUser.IsSelected))
{
UpdateSelectionCount();
UpdateActionState();
}
}
private void ClearUserList()
{
foreach (var row in _rows)
row.PropertyChanged -= RowSelectionChanged;
_rows.Clear();
UpdateUsersEmptyState();
UpdateSelectionCount();
}
private void SelectAll_Click(object sender, RoutedEventArgs e)
{
foreach (var row in _rows) row.IsSelected = true;
UsersGrid.Items.Refresh();
UpdateSelectionCount();
UpdateActionState();
}
private void ClearSelection_Click(object sender, RoutedEventArgs e)
{
foreach (var row in _rows) row.IsSelected = false;
UsersGrid.Items.Refresh();
UpdateSelectionCount();
UpdateActionState();
}
private void UpdateUsersEmptyState()
{
UsersEmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void UpdateSelectionCount()
{
var selected = _rows.Count(r => r.IsSelected);
SelectionCountText.Text = $"{_rows.Count} user(s) loaded • {selected} selected";
}
private bool IsSameDeviceSelected()
{
if (SourceCombo.SelectedItem is not Device source || TargetCombo.SelectedItem is not Device target)
return false;
return string.Equals(source.MachineId, target.MachineId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(source.IpAddress, target.IpAddress, StringComparison.OrdinalIgnoreCase);
}
private void UpdateActionState()
{
var sameDevice = IsSameDeviceSelected();
SameDeviceWarning.Visibility = sameDevice ? Visibility.Visible : Visibility.Collapsed;
var canTransfer = !sameDevice &&
_targetConnected == true &&
_rows.Any(r => r.IsSelected) &&
SourceCombo.SelectedItem is Device &&
TargetCombo.SelectedItem is Device;
TransferButton.IsEnabled = canTransfer;
}
private async void Transfer_Click(object sender, RoutedEventArgs e)
{
if (SourceCombo.SelectedItem is not Device source || TargetCombo.SelectedItem is not Device target)
{
MessageBox.Show("Select source and target machines.", "Device → Device", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
if (IsSameDeviceSelected())
{
MessageBox.Show("Source and target devices must be different.", "Device → Device", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (_targetConnected is null)
await CheckTargetConnectionAsync();
if (_targetConnected != true)
{
MessageBox.Show("Target device is not connected. Connect the target device before transferring users.",
"Device → Device", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (_sourceConnected is null)
await CheckSourceConnectionAsync();
var selected = _rows.Where(r => r.IsSelected)
.Select(r => new DeviceTransferUser(r.SerialNumber, r.EmployeeName))
.ToList();
if (selected.Count == 0)
{
MessageBox.Show("Select at least one user.", "Device → Device", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
ProgressCard.Visibility = Visibility.Visible;
TransferButton.IsEnabled = false;
var progress = new Progress<UserSyncProgress>(p =>
{
ProgressBar.Maximum = Math.Max(1, p.Total);
ProgressBar.Value = p.Completed;
ProgressText.Text = $"{p.Completed}/{p.Total} · {p.CurrentEmployee} · {p.CurrentOperation}";
CounterText.Text = $"Users: {p.Created} · Faces: {p.FacesUploaded} · Skipped: {p.Skipped} · Failed: {p.Failed}";
});
try
{
var entry = await _service.DeviceToDeviceAsync(source, target, selected, _sourceConnected == true, progress, CancellationToken.None);
ProgressText.Text = $"Completed — {entry.Status}";
CounterText.Text = entry.Context;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Device → Device", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
UpdateActionState();
}
}
private static void SetConnectionStatus(System.Windows.Shapes.Ellipse dot, TextBlock label, bool? connected, string? text = null)
{
if (connected is null)
{
dot.Fill = new SolidColorBrush(Color.FromRgb(156, 163, 175));
label.Text = text ?? "Select a device";
label.Foreground = new SolidColorBrush(Color.FromRgb(100, 116, 139));
return;
}
if (connected == true)
{
dot.Fill = new SolidColorBrush(Color.FromRgb(22, 163, 74));
label.Text = text ?? "Connected";
label.Foreground = new SolidColorBrush(Color.FromRgb(22, 163, 74));
}
else
{
dot.Fill = new SolidColorBrush(Color.FromRgb(220, 38, 38));
label.Text = text ?? "Not Connected";
label.Foreground = new SolidColorBrush(Color.FromRgb(220, 38, 38));
}
}
}