refactor(services): rewrite DeviceConnectionService with dedup and logging

Per-device test locks, 10s timeout, [DEVICE_TEST] logging, and outcome-based results.

Background device probes with parallel checks; maps outcomes to dashboard without false offline on parse errors.
main
SYED MUSTUFA AHMED NAQVI 2026-08-27 09:46:42 +05:00
parent 85ac3c7b3f
commit f047129c14
3 changed files with 186 additions and 135 deletions

View File

@ -1,117 +1,119 @@
using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services;
/// <summary>Live dashboard probes that mutate the shared Device collection in place.</summary>
public sealed class DashboardService
{
public const int MaxConcurrentDeviceChecks = 4;
private readonly DeviceConnectionService _connectionService = new();
private readonly HikvisionIsapiClient _isapi = new();
private readonly OperationHistoryService _historyService = new();
public static void NormalizeConnectivityStatus(IEnumerable<Device> devices)
{
foreach (var device in devices)
{
if (!string.Equals(device.Status, "Online", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(device.Status, "Offline", StringComparison.OrdinalIgnoreCase))
{
device.Status = "Not Tested";
}
}
}
public async Task ProbeConnectivityAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
{
if (devices.Count == 0)
return;
await Parallel.ForEachAsync(
devices,
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
async (device, token) =>
{
await ProbeDeviceAsync(device, token).ConfigureAwait(false);
}).ConfigureAwait(false);
}
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
await _historyService.LoadAsync(cancellationToken).ConfigureAwait(false);
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
devices
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
.Select(d => d.LastSync!.Value)
.DefaultIfEmpty()
.Max() is var latest && latest != default
? latest
: null;
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
{
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
return null;
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
return "Unable to load device data.\nCheck the HRMS connection.";
try
{
await using var connection = HrmsConnectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
return "Unable to load device data.\nCheck the HRMS connection.";
}
}
private async Task ProbeDeviceAsync(Device device, CancellationToken cancellationToken)
{
if (!HasCredentials(device))
{
device.Status = "Not Tested";
return;
}
try
{
var result = await _connectionService.TestConnectivityAsync(device, cancellationToken).ConfigureAwait(false);
if (!result.IsConnected)
{
device.Status = "Offline";
return;
}
device.Status = "Online";
try
{
var liveCount = await _isapi.TryGetUserCountAsync(device, cancellationToken).ConfigureAwait(false);
if (liveCount.HasValue)
device.RegisteredUserCount = liveCount.Value;
}
catch (Exception ex)
{
AppLogger.Warning($"Dashboard: live user count failed for {device.Name}: {ex.Message}");
}
}
catch (Exception ex)
{
AppLogger.Error($"Dashboard: connectivity probe failed for {device.Name}.", ex);
device.Status = "Offline";
}
}
private static bool HasCredentials(Device device)
{
var username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? "";
var password = !string.IsNullOrWhiteSpace(device.ProtectedPassword)
? PasswordProtector.Unprotect(device.ProtectedPassword)
: Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? "";
return !string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password);
}
}
using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services;
/// <summary>Live dashboard probes — connectivity only; results applied on the UI thread.</summary>
public sealed class DashboardService
{
public const int MaxConcurrentDeviceChecks = 4;
public const int ProbeTimeoutSeconds = 45;
private readonly DeviceConnectionService _connectionService = new();
public static void NormalizeConnectivityStatus(IEnumerable<Device> devices)
{
foreach (var device in devices)
{
if (IsKnownDashboardStatus(device.Status))
continue;
device.Status = "Not Tested";
}
}
public async Task<IReadOnlyList<DashboardProbeResult>> ProbeConnectivityAsync(
IReadOnlyList<Device> devices,
CancellationToken cancellationToken = default)
{
if (devices.Count == 0)
return [];
var results = new DashboardProbeResult[devices.Count];
await Parallel.ForEachAsync(
Enumerable.Range(0, devices.Count),
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
async (index, token) =>
{
results[index] = await ProbeDeviceAsync(devices[index], token).ConfigureAwait(false);
}).ConfigureAwait(false);
return results;
}
public static void ApplyProbeResults(IEnumerable<DashboardProbeResult> results)
{
foreach (var result in results)
result.Device.Status = result.ConnectivityStatus;
}
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
devices
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
.Select(d => d.LastSync!.Value)
.DefaultIfEmpty()
.Max() is var latest && latest != default
? latest
: null;
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
{
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
return null;
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
return "Unable to load device data.\nCheck the HRMS connection.";
try
{
await using var connection = HrmsConnectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
return "Unable to load device data.\nCheck the HRMS connection.";
}
}
private async Task<DashboardProbeResult> ProbeDeviceAsync(Device device, CancellationToken cancellationToken)
{
if (!HikvisionCredentialsFactory.HasCredentials(device))
return new DashboardProbeResult(device, ConnectionTestOutcome.CredentialsMissing.ToDashboardStatus(), "Credentials not configured.");
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(DeviceConnectionService.TestTimeoutSeconds + 2));
var result = await _connectionService.TestConnectivityAsync(device, timeout.Token).ConfigureAwait(false);
var status = result.Outcome.ToDashboardStatus();
if (result.Outcome.CountsAsOffline())
AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}:{device.IsapiPort}) {status} — {result.Message}");
return new DashboardProbeResult(device, status, result.Message);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}) probe timed out.");
return new DashboardProbeResult(device, ConnectionTestOutcome.Timeout.ToDashboardStatus(), "Connection timed out.");
}
catch (Exception ex)
{
AppLogger.Error($"Dashboard: connectivity probe failed for {device.Name}.", ex);
return new DashboardProbeResult(device, ConnectionTestOutcome.Offline.ToDashboardStatus(), "Connection failed.");
}
}
private static bool IsKnownDashboardStatus(string status) =>
status.Equals("Online", StringComparison.OrdinalIgnoreCase) ||
status.Equals("Offline", StringComparison.OrdinalIgnoreCase) ||
status.Equals("Not Tested", StringComparison.OrdinalIgnoreCase) ||
status.Equals("Authentication Failed", StringComparison.OrdinalIgnoreCase) ||
status.Equals("API Error", StringComparison.OrdinalIgnoreCase);
}
public sealed record DashboardProbeResult(Device Device, string ConnectivityStatus, string Detail);

View File

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services;
@ -6,13 +7,15 @@ public sealed class DeviceConnectionService
{
public const int TestTimeoutSeconds = 10;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DeviceTestLocks = new(StringComparer.OrdinalIgnoreCase);
private readonly HikvisionIsapiClient _isapi = new();
private readonly SemaphoreSlim _testLock = new(1, 1);
private readonly SemaphoreSlim _manualTestLock = new(1, 1);
public async Task<ConnectionResult> TestAsync(Device device, CancellationToken cancellationToken = default)
{
if (!await _testLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
return ConnectionResult.Failed("A connection test is already in progress. Please wait.");
if (!await _manualTestLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
return ConnectionResult.Failed(ConnectionTestOutcome.Offline, "A connection test is already in progress. Please wait.");
try
{
@ -20,15 +23,30 @@ public sealed class DeviceConnectionService
}
finally
{
_testLock.Release();
_manualTestLock.Release();
}
}
/// <summary>Lightweight connectivity test for batch/dashboard use (no single-flight lock).</summary>
/// <summary>Lightweight connectivity test for batch/dashboard use (deduplicated per device endpoint).</summary>
public Task<ConnectionResult> TestConnectivityAsync(Device device, CancellationToken cancellationToken = default) =>
TestConnectivityCoreAsync(device, cancellationToken);
private async Task<ConnectionResult> TestConnectivityCoreAsync(Device device, CancellationToken cancellationToken)
{
var deviceKey = BuildDeviceKey(device);
var gate = DeviceTestLocks.GetOrAdd(deviceKey, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await ExecuteTestAsync(device, cancellationToken).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
private async Task<ConnectionResult> ExecuteTestAsync(Device device, CancellationToken cancellationToken)
{
var deviceLabel = string.IsNullOrWhiteSpace(device.Name) ? device.IpAddress : device.Name;
try
@ -40,24 +58,32 @@ public sealed class DeviceConnectionService
timeout.CancelAfter(TimeSpan.FromSeconds(TestTimeoutSeconds));
var result = await _isapi.TestConnectionAsync(device, timeout.Token).ConfigureAwait(false);
if (result.Success)
foreach (var line in result.LogLines)
AppLogger.Info($"[DEVICE_TEST] {line}");
if (result.Outcome is ConnectionTestOutcome.Online or ConnectionTestOutcome.ApiResponseError)
{
AppLogger.Info($"[DEVICE_TEST] Connected device={deviceLabel} ip={device.IpAddress} model={result.Model ?? "-"} firmware={result.Firmware ?? "-"}");
return ConnectionResult.Connected(FormatSuccessMessage(device, result), result.Model, result.Firmware);
AppLogger.Info($"[DEVICE_TEST] Connected device={deviceLabel} ip={device.IpAddress} outcome={result.Outcome} model={result.Model ?? "-"} firmware={result.Firmware ?? "-"}");
return ConnectionResult.FromIsapi(result, FormatSuccessMessage(device, result));
}
AppLogger.Warning($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={result.Reason}");
return ConnectionResult.Failed(FormatFailureMessage(result.Reason));
AppLogger.Warning($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} outcome={result.Outcome} reason={result.Reason}");
return ConnectionResult.FromIsapi(result, FormatFailureMessage(result));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
AppLogger.Warning($"[DEVICE_TEST] Timeout device={deviceLabel} ip={device.IpAddress}");
return ConnectionResult.Failed($"Device did not respond within {TestTimeoutSeconds} seconds.");
return ConnectionResult.Failed(ConnectionTestOutcome.Timeout, $"Device did not respond within {TestTimeoutSeconds} seconds.");
}
catch (Exception ex) when (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
AppLogger.Warning($"[DEVICE_TEST] Timeout device={deviceLabel} ip={device.IpAddress}");
return ConnectionResult.Failed(ConnectionTestOutcome.Timeout, $"Device did not respond within {TestTimeoutSeconds} seconds.");
}
catch (Exception ex)
{
AppLogger.Error($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={ex.Message}", ex);
return ConnectionResult.Failed(FriendlyFailure(ex));
return ConnectionResult.Failed(ConnectionTestOutcome.Offline, FriendlyFailure(ex));
}
finally
{
@ -65,22 +91,45 @@ public sealed class DeviceConnectionService
}
}
private static string BuildDeviceKey(Device device) => $"{device.IpAddress}:{device.IsapiPort}";
private static string FormatSuccessMessage(Device device, IsapiTestResult result)
{
var lines = new List<string> { "Connection successful", result.DeviceName ?? device.Name, device.IpAddress };
var lines = new List<string>
{
result.Outcome == ConnectionTestOutcome.ApiResponseError
? "Connection successful (device info response could not be parsed completely)."
: "Connection successful",
result.DeviceName ?? device.Name,
device.IpAddress
};
if (!string.IsNullOrWhiteSpace(result.Model)) lines.Add($"Model: {result.Model}");
if (!string.IsNullOrWhiteSpace(result.Firmware)) lines.Add($"Firmware: {result.Firmware}");
if (!string.IsNullOrWhiteSpace(result.Reason) && result.Outcome == ConnectionTestOutcome.ApiResponseError)
lines.Add(result.Reason);
return string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line)));
}
private static string FormatFailureMessage(string reason) => string.IsNullOrWhiteSpace(reason) ? "Connection failed." : reason;
private static string FormatFailureMessage(IsapiTestResult result) =>
string.IsNullOrWhiteSpace(result.Reason) ? result.Outcome.ToString() : result.Reason;
private static string FriendlyFailure(Exception ex) =>
ex is InvalidOperationException ? ex.Message : "Unexpected connection error. See the application log for details.";
}
public sealed record ConnectionResult(bool IsConnected, string Message, string? Model = null, string? Firmware = null)
public sealed record ConnectionResult(
bool IsConnected,
string Message,
ConnectionTestOutcome Outcome,
string? Model = null,
string? Firmware = null)
{
public static ConnectionResult Connected(string message, string? model = null, string? firmware = null) => new(true, message, model, firmware);
public static ConnectionResult Failed(string message) => new(false, message);
public static ConnectionResult Connected(ConnectionTestOutcome outcome, string message, string? model = null, string? firmware = null) =>
new(outcome.CountsAsOnline(), message, outcome, model, firmware);
public static ConnectionResult Failed(ConnectionTestOutcome outcome, string message) =>
new(false, message, outcome);
public static ConnectionResult FromIsapi(IsapiTestResult result, string message) =>
new(result.Outcome.CountsAsOnline(), message, result.Outcome, result.Model, result.Firmware);
}

View File

@ -29,4 +29,4 @@ public sealed class DeviceStore
await using var stream = File.Create(_path);
await JsonSerializer.SerializeAsync(stream, devices, new JsonSerializerOptions { WriteIndented = true });
}
}
}