diff --git a/src/HikvisionAttendanceManager.App/Services/AppSecrets.example.cs b/src/HikvisionAttendanceManager.App/Services/AppSecrets.example.cs
new file mode 100644
index 0000000..ee27a3e
--- /dev/null
+++ b/src/HikvisionAttendanceManager.App/Services/AppSecrets.example.cs
@@ -0,0 +1,12 @@
+namespace HikvisionAttendanceManager.App.Services;
+
+///
+/// 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.
+///
+internal static class AppSecretsExample
+{
+ internal const string HrmsConnectionString = "";
+ internal const string HikvisionUsername = "";
+ internal const string HikvisionPassword = "";
+}
diff --git a/src/HikvisionAttendanceManager.App/Services/AppSettingsConfiguration.cs b/src/HikvisionAttendanceManager.App/Services/AppSettingsConfiguration.cs
new file mode 100644
index 0000000..537e8e5
--- /dev/null
+++ b/src/HikvisionAttendanceManager.App/Services/AppSettingsConfiguration.cs
@@ -0,0 +1,52 @@
+using Microsoft.Extensions.Configuration;
+using System.IO;
+
+namespace HikvisionAttendanceManager.App.Services;
+
+/// Resolves app settings from embedded AppSecrets, then optional appsettings JSON files.
+internal static class AppSettingsConfiguration
+{
+ public static IConfigurationRoot Build()
+ {
+ var embedded = new Dictionary();
+ 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();
+ 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);
+ }
+}
diff --git a/src/HikvisionAttendanceManager.App/Services/DashboardService.cs b/src/HikvisionAttendanceManager.App/Services/DashboardService.cs
index 1be2b2c..ef5c407 100644
--- a/src/HikvisionAttendanceManager.App/Services/DashboardService.cs
+++ b/src/HikvisionAttendanceManager.App/Services/DashboardService.cs
@@ -1,119 +1,121 @@
-using HikvisionAttendanceManager.App.Models;
-
-namespace HikvisionAttendanceManager.App.Services;
-
-/// Live dashboard probes — connectivity only; results applied on the UI thread.
-public sealed class DashboardService
-{
- public const int MaxConcurrentDeviceChecks = 4;
- public const int ProbeTimeoutSeconds = 45;
-
- private readonly DeviceConnectionService _connectionService = new();
-
- public static void NormalizeConnectivityStatus(IEnumerable devices)
- {
- foreach (var device in devices)
- {
- if (IsKnownDashboardStatus(device.Status))
- continue;
-
- device.Status = "Not Tested";
- }
- }
-
- public async Task> ProbeConnectivityAsync(
- IReadOnlyList 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 results)
- {
- foreach (var result in results)
- result.Device.Status = result.ConnectivityStatus;
- }
-
- public async Task> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
- await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
-
- public static DateTime? GetLatestHrmsSync(IEnumerable 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 DetectHrmsErrorAsync(IReadOnlyList 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 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;
+
+/// Live dashboard probes — connectivity only; results applied on the UI thread.
+public sealed class DashboardService
+{
+ public const int MaxConcurrentDeviceChecks = 4;
+ public const int ProbeTimeoutSeconds = 45;
+
+ private readonly DeviceConnectionService _connectionService = new();
+
+ public static void NormalizeConnectivityStatus(IEnumerable devices)
+ {
+ foreach (var device in devices)
+ {
+ if (IsKnownDashboardStatus(device.Status))
+ continue;
+
+ device.Status = "Not Tested";
+ }
+ }
+
+ public async Task> ProbeConnectivityAsync(
+ IReadOnlyList 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 results)
+ {
+ foreach (var result in results)
+ result.Device.Status = result.ConnectivityStatus;
+ }
+
+ public async Task> LoadRecentHistoryAsync(CancellationToken cancellationToken = default) =>
+ await new OperationHistoryService().LoadAsync(cancellationToken).ConfigureAwait(false);
+
+ public static DateTime? GetLatestHrmsSync(IEnumerable 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 DetectHrmsErrorAsync(IReadOnlyList 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 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);
diff --git a/src/HikvisionAttendanceManager.App/Services/HikvisionCredentialsFactory.cs b/src/HikvisionAttendanceManager.App/Services/HikvisionCredentialsFactory.cs
index 98c65cd..c8d27fa 100644
--- a/src/HikvisionAttendanceManager.App/Services/HikvisionCredentialsFactory.cs
+++ b/src/HikvisionAttendanceManager.App/Services/HikvisionCredentialsFactory.cs
@@ -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);
}
diff --git a/src/HikvisionAttendanceManager.App/Services/HrmsConnectionFactory.cs b/src/HikvisionAttendanceManager.App/Services/HrmsConnectionFactory.cs
index 8ebf9fe..16f475d 100644
--- a/src/HikvisionAttendanceManager.App/Services/HrmsConnectionFactory.cs
+++ b/src/HikvisionAttendanceManager.App/Services/HrmsConnectionFactory.cs
@@ -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);
}
}