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; using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services; namespace HikvisionAttendanceManager.App.Services;
/// <summary>Live dashboard probes that mutate the shared Device collection in place.</summary> /// <summary>Live dashboard probes — connectivity only; results applied on the UI thread.</summary>
public sealed class DashboardService public sealed class DashboardService
{ {
public const int MaxConcurrentDeviceChecks = 4; public const int MaxConcurrentDeviceChecks = 4;
public const int ProbeTimeoutSeconds = 45;
private readonly DeviceConnectionService _connectionService = new();
private readonly HikvisionIsapiClient _isapi = new(); private readonly DeviceConnectionService _connectionService = new();
private readonly OperationHistoryService _historyService = new();
public static void NormalizeConnectivityStatus(IEnumerable<Device> devices)
public static void NormalizeConnectivityStatus(IEnumerable<Device> devices) {
{ foreach (var device in devices)
foreach (var device in devices) {
{ if (IsKnownDashboardStatus(device.Status))
if (!string.Equals(device.Status, "Online", StringComparison.OrdinalIgnoreCase) && continue;
!string.Equals(device.Status, "Offline", StringComparison.OrdinalIgnoreCase))
{ device.Status = "Not Tested";
device.Status = "Not Tested"; }
} }
}
} public async Task<IReadOnlyList<DashboardProbeResult>> ProbeConnectivityAsync(
IReadOnlyList<Device> devices,
public async Task ProbeConnectivityAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (devices.Count == 0) if (devices.Count == 0)
return; return [];
await Parallel.ForEachAsync( var results = new DashboardProbeResult[devices.Count];
devices, await Parallel.ForEachAsync(
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken }, Enumerable.Range(0, devices.Count),
async (device, token) => new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
{ async (index, token) =>
await ProbeDeviceAsync(device, token).ConfigureAwait(false); {
}).ConfigureAwait(false); results[index] = await ProbeDeviceAsync(devices[index], token).ConfigureAwait(false);
} }).ConfigureAwait(false);
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) => return results;
await _historyService.LoadAsync(cancellationToken).ConfigureAwait(false); }
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) => public static void ApplyProbeResults(IEnumerable<DashboardProbeResult> results)
devices {
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue) foreach (var result in results)
.Select(d => d.LastSync!.Value) result.Device.Status = result.ConnectivityStatus;
.DefaultIfEmpty() }
.Max() is var latest && latest != default
? latest public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
: null; await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default) public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
{ devices
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase))) .Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
return null; .Select(d => d.LastSync!.Value)
.DefaultIfEmpty()
if (!HrmsConnectionFactory.TryGetConnectionString(out _)) .Max() is var latest && latest != default
return "Unable to load device data.\nCheck the HRMS connection."; ? latest
: null;
try
{ public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
await using var connection = HrmsConnectionFactory.CreateConnection(); {
await connection.OpenAsync(cancellationToken).ConfigureAwait(false); if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
return null; return null;
}
catch (Exception ex) if (!HrmsConnectionFactory.TryGetConnectionString(out _))
{ return "Unable to load device data.\nCheck the HRMS connection.";
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
return "Unable to load device data.\nCheck the HRMS connection."; try
} {
} await using var connection = HrmsConnectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
private async Task ProbeDeviceAsync(Device device, CancellationToken cancellationToken) return null;
{ }
if (!HasCredentials(device)) catch (Exception ex)
{ {
device.Status = "Not Tested"; AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
return; return "Unable to load device data.\nCheck the HRMS connection.";
} }
}
try
{ private async Task<DashboardProbeResult> ProbeDeviceAsync(Device device, CancellationToken cancellationToken)
var result = await _connectionService.TestConnectivityAsync(device, cancellationToken).ConfigureAwait(false); {
if (!result.IsConnected) if (!HikvisionCredentialsFactory.HasCredentials(device))
{ return new DashboardProbeResult(device, ConnectionTestOutcome.CredentialsMissing.ToDashboardStatus(), "Credentials not configured.");
device.Status = "Offline";
return; try
} {
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
device.Status = "Online"; timeout.CancelAfter(TimeSpan.FromSeconds(DeviceConnectionService.TestTimeoutSeconds + 2));
try
{ var result = await _connectionService.TestConnectivityAsync(device, timeout.Token).ConfigureAwait(false);
var liveCount = await _isapi.TryGetUserCountAsync(device, cancellationToken).ConfigureAwait(false); var status = result.Outcome.ToDashboardStatus();
if (liveCount.HasValue) if (result.Outcome.CountsAsOffline())
device.RegisteredUserCount = liveCount.Value; AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}:{device.IsapiPort}) {status} — {result.Message}");
}
catch (Exception ex) return new DashboardProbeResult(device, status, result.Message);
{ }
AppLogger.Warning($"Dashboard: live user count failed for {device.Name}: {ex.Message}"); catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
} {
} AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}) probe timed out.");
catch (Exception ex) return new DashboardProbeResult(device, ConnectionTestOutcome.Timeout.ToDashboardStatus(), "Connection timed out.");
{ }
AppLogger.Error($"Dashboard: connectivity probe failed for {device.Name}.", ex); catch (Exception ex)
device.Status = "Offline"; {
} AppLogger.Error($"Dashboard: connectivity probe failed for {device.Name}.", ex);
} return new DashboardProbeResult(device, ConnectionTestOutcome.Offline.ToDashboardStatus(), "Connection failed.");
}
private static bool HasCredentials(Device device) }
{
var username = device.Username ?? Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_USERNAME") ?? ""; private static bool IsKnownDashboardStatus(string status) =>
var password = !string.IsNullOrWhiteSpace(device.ProtectedPassword) status.Equals("Online", StringComparison.OrdinalIgnoreCase) ||
? PasswordProtector.Unprotect(device.ProtectedPassword) status.Equals("Offline", StringComparison.OrdinalIgnoreCase) ||
: Environment.GetEnvironmentVariable("HIKVISION_MANAGER_DEFAULT_PASSWORD") ?? ""; status.Equals("Not Tested", StringComparison.OrdinalIgnoreCase) ||
return !string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password); 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; using HikvisionAttendanceManager.App.Models;
namespace HikvisionAttendanceManager.App.Services; namespace HikvisionAttendanceManager.App.Services;
@ -6,13 +7,15 @@ public sealed class DeviceConnectionService
{ {
public const int TestTimeoutSeconds = 10; public const int TestTimeoutSeconds = 10;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DeviceTestLocks = new(StringComparer.OrdinalIgnoreCase);
private readonly HikvisionIsapiClient _isapi = new(); 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) public async Task<ConnectionResult> TestAsync(Device device, CancellationToken cancellationToken = default)
{ {
if (!await _testLock.WaitAsync(0, cancellationToken).ConfigureAwait(false)) if (!await _manualTestLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
return ConnectionResult.Failed("A connection test is already in progress. Please wait."); return ConnectionResult.Failed(ConnectionTestOutcome.Offline, "A connection test is already in progress. Please wait.");
try try
{ {
@ -20,15 +23,30 @@ public sealed class DeviceConnectionService
} }
finally 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) => public Task<ConnectionResult> TestConnectivityAsync(Device device, CancellationToken cancellationToken = default) =>
TestConnectivityCoreAsync(device, cancellationToken); TestConnectivityCoreAsync(device, cancellationToken);
private async Task<ConnectionResult> TestConnectivityCoreAsync(Device device, CancellationToken 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; var deviceLabel = string.IsNullOrWhiteSpace(device.Name) ? device.IpAddress : device.Name;
try try
@ -40,24 +58,32 @@ public sealed class DeviceConnectionService
timeout.CancelAfter(TimeSpan.FromSeconds(TestTimeoutSeconds)); timeout.CancelAfter(TimeSpan.FromSeconds(TestTimeoutSeconds));
var result = await _isapi.TestConnectionAsync(device, timeout.Token).ConfigureAwait(false); 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 ?? "-"}"); AppLogger.Info($"[DEVICE_TEST] Connected device={deviceLabel} ip={device.IpAddress} outcome={result.Outcome} model={result.Model ?? "-"} firmware={result.Firmware ?? "-"}");
return ConnectionResult.Connected(FormatSuccessMessage(device, result), result.Model, result.Firmware); return ConnectionResult.FromIsapi(result, FormatSuccessMessage(device, result));
} }
AppLogger.Warning($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={result.Reason}"); AppLogger.Warning($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} outcome={result.Outcome} reason={result.Reason}");
return ConnectionResult.Failed(FormatFailureMessage(result.Reason)); return ConnectionResult.FromIsapi(result, FormatFailureMessage(result));
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
AppLogger.Warning($"[DEVICE_TEST] Timeout device={deviceLabel} ip={device.IpAddress}"); 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) catch (Exception ex)
{ {
AppLogger.Error($"[DEVICE_TEST] Failed device={deviceLabel} ip={device.IpAddress} reason={ex.Message}", 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 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) 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.Model)) lines.Add($"Model: {result.Model}");
if (!string.IsNullOrWhiteSpace(result.Firmware)) lines.Add($"Firmware: {result.Firmware}"); 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))); 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) => private static string FriendlyFailure(Exception ex) =>
ex is InvalidOperationException ? ex.Message : "Unexpected connection error. See the application log for details."; 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 Connected(ConnectionTestOutcome outcome, string message, string? model = null, string? firmware = null) =>
public static ConnectionResult Failed(string message) => new(false, message); 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 using var stream = File.Create(_path);
await JsonSerializer.SerializeAsync(stream, devices, new JsonSerializerOptions { WriteIndented = true }); await JsonSerializer.SerializeAsync(stream, devices, new JsonSerializerOptions { WriteIndented = true });
} }
} }