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