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;
|
using HikvisionAttendanceManager.App.Models;
|
||||||
|
|
||||||
namespace HikvisionAttendanceManager.App.Services;
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
/// <summary>Live dashboard probes — connectivity only; results applied on the UI thread.</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;
|
public const int ProbeTimeoutSeconds = 45;
|
||||||
|
|
||||||
private readonly DeviceConnectionService _connectionService = new();
|
private readonly DeviceConnectionService _connectionService = 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 (IsKnownDashboardStatus(device.Status))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
device.Status = "Not Tested";
|
device.Status = "Not Tested";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DashboardProbeResult>> ProbeConnectivityAsync(
|
public async Task<IReadOnlyList<DashboardProbeResult>> ProbeConnectivityAsync(
|
||||||
IReadOnlyList<Device> devices,
|
IReadOnlyList<Device> devices,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (devices.Count == 0)
|
if (devices.Count == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var results = new DashboardProbeResult[devices.Count];
|
var results = new DashboardProbeResult[devices.Count];
|
||||||
await Parallel.ForEachAsync(
|
await Parallel.ForEachAsync(
|
||||||
Enumerable.Range(0, devices.Count),
|
Enumerable.Range(0, devices.Count),
|
||||||
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
|
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentDeviceChecks, CancellationToken = cancellationToken },
|
||||||
async (index, token) =>
|
async (index, token) =>
|
||||||
{
|
{
|
||||||
results[index] = await ProbeDeviceAsync(devices[index], token).ConfigureAwait(false);
|
results[index] = await ProbeDeviceAsync(devices[index], token).ConfigureAwait(false);
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ApplyProbeResults(IEnumerable<DashboardProbeResult> results)
|
public static void ApplyProbeResults(IEnumerable<DashboardProbeResult> results)
|
||||||
{
|
{
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
result.Device.Status = result.ConnectivityStatus;
|
result.Device.Status = result.ConnectivityStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
|
public async Task<IReadOnlyList<SyncHistoryEntry>> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
|
||||||
await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
|
await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
|
public static DateTime? GetLatestHrmsSync(IEnumerable<Device> devices) =>
|
||||||
devices
|
devices
|
||||||
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
|
.Where(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase) && d.LastSync.HasValue)
|
||||||
.Select(d => d.LastSync!.Value)
|
.Select(d => d.LastSync!.Value)
|
||||||
.DefaultIfEmpty()
|
.DefaultIfEmpty()
|
||||||
.Max() is var latest && latest != default
|
.Max() is var latest && latest != default
|
||||||
? latest
|
? latest
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
|
public static async Task<string?> DetectHrmsErrorAsync(IReadOnlyList<Device> devices, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
|
if (devices.Any(d => string.Equals(d.Source, "HRMS", StringComparison.OrdinalIgnoreCase)))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
|
if (!HrmsConnectionFactory.TryGetConnectionString(out _))
|
||||||
return "Unable to load device data.\nCheck the HRMS connection.";
|
return "Unable to load device data.\nHRMS is not configured. Set connection values in Services/AppSecrets.cs before publishing.";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var connection = HrmsConnectionFactory.CreateConnection();
|
await using var connection = HrmsConnectionFactory.CreateConnection();
|
||||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
return null;
|
return devices.Count == 0
|
||||||
}
|
? "Connected to HRMS, but no active Hikvision devices were returned."
|
||||||
catch (Exception ex)
|
: null;
|
||||||
{
|
}
|
||||||
AppLogger.Error("Dashboard: HRMS connection check failed.", ex);
|
catch (Exception ex)
|
||||||
return "Unable to load device data.\nCheck the HRMS connection.";
|
{
|
||||||
}
|
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))
|
private async Task<DashboardProbeResult> ProbeDeviceAsync(Device device, CancellationToken cancellationToken)
|
||||||
return new DashboardProbeResult(device, ConnectionTestOutcome.CredentialsMissing.ToDashboardStatus(), "Credentials not configured.");
|
{
|
||||||
|
if (!HikvisionCredentialsFactory.HasCredentials(device))
|
||||||
try
|
return new DashboardProbeResult(device, ConnectionTestOutcome.CredentialsMissing.ToDashboardStatus(), "Credentials not configured.");
|
||||||
{
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
try
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(DeviceConnectionService.TestTimeoutSeconds + 2));
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
var result = await _connectionService.TestConnectivityAsync(device, timeout.Token).ConfigureAwait(false);
|
timeout.CancelAfter(TimeSpan.FromSeconds(DeviceConnectionService.TestTimeoutSeconds + 2));
|
||||||
var status = result.Outcome.ToDashboardStatus();
|
|
||||||
if (result.Outcome.CountsAsOffline())
|
var result = await _connectionService.TestConnectivityAsync(device, timeout.Token).ConfigureAwait(false);
|
||||||
AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}:{device.IsapiPort}) {status} — {result.Message}");
|
var status = result.Outcome.ToDashboardStatus();
|
||||||
|
if (result.Outcome.CountsAsOffline())
|
||||||
return new DashboardProbeResult(device, status, result.Message);
|
AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}:{device.IsapiPort}) {status} — {result.Message}");
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
return new DashboardProbeResult(device, status, result.Message);
|
||||||
{
|
}
|
||||||
AppLogger.Warning($"Dashboard: {device.Name} ({device.IpAddress}) probe timed out.");
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
return new DashboardProbeResult(device, ConnectionTestOutcome.Timeout.ToDashboardStatus(), "Connection timed out.");
|
{
|
||||||
}
|
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)
|
||||||
return new DashboardProbeResult(device, ConnectionTestOutcome.Offline.ToDashboardStatus(), "Connection failed.");
|
{
|
||||||
}
|
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) ||
|
private static bool IsKnownDashboardStatus(string status) =>
|
||||||
status.Equals("Not Tested", StringComparison.OrdinalIgnoreCase) ||
|
status.Equals("Online", StringComparison.OrdinalIgnoreCase) ||
|
||||||
status.Equals("Authentication Failed", StringComparison.OrdinalIgnoreCase) ||
|
status.Equals("Offline", StringComparison.OrdinalIgnoreCase) ||
|
||||||
status.Equals("API Error", 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);
|
}
|
||||||
|
|
||||||
|
public sealed record DashboardProbeResult(Device Device, string ConnectivityStatus, string Detail);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using HikvisionAttendanceManager.App.Models;
|
using HikvisionAttendanceManager.App.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace HikvisionAttendanceManager.App.Services;
|
namespace HikvisionAttendanceManager.App.Services;
|
||||||
|
|
||||||
|
|
@ -37,42 +36,15 @@ public static class HikvisionCredentialsFactory
|
||||||
|
|
||||||
public static void LogDevelopmentCredentialStatus()
|
public static void LogDevelopmentCredentialStatus()
|
||||||
{
|
{
|
||||||
if (!IsDevelopment())
|
AppSettingsConfiguration.LogStartupConfigurationStatus();
|
||||||
return;
|
|
||||||
|
|
||||||
var username = GetDevelopmentSetting("Hikvision:Username");
|
var username = AppSettingsConfiguration.GetSetting("Hikvision:Username");
|
||||||
var hasPassword = !string.IsNullOrWhiteSpace(GetDevelopmentSetting("Hikvision:Password"));
|
var hasPassword = !string.IsNullOrWhiteSpace(AppSettingsConfiguration.GetSetting("Hikvision:Password"));
|
||||||
if (!string.IsNullOrWhiteSpace(username) && hasPassword)
|
if (!string.IsNullOrWhiteSpace(username) && hasPassword)
|
||||||
AppLogger.Info($"Hikvision default credentials loaded for user '{username}'.");
|
AppLogger.Info($"Hikvision default credentials loaded for user '{username}'.");
|
||||||
else
|
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)
|
private static string? GetDevelopmentSetting(string key) => AppSettingsConfiguration.GetSetting(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ public static class HrmsConnectionFactory
|
||||||
AppLogger.Info($"Environment = {environment}");
|
AppLogger.Info($"Environment = {environment}");
|
||||||
AppLogger.Info($"Development config loaded = {(IsDevelopment() && developmentConfigExists ? "YES" : "NO")}");
|
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 loaded from Development settings.");
|
||||||
AppLogger.Info("HRMS connection configuration found = YES");
|
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 loaded from service database environment settings.");
|
||||||
AppLogger.Info("HRMS connection configuration found = YES");
|
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()
|
public static MySqlConnection CreateConnection()
|
||||||
|
|
@ -125,14 +132,9 @@ public static class HrmsConnectionFactory
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetDevelopmentConnectionString(out string connectionString)
|
private static bool TryGetJsonConnectionString(out string connectionString)
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder()
|
connectionString = AppSettingsConfiguration.GetConnectionString() ?? "";
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
|
||||||
.AddJsonFile("appsettings.json", optional: true)
|
|
||||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
|
||||||
.Build();
|
|
||||||
connectionString = configuration.GetConnectionString("Hrms")?.Trim() ?? "";
|
|
||||||
return !string.IsNullOrWhiteSpace(connectionString);
|
return !string.IsNullOrWhiteSpace(connectionString);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue