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
parent
85ac3c7b3f
commit
f047129c14
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue