Assembly-scanned services, options validator marker, UI tweaks, and config fixes

- Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces)
- Add IApplicationOptionsValidator<TOptions> marker; scan only those for IValidateOptions registration
- Fix ApplicationLifecycleLoggingHostedService: log RestartCommand/ReloadCommand/StatusCommand (no ApplyCommand)
- NavMenu: spin animation for update-check button icon when checking; single icon with conditional nav-footer-spin class
- Add NotificationHost/NotificationToast components and NotificationService for toasts
- Effective config save flow, restore result, save service; various EffectiveConfig and DnsmasqOptions/validator updates
- Test and script updates; docker-compose.test.yml, appsettings, DnsmasqConfKeys/tooltips/sections
This commit is contained in:
2026-03-01 19:21:09 +10:00
parent b0ce7afb58
commit a776f445b6
52 changed files with 1016 additions and 155 deletions
@@ -0,0 +1,138 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService(
IDnsmasqConfigSetService configSetService,
IDnsmasqConfigService configService,
IReloadService reloadService,
ILogger<EffectiveConfigSaveService> logger)
{
_configSetService = configSetService;
_configService = configService;
_reloadService = reloadService;
_logger = logger;
}
/// <inheritdoc />
public async Task<EffectiveConfigSaveResult> SaveAsync(
IReadOnlyList<PendingEffectiveConfigChange> changes,
CancellationToken ct = default)
{
if (changes.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Save skipped: managed config path is not configured");
return new EffectiveConfigSaveResult(
false, null, false, false, -1, null, null,
"missing_managed_path", "Managed config path is not configured.");
}
var managedPath = set.ManagedFilePath!;
var backupPath = BuildBackupPath(managedPath);
try
{
CreateBackupIfSourceExists(managedPath, backupPath);
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write effective config");
var backupExists = File.Exists(backupPath);
return new EffectiveConfigSaveResult(
backupExists,
backupExists ? backupPath : null,
false,
false,
-1,
null,
ex.Message,
"write_failed",
"Failed to write config.");
}
var reload = await _reloadService.ReloadAsync(ct);
var backupCreated = File.Exists(backupPath);
if (!reload.Success)
_logger.LogWarning("Config saved but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr);
return new EffectiveConfigSaveResult(
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Reloaded: reload.Success,
ReloadExitCode: reload.ExitCode,
ReloadStdOut: reload.StdOut,
ReloadStdErr: reload.StdErr,
ErrorCode: reload.Success ? null : "reload_failed",
UserMessage: reload.Success ? "Saved and reloaded." : "Saved, but reload failed.");
}
/// <inheritdoc />
public async Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Restore skipped: managed config path is not configured");
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Managed config path is not configured.");
}
if (!File.Exists(backupPath))
{
_logger.LogWarning("Restore skipped: backup file not found: {Path}", backupPath);
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Backup file not found.");
}
var managedPath = set.ManagedFilePath!;
File.Copy(backupPath, managedPath, overwrite: true);
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
var reload = await _reloadService.ReloadAsync(ct);
if (!reload.Success)
_logger.LogWarning("Restore completed but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr);
return new EffectiveConfigRestoreResult(
Restored: true,
Reloaded: reload.Success,
ReloadExitCode: reload.ExitCode,
ReloadStdErr: reload.StdErr,
UserMessage: reload.Success
? "Backup restored and dnsmasq reloaded."
: "Backup restored, but reload still failed.");
}
private static string BuildBackupPath(string managedPath) =>
$"{managedPath}.bak.{DateTime.UtcNow:yyyyMMdd-HHmmss}";
private static void CreateBackupIfSourceExists(string managedPath, string backupPath)
{
if (!File.Exists(managedPath))
return;
var dir = Path.GetDirectoryName(backupPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Copy(managedPath, backupPath, overwrite: false);
}
}