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
SYED MUSTUFA AHMED NAQVI 2026-09-04 10:56:14 +05:00
parent 252e1b0b80
commit 7cb1ffd65b
5 changed files with 203 additions and 163 deletions

View File

@ -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 = "";
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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
}
} }

View File

@ -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);
} }
} }