feat(config): embed credentials for ClickOnce and improve HRMS error messages
Adds embedded AppSecrets configuration so Release/ClickOnce builds work without appsettings.Development.json or a Public Documents config file. Includes AppSecrets.example.cs as a template; copy to gitignored AppSecrets.cs and fill in credentials before publishing. Improves dashboard messages when HRMS is missing vs unreachable.main
parent
252e1b0b80
commit
7cb1ffd65b
|
|
@ -0,0 +1,12 @@
|
|||
namespace HikvisionAttendanceManager.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Copy this file to AppSecrets.cs and fill in values before publishing ClickOnce.
|
||||
/// AppSecrets.cs is gitignored and compiled into the application — no external config file required.
|
||||
/// </summary>
|
||||
internal static class AppSecretsExample
|
||||
{
|
||||
internal const string HrmsConnectionString = "";
|
||||
internal const string HikvisionUsername = "";
|
||||
internal const string HikvisionPassword = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using System.IO;
|
||||
|
||||
namespace HikvisionAttendanceManager.App.Services;
|
||||
|
||||
/// <summary>Resolves app settings from embedded AppSecrets, then optional appsettings JSON files.</summary>
|
||||
internal static class AppSettingsConfiguration
|
||||
{
|
||||
public static IConfigurationRoot Build()
|
||||
{
|
||||
var embedded = new Dictionary<string, string?>();
|
||||
if (AppSecrets.HasHrmsConnection)
|
||||
embedded["ConnectionStrings:Hrms"] = AppSecrets.HrmsConnectionString.Trim();
|
||||
if (AppSecrets.HasHikvisionCredentials)
|
||||
{
|
||||
embedded["Hikvision:Username"] = AppSecrets.HikvisionUsername.Trim();
|
||||
embedded["Hikvision:Password"] = AppSecrets.HikvisionPassword.Trim();
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(embedded)
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static string? GetConnectionString() => Build().GetConnectionString("Hrms")?.Trim();
|
||||
|
||||
public static string? GetSetting(string key) => Build()[key]?.Trim();
|
||||
|
||||
public static void LogStartupConfigurationStatus()
|
||||
{
|
||||
AppLogger.Info($"Configuration sources: {DescribeConfigSources()}");
|
||||
if (!AppSecrets.HasHrmsConnection && string.IsNullOrWhiteSpace(GetConnectionString()))
|
||||
AppLogger.Warning("HRMS connection is not configured. Set values in Services/AppSecrets.cs before publishing.");
|
||||
if (!AppSecrets.HasHikvisionCredentials && string.IsNullOrWhiteSpace(GetSetting("Hikvision:Username")))
|
||||
AppLogger.Warning("Hikvision default credentials are not configured in AppSecrets.cs.");
|
||||
}
|
||||
|
||||
public static string DescribeConfigSources()
|
||||
{
|
||||
var sources = new List<string>();
|
||||
if (AppSecrets.HasHrmsConnection)
|
||||
sources.Add("embedded AppSecrets");
|
||||
if (File.Exists(Path.Combine(AppContext.BaseDirectory, "appsettings.json")))
|
||||
sources.Add("application appsettings.json");
|
||||
if (File.Exists(Path.Combine(AppContext.BaseDirectory, "appsettings.Development.json")))
|
||||
sources.Add("application appsettings.Development.json");
|
||||
return sources.Count == 0 ? "none" : string.Join(", ", sources);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +1,121 @@
|
|||
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);
|
||||
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.\nHRMS is not configured. Set connection values in Services/AppSecrets.cs before publishing.";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return devices.Count == 0
|
||||
? "Connected to HRMS, but no active Hikvision devices were returned."
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
|
||||
return "Unable to connect to the HRMS database.\nCheck server, credentials, and network access.";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using HikvisionAttendanceManager.App.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace HikvisionAttendanceManager.App.Services;
|
||||
|
||||
|
|
@ -37,42 +36,15 @@ public static class HikvisionCredentialsFactory
|
|||
|
||||
public static void LogDevelopmentCredentialStatus()
|
||||
{
|
||||
if (!IsDevelopment())
|
||||
return;
|
||||
AppSettingsConfiguration.LogStartupConfigurationStatus();
|
||||
|
||||
var username = GetDevelopmentSetting("Hikvision:Username");
|
||||
var hasPassword = !string.IsNullOrWhiteSpace(GetDevelopmentSetting("Hikvision:Password"));
|
||||
var username = AppSettingsConfiguration.GetSetting("Hikvision:Username");
|
||||
var hasPassword = !string.IsNullOrWhiteSpace(AppSettingsConfiguration.GetSetting("Hikvision:Password"));
|
||||
if (!string.IsNullOrWhiteSpace(username) && hasPassword)
|
||||
AppLogger.Info($"Hikvision default credentials loaded for user '{username}'.");
|
||||
else
|
||||
AppLogger.Warning("Hikvision default credentials were not found in Development settings.");
|
||||
AppLogger.Warning("Hikvision default credentials were not found in appsettings.");
|
||||
}
|
||||
|
||||
private static string? GetDevelopmentSetting(string key)
|
||||
{
|
||||
if (!IsDevelopment())
|
||||
return null;
|
||||
|
||||
return BuildDevelopmentConfiguration()[key]?.Trim();
|
||||
}
|
||||
|
||||
private static IConfiguration BuildDevelopmentConfiguration() =>
|
||||
new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.Build();
|
||||
|
||||
private static bool IsDevelopment()
|
||||
{
|
||||
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
return string.Equals(environment, "Development", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
#if DEBUG
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
private static string? GetDevelopmentSetting(string key) => AppSettingsConfiguration.GetSetting(key);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public static class HrmsConnectionFactory
|
|||
AppLogger.Info($"Environment = {environment}");
|
||||
AppLogger.Info($"Development config loaded = {(IsDevelopment() && developmentConfigExists ? "YES" : "NO")}");
|
||||
|
||||
if (IsDevelopment() && TryGetDevelopmentConnectionString(out connectionString))
|
||||
if (IsDevelopment() && TryGetJsonConnectionString(out connectionString))
|
||||
{
|
||||
AppLogger.Info("HRMS connection configuration loaded from Development settings.");
|
||||
AppLogger.Info("HRMS connection configuration found = YES");
|
||||
|
|
@ -53,12 +53,19 @@ public static class HrmsConnectionFactory
|
|||
{
|
||||
AppLogger.Info("HRMS connection configuration loaded from service database environment settings.");
|
||||
AppLogger.Info("HRMS connection configuration found = YES");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
|
||||
if (TryGetJsonConnectionString(out connectionString))
|
||||
{
|
||||
AppLogger.Warning("HRMS connection configuration found = NO");
|
||||
AppLogger.Info($"HRMS connection configuration loaded from appsettings ({AppSettingsConfiguration.DescribeConfigSources()}).");
|
||||
AppLogger.Info("HRMS connection configuration found = YES");
|
||||
return true;
|
||||
}
|
||||
return resolved;
|
||||
|
||||
AppLogger.Warning("HRMS connection configuration found = NO");
|
||||
connectionString = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MySqlConnection CreateConnection()
|
||||
|
|
@ -125,14 +132,9 @@ public static class HrmsConnectionFactory
|
|||
#endif
|
||||
}
|
||||
|
||||
private static bool TryGetDevelopmentConnectionString(out string connectionString)
|
||||
private static bool TryGetJsonConnectionString(out string connectionString)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.Build();
|
||||
connectionString = configuration.GetConnectionString("Hrms")?.Trim() ?? "";
|
||||
connectionString = AppSettingsConfiguration.GetConnectionString() ?? "";
|
||||
return !string.IsNullOrWhiteSpace(connectionString);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue