mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
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:
@@ -1,7 +1,8 @@
|
||||
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
|
||||
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--minimal-conf | --prepare-only | ...]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
|
||||
#
|
||||
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
|
||||
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
|
||||
@@ -18,9 +19,11 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
||||
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
|
||||
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
|
||||
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
|
||||
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }; dnsmasq -k --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 & sleep 1; pgrep -x dnsmasq >/dev/null || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }"
|
||||
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
|
||||
@@ -50,7 +50,7 @@ except ImportError:
|
||||
OUT_DIR = "/out"
|
||||
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
||||
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
|
||||
OPTION_KEYS = [
|
||||
"no-hosts",
|
||||
"addn-hosts",
|
||||
|
||||
@@ -21,6 +21,7 @@ RECREATE=false
|
||||
CLEAR=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
MINIMAL_CONF=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
@@ -43,6 +44,8 @@ usage() {
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
||||
echo " config has few readonly options; good for testing reload/restart failure flows."
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
echo " Use to inspect or edit the mount before starting containers."
|
||||
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
||||
@@ -85,6 +88,9 @@ usage() {
|
||||
echo ""
|
||||
echo " $0 --tidy"
|
||||
echo " Stop harness and clear testdata-mount for a clean next run."
|
||||
echo ""
|
||||
echo " $0 --minimal-conf"
|
||||
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -125,6 +131,10 @@ while [ $# -gt 0 ]; do
|
||||
RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--minimal-conf)
|
||||
MINIMAL_CONF=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
@@ -210,7 +220,11 @@ if [ "$PREPARE_ONLY" = true ]; then
|
||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
fi
|
||||
echo "To start the harness: $START_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -220,6 +234,11 @@ case "$MOUNT_DIR" in
|
||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
|
||||
echo "Using minimal config: $TEST_DNSMASQ_CONF"
|
||||
fi
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$BUILD" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
|
||||
@@ -26,7 +26,7 @@ public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
|
||||
@@ -45,7 +45,8 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||
@@ -53,6 +54,7 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Empty(servers);
|
||||
Assert.Empty(locals);
|
||||
Assert.Empty(dhcpOptionForce);
|
||||
Assert.Empty(ipset);
|
||||
Assert.Empty(nftset);
|
||||
|
||||
@@ -23,11 +23,13 @@ public class DnsmasqConfIncludeParserServerAndResolvTests
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
Assert.Equal(v3, result[2]);
|
||||
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
|
||||
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
|
||||
Assert.Equal(2, resultServer.Count);
|
||||
Assert.Equal(v1, resultServer[0]);
|
||||
Assert.Equal(v3, resultServer[1]);
|
||||
Assert.Single(resultLocal);
|
||||
Assert.Equal(v2, resultLocal[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Empty(config.AddnHostsPaths);
|
||||
Assert.NotNull(sources);
|
||||
Assert.Empty(sources.AddnHostsPaths);
|
||||
Assert.Empty(sources.ServerLocalValues);
|
||||
Assert.Empty(sources.ServerValues);
|
||||
Assert.Empty(sources.LocalValues);
|
||||
Assert.Empty(sources.DhcpRanges);
|
||||
Assert.Null(sources.NoHosts);
|
||||
Assert.Null(sources.Port);
|
||||
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerLocalValues);
|
||||
Assert.Empty(config.ServerValues);
|
||||
Assert.Empty(config.LocalValues);
|
||||
Assert.Empty(config.DhcpRanges);
|
||||
|
||||
Assert.NotNull(sources);
|
||||
|
||||
@@ -12,7 +12,8 @@ public class EffectiveDnsmasqConfigTests
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<string>(),
|
||||
ServerValues: Array.Empty<string>(),
|
||||
LocalValues: Array.Empty<string>(),
|
||||
RevServerValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
|
||||
+67
-9
@@ -4,7 +4,11 @@
|
||||
@using DnsmasqWebUI.Models.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@using System.Net
|
||||
@using System.Net.Sockets
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
|
||||
@if (_customFragment is { } fragment)
|
||||
@@ -40,8 +44,14 @@
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" />
|
||||
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList
|
||||
? pendingList.Select(s => new ValueWithSource(s, null)).ToList()
|
||||
: Descriptor.GetItems();
|
||||
var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true);
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@(allReadonly ? items?.FirstOrDefault()?.Source : null)" ShowEditableBadge="@(!allReadonly)" HasPendingChange="@_hasPendingChange" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -93,14 +103,17 @@ else
|
||||
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
|
||||
_optionHelpKey = Descriptor.OptionName;
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
var descriptorValue = Descriptor.IsMultiValue
|
||||
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||
: Descriptor.GetValue();
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = Descriptor.GetValue();
|
||||
_valueAtActivation = descriptorValue;
|
||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
if (_wasActive && !IsActive)
|
||||
@@ -119,17 +132,25 @@ else
|
||||
await CommitIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task CommitIfChangedAsync()
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? _effectiveValue;
|
||||
if (!Equals(_valueAtActivation, newValue))
|
||||
if (!ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
var validationError = ValidateDraftValue(newValue);
|
||||
if (validationError != null)
|
||||
{
|
||||
NotificationService.ShowError(validationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = Descriptor.GetSource();
|
||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
}
|
||||
_draftValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
@@ -143,12 +164,19 @@ else
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = value;
|
||||
await CommitIfChangedAsync();
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
}
|
||||
|
||||
private async Task HandleConfirmEdit()
|
||||
{
|
||||
await CommitIfChangedAsync();
|
||||
if (!await CommitIfChangedAsync())
|
||||
return;
|
||||
_exitAction = ExitAction.Confirm;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
@@ -171,5 +199,35 @@ else
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private string? ValidateDraftValue(object? value)
|
||||
{
|
||||
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
|
||||
{
|
||||
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
|
||||
if (values == null) return null;
|
||||
foreach (var raw in values)
|
||||
{
|
||||
var v = raw.Trim();
|
||||
if (!IPAddress.TryParse(v, out var ip) || ip.AddressFamily != AddressFamily.InterNetwork)
|
||||
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 address.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? oldValue, object? newValue)
|
||||
{
|
||||
var oldList = AsStringList(oldValue);
|
||||
var newList = AsStringList(newValue);
|
||||
if (oldList != null || newList != null)
|
||||
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
return Equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private enum ExitAction { None, Confirm, Cancel, Revert }
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@(Source?.IsReadOnly != true && IsEditMode)" EditableAsButton="false" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="false" EditableAsButton="false" />
|
||||
</span>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
|
||||
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
@@ -27,6 +31,10 @@
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
@@ -41,6 +49,13 @@
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private string _editLines = "";
|
||||
|
||||
|
||||
@@ -2,13 +2,36 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IEffectiveConfigSaveService SaveService
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@if (ShowModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal PendingChanges="@PendingChanges" Status="@Status" Session="@Session" OnClose="@OnClose" OnSave="@HandleSave" OnReverted="@HandleReverted" />
|
||||
<EffectiveConfigSaveModal
|
||||
PendingChanges="@PendingChanges"
|
||||
Status="@Status"
|
||||
Session="@Session"
|
||||
SaveState="@_saveState"
|
||||
SaveResult="@_saveResult"
|
||||
RestoreResult="@_restoreResult"
|
||||
OnClose="@OnClose"
|
||||
OnSave="@HandleSave"
|
||||
OnRestore="@HandleRestore"
|
||||
OnReverted="@HandleReverted" />
|
||||
}
|
||||
|
||||
@code {
|
||||
public enum SaveUiState
|
||||
{
|
||||
Review,
|
||||
Saving,
|
||||
WriteFailed,
|
||||
ReloadFailed,
|
||||
RestoreInProgress,
|
||||
RestoreFailed
|
||||
}
|
||||
|
||||
[Parameter] public bool ShowModal { get; set; }
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
@@ -16,6 +39,20 @@
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
|
||||
private SaveUiState _saveState = SaveUiState.Review;
|
||||
private EffectiveConfigSaveResult? _saveResult;
|
||||
private EffectiveConfigRestoreResult? _restoreResult;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ShowModal)
|
||||
{
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleReverted()
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
@@ -24,15 +61,60 @@
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
try
|
||||
|
||||
_saveState = SaveUiState.Saving;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await Session.ApplyAsync();
|
||||
|
||||
_saveResult = result;
|
||||
if (result.Saved && result.Reloaded)
|
||||
{
|
||||
await Session.ApplyAsync();
|
||||
var message = result.UserMessage ?? "Config saved and dnsmasq reloaded.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
if (result.Saved && !result.Reloaded)
|
||||
_saveState = SaveUiState.ReloadFailed;
|
||||
else if (!result.Saved)
|
||||
_saveState = SaveUiState.WriteFailed;
|
||||
else
|
||||
_saveState = SaveUiState.Review;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleRestore(string backupPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupPath)) return;
|
||||
|
||||
_saveState = SaveUiState.RestoreInProgress;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await SaveService.RestoreAsync(backupPath);
|
||||
|
||||
_restoreResult = result;
|
||||
if (result.Restored && result.Reloaded)
|
||||
{
|
||||
// TODO: show error to user (e.g. toast or modal message)
|
||||
var message = result.UserMessage ?? "Backup restored and dnsmasq reloaded.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.RestoreFailed;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
+160
-34
@@ -8,73 +8,187 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
|
||||
</div>
|
||||
<div class="modal-body ec-save-modal-body">
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don’t want to keep.</p>
|
||||
@if (ManagedFilePath != null)
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<p class="ec-save-modal-target mb-3">
|
||||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
||||
</p>
|
||||
}
|
||||
@if (PendingChanges.Count == 0)
|
||||
{
|
||||
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
|
||||
No changes to save. Revert all above or close to keep editing.
|
||||
<div class="ec-save-modal-loading text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and reloading…" : "Restoring backup and reloading…")</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||
@foreach (var c in PendingChanges)
|
||||
<div class="alert alert-danger mb-0">
|
||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
{
|
||||
var change = c;
|
||||
<div class="list-group-item ec-save-modal-item d-flex align-items-center gap-3 py-3 px-0 border-0 border-bottom">
|
||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
||||
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
</span>
|
||||
@if (change.CurrentSourceFilePath != null)
|
||||
{
|
||||
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Remove this change from the list" aria-label="Revert @change.OptionName">
|
||||
Remove
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Config was saved, but reload failed.</strong> You can restore the backup or keep editing.
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
<p class="ec-save-modal-backup-path mb-2">
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
{
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-2 @(_stderrExpanded ? "active" : "")" @onclick="() => _stderrExpanded = !_stderrExpanded" aria-expanded="@_stderrExpanded">
|
||||
@(_stderrExpanded ? "Hide" : "Show") reload stderr
|
||||
</button>
|
||||
@if (_stderrExpanded)
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
@RestoreResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(RestoreResult.ReloadStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@RestoreResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
||||
@if (ManagedFilePath != null)
|
||||
{
|
||||
<p class="ec-save-modal-target mb-3">
|
||||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
||||
</p>
|
||||
}
|
||||
@if (PendingChanges.Count == 0)
|
||||
{
|
||||
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
|
||||
No pending changes. Close to continue editing.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||
@foreach (var c in PendingChanges)
|
||||
{
|
||||
var change = c;
|
||||
var oldList = AsStringList(change.OldValue);
|
||||
var newList = AsStringList(change.NewValue);
|
||||
var isMultiValue = oldList != null || newList != null;
|
||||
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
|
||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
|
||||
@if (!isMultiValue)
|
||||
{
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isMultiValue)
|
||||
{
|
||||
var added = (newList ?? Array.Empty<string>()).Except(oldList ?? Array.Empty<string>()).ToList();
|
||||
var removed = (oldList ?? Array.Empty<string>()).Except(newList ?? Array.Empty<string>()).ToList();
|
||||
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
||||
@foreach (var val in removed)
|
||||
{
|
||||
<div class="text-muted text-decoration-line-through">− @val</div>
|
||||
}
|
||||
@foreach (var val in added)
|
||||
{
|
||||
<div class="text-success">+ @val</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (change.CurrentSourceFilePath != null)
|
||||
{
|
||||
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)">Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" style="display: block;" @onclick="Close"></div>
|
||||
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
||||
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
|
||||
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
/// <summary>Invoked after reverting a change so the parent can re-render.</summary>
|
||||
[Parameter] public EventCallback<string> OnRestore { get; set; }
|
||||
[Parameter] public EventCallback OnReverted { get; set; }
|
||||
|
||||
private bool _stderrExpanded;
|
||||
|
||||
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
||||
|
||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||
|
||||
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
|
||||
private static IReadOnlyList<string>? AsStringList(object? v)
|
||||
{
|
||||
if (v == null) return null;
|
||||
if (v is IReadOnlyList<string> ro) return ro;
|
||||
if (v is List<string> list) return list;
|
||||
if (v is string[] arr) return arr;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v)
|
||||
{
|
||||
if (v == null) return "(not set)";
|
||||
if (v is bool b) return b ? "on" : "off";
|
||||
var list = AsStringList(v);
|
||||
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
|
||||
return v.ToString() ?? "(not set)";
|
||||
}
|
||||
|
||||
@@ -84,8 +198,15 @@
|
||||
await OnReverted.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnBackdropClick()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await Close();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -93,4 +214,9 @@
|
||||
{
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Restore(string backupPath)
|
||||
{
|
||||
await OnRestore.InvokeAsync(backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,3 +41,21 @@
|
||||
.ec-save-modal-empty {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-loading {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-path {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ec-save-modal-pre {
|
||||
font-size: 0.8125rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@using DnsmasqWebUI.Components.Shared
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@implements IDisposable
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
</div>
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
<NotificationHost />
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
|
||||
@@ -54,14 +54,7 @@
|
||||
else
|
||||
{
|
||||
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
|
||||
@if (UpdateCheckService.CheckInProgress)
|
||||
{
|
||||
<i class="bi bi-arrow-repeat nav-footer-spin" aria-hidden="true"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-repeat" aria-hidden="true"></i>
|
||||
}
|
||||
<i class="bi bi-arrow-repeat nav-footer-check-icon @(UpdateCheckService.CheckInProgress ? "nav-footer-spin" : null)" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
]</span>
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.nav-footer-check-icon {
|
||||
display: inline-block; /* required for transform animation */
|
||||
}
|
||||
|
||||
.nav-footer-spin {
|
||||
animation: nav-footer-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject INotificationService NotificationService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
|
||||
<PageTitle>Dnsmasq | @AppTitle</PageTitle>
|
||||
|
||||
@@ -67,14 +69,6 @@ else if (_status != null)
|
||||
}
|
||||
@(_reloading ? "Reloading…" : "Reload config")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(_reloadMessage))
|
||||
{
|
||||
<span class="@(_reloadSuccess ? "text-success" : "text-danger") small">@_reloadMessage</span>
|
||||
}
|
||||
@if (!_reloadSuccess && _reloadDetail != null)
|
||||
{
|
||||
<pre class="pre-block mt-2 mb-0" style="max-height: 10rem;">@_reloadDetail</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -92,9 +86,6 @@ else if (_status != null)
|
||||
private string? _error;
|
||||
private bool _loading = true;
|
||||
private bool _reloading;
|
||||
private string? _reloadMessage;
|
||||
private string? _reloadDetail;
|
||||
private bool _reloadSuccess;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -158,28 +149,30 @@ else if (_status != null)
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed (e.g. navigated away) while callback was pending; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadDnsmasq()
|
||||
{
|
||||
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
|
||||
_reloading = true;
|
||||
_reloadMessage = null;
|
||||
_reloadDetail = null;
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
var result = await ReloadClient.ReloadAsync(token);
|
||||
if (result.Success)
|
||||
{
|
||||
_reloadMessage = "Reloaded.";
|
||||
_reloadSuccess = true;
|
||||
NotificationService.ShowSuccess("Reloaded.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_reloadMessage = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
|
||||
_reloadDetail = BuildReloadDetail(result);
|
||||
_reloadSuccess = false;
|
||||
var msg = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
|
||||
if (!string.IsNullOrEmpty(result.StdErr))
|
||||
msg += " " + result.StdErr;
|
||||
NotificationService.ShowError(msg);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -193,7 +186,7 @@ else if (_status != null)
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_reloadMessage == null) { _reloadMessage = ex.Message; _reloadSuccess = false; }
|
||||
NotificationService.ShowError("Reload failed: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -201,14 +194,6 @@ else if (_status != null)
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildReloadDetail(ReloadResult r)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(r.StdOut)) parts.Add("stdout:\n" + r.StdOut);
|
||||
if (!string.IsNullOrEmpty(r.StdErr)) parts.Add("stderr:\n" + r.StdErr);
|
||||
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
@implements IDisposable
|
||||
|
||||
@if (_toasts.Count > 0)
|
||||
{
|
||||
<div class="notification-host" aria-live="polite">
|
||||
@foreach (var item in _toasts)
|
||||
{
|
||||
<NotificationToast @key="item.Id"
|
||||
Id="@item.Id"
|
||||
Kind="@item.Kind"
|
||||
Message="@item.Message"
|
||||
AutoDismissMs="4500"
|
||||
OnDismissRequested="Dismiss" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<NotificationItem> _toasts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NotificationService.NotificationsChanged += OnNotificationsChanged;
|
||||
_toasts = NotificationService.GetSnapshot().ToList();
|
||||
}
|
||||
|
||||
private void OnNotificationsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_toasts = NotificationService.GetSnapshot().ToList();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task Dismiss(int id)
|
||||
{
|
||||
NotificationService.Dismiss(id);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NotificationService.NotificationsChanged -= OnNotificationsChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.notification-host {
|
||||
position: fixed;
|
||||
top: var(--app-spacing-md, 1rem);
|
||||
right: var(--app-spacing-md, 1rem);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-host > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
|
||||
<div class="notification-toast notification-toast-@Kind.ToString().ToLowerInvariant()" role="alert">
|
||||
<span class="notification-toast-accent" aria-hidden="true"></span>
|
||||
<span class="notification-toast-icon" aria-hidden="true">@Icon</span>
|
||||
<span class="notification-toast-message">@Message</span>
|
||||
<button type="button" class="notification-toast-dismiss" aria-label="Dismiss" @onclick="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public NotificationKind Kind { get; set; }
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Parameter] public int AutoDismissMs { get; set; } = 4500;
|
||||
[Parameter] public EventCallback<int> OnDismissRequested { get; set; }
|
||||
|
||||
private string Icon => Kind switch
|
||||
{
|
||||
NotificationKind.Success => "✓",
|
||||
NotificationKind.Error => "✕",
|
||||
NotificationKind.Warning => "⚠",
|
||||
NotificationKind.Info => "ℹ",
|
||||
_ => "•"
|
||||
};
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender && AutoDismissMs > 0)
|
||||
_ = ScheduleAutoDismiss();
|
||||
}
|
||||
|
||||
private async Task ScheduleAutoDismiss()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(AutoDismissMs);
|
||||
await InvokeAsync(() => OnDismissRequested.InvokeAsync(Id));
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
}
|
||||
|
||||
private async Task Dismiss() => await OnDismissRequested.InvokeAsync(Id);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Toast: compact popover with left accent, icon, message, dismiss. Modern, distinct. */
|
||||
.notification-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 16rem;
|
||||
max-width: 28rem;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-left: 0.5rem;
|
||||
background: var(--notification-bg);
|
||||
color: var(--notification-fg);
|
||||
border-radius: var(--app-radius-lg, 0.5rem);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-toast-accent {
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
background: var(--notification-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.notification-toast-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-toast-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--app-radius, 0.375rem);
|
||||
}
|
||||
|
||||
.notification-toast-dismiss:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.notification-toast-success {
|
||||
--notification-bg: #f0fdf4;
|
||||
--notification-fg: #166534;
|
||||
--notification-accent: #22c55e;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.notification-toast-error {
|
||||
--notification-bg: #fef2f2;
|
||||
--notification-fg: #b91c1c;
|
||||
--notification-accent: #ef4444;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.notification-toast-warning {
|
||||
--notification-bg: #fffbeb;
|
||||
--notification-fg: #b45309;
|
||||
--notification-accent: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.notification-toast-info {
|
||||
--notification-bg: #eff6ff;
|
||||
--notification-fg: #1d4ed8;
|
||||
--notification-accent: #3b82f6;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public class StatusController : ControllerBase
|
||||
LeasesPathConfigured: !string.IsNullOrEmpty(effectiveLeasesPath),
|
||||
LeasesPathExists: !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
|
||||
ConfigFiles: set.Files.Count > 0 ? set.Files : null,
|
||||
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.ReloadCommand),
|
||||
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.RestartCommand) || !string.IsNullOrWhiteSpace(_options.ReloadCommand),
|
||||
StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand),
|
||||
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
|
||||
LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand),
|
||||
|
||||
@@ -4,6 +4,7 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions.DependencyInjection;
|
||||
|
||||
@@ -59,12 +60,38 @@ public static class ServiceCollectionExtensions
|
||||
/// Scans the assembly for types implementing application marker interfaces
|
||||
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationScopedConcrete"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
|
||||
/// and registers each with the configured lifetime. Hosted services are registered via <c>AddHostedService<T></c>.
|
||||
/// Also registers all <see cref="IApplicationOptionsValidator{TOptions}"/> implementations as <see cref="IValidateOptions{TOptions}"/>.
|
||||
/// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register);
|
||||
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the given assembly for types implementing <see cref="IApplicationOptionsValidator{TOptions}"/> and registers each
|
||||
/// as <see cref="IValidateOptions{TOptions}"/> singleton so the options framework picks them up.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOptionsValidatorsFromAssembly(this IServiceCollection services, Assembly assembly)
|
||||
{
|
||||
var markerDef = typeof(IApplicationOptionsValidator<>);
|
||||
var validateOptionsDef = typeof(IValidateOptions<>);
|
||||
var validators = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition)
|
||||
.Select(t => (Impl: t, MarkerInterface: t.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == markerDef)))
|
||||
.Where(x => x.MarkerInterface != null)
|
||||
.ToList();
|
||||
|
||||
foreach (var (impl, markerInterface) in validators)
|
||||
{
|
||||
// Register as IValidateOptions<TOptions> so options validation finds it
|
||||
var optionsType = markerInterface!.GetGenericArguments()[0];
|
||||
var validateOptionsType = validateOptionsDef.MakeGenericType(optionsType);
|
||||
services.AddSingleton(validateOptionsType, impl);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,4 @@ public static class DnsmasqConfKeys
|
||||
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
|
||||
public const string Conntrack = "conntrack";
|
||||
|
||||
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
|
||||
public static readonly string[] ServerLocalKeys = { Server, Local };
|
||||
}
|
||||
|
||||
@@ -8,13 +8,6 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
/// </summary>
|
||||
public static class DnsmasqOptionTooltips
|
||||
{
|
||||
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
|
||||
public const string ServerLocalLabel = "server / local";
|
||||
|
||||
/// <summary>Option name to option-help file key. "server / local" → "server"; others unchanged.</summary>
|
||||
public static string GetOptionHelpKey(string optionName) =>
|
||||
string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName;
|
||||
|
||||
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
// --- Hosts ---
|
||||
@@ -32,7 +25,8 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
|
||||
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
|
||||
[DnsmasqConfKeys.LogQueries] = "Log DNS queries. Optional value: extra, proto, or auth.",
|
||||
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
|
||||
[DnsmasqConfKeys.Server] = "Upstream DNS servers. Order is preserved.",
|
||||
[DnsmasqConfKeys.Local] = "Local-only domains (queries not forwarded). Order is preserved.",
|
||||
[DnsmasqConfKeys.RevServer] = "Reverse DNS server (e.g. rev-server=1.2.3.0/24,192.168.0.1). Can repeat.",
|
||||
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
|
||||
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
|
||||
|
||||
@@ -52,7 +52,8 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null);
|
||||
|
||||
// Resolver / DNS
|
||||
list.AddDescriptor(registry, DnsmasqOptionTooltips.ServerLocalLabel, status, null, null, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Server, status, null, null, Items(ec => ec?.ServerValues, src => src?.ServerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Local, status, null, null, Items(ec => ec?.LocalValues, src => src?.LocalValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.RevServer, status, null, null, Items(ec => ec?.RevServerValues, src => src?.RevServerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Address, status, null, null, Items(ec => ec?.AddressValues, src => src?.AddressValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ResolvFile, status, null, null, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles));
|
||||
|
||||
@@ -36,7 +36,8 @@ public static class EffectiveConfigOptionKindMap
|
||||
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqOptionTooltips.ServerLocalLabel] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Server] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Local] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.RevServer] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Address] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigFieldKind.Multi,
|
||||
@@ -160,7 +161,7 @@ public static class EffectiveConfigOptionKindMap
|
||||
/// <summary>
|
||||
/// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it
|
||||
/// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value,
|
||||
/// AddnHosts, server/local keys array, DhcpLeaseFilePath) remain explicit in the cache.
|
||||
/// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigParserBehaviorMap
|
||||
{
|
||||
@@ -171,6 +172,8 @@ public static class EffectiveConfigParserBehaviorMap
|
||||
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Server] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Local] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.RevServer] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Address] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigParserBehavior.Multi,
|
||||
|
||||
@@ -32,7 +32,8 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.ReadEthers,
|
||||
]),
|
||||
new SectionDef(SectionResolver, "Resolver / DNS", [
|
||||
DnsmasqOptionTooltips.ServerLocalLabel,
|
||||
DnsmasqConfKeys.Server,
|
||||
DnsmasqConfKeys.Local,
|
||||
DnsmasqConfKeys.RevServer,
|
||||
DnsmasqConfKeys.Address,
|
||||
DnsmasqConfKeys.ResolvFile,
|
||||
|
||||
@@ -244,7 +244,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths, pathToLines);
|
||||
var (hostsdirVal, hostsdirDir) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Hostsdir);
|
||||
var hostsdirPath = string.IsNullOrWhiteSpace(hostsdirVal) ? null : DnsmasqConfIncludeParser.ResolvePath(hostsdirVal?.Trim(), hostsdirDir);
|
||||
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var serverValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
|
||||
var localValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Local);
|
||||
var revServer = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.RevServer);
|
||||
var addressValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Address);
|
||||
var interfaces = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Interface);
|
||||
@@ -395,7 +396,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
|
||||
return new EffectiveDnsmasqConfig(
|
||||
noHosts, addnHosts, hostsdirPath,
|
||||
serverLocal, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
|
||||
serverValues, localValues, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
|
||||
dhcpMatchValues, dhcpBootValues, dhcpIgnoreValues, dhcpVendorclassValues, dhcpUserclassValues, raParamValues, slaacValues, pxeServiceValues, trustAnchorValues, resolvFiles,
|
||||
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
|
||||
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
|
||||
@@ -417,7 +418,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var (_, noHostsSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
|
||||
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath, managedHostsFilePath);
|
||||
var (_, hostsdirPathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Hostsdir, managedFilePath);
|
||||
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
|
||||
var serverWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Server, managedFilePath);
|
||||
var localWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Local, managedFilePath);
|
||||
var revServerWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.RevServer, managedFilePath);
|
||||
var addressWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Address, managedFilePath);
|
||||
var interfacesWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Interface, managedFilePath);
|
||||
@@ -537,7 +539,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
|
||||
return new EffectiveConfigSources(
|
||||
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
|
||||
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
serverWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
localWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
revServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
@@ -690,7 +693,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
|
||||
new(
|
||||
NoHosts: false, AddnHostsPaths: Array.Empty<string>(), HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
|
||||
ServerValues: Array.Empty<string>(), LocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(),
|
||||
DhcpMatchValues: Array.Empty<string>(), DhcpBootValues: Array.Empty<string>(), DhcpIgnoreValues: Array.Empty<string>(),
|
||||
@@ -721,7 +724,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
|
||||
new(
|
||||
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(), HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
|
||||
ServerValues: Array.Empty<ValueWithSource>(), LocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
|
||||
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
|
||||
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
|
||||
DhcpHostLines: Array.Empty<ValueWithSource>(),
|
||||
|
||||
@@ -194,11 +194,13 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
{
|
||||
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName);
|
||||
var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
|
||||
var confKey = c.OptionName;
|
||||
|
||||
bool MatchesOption(DnsmasqConfLine line)
|
||||
{
|
||||
if (line is not OtherLine o) return false;
|
||||
var raw = o.RawLine.Trim();
|
||||
return raw == c.OptionName || raw.StartsWith(c.OptionName + "=", StringComparison.Ordinal);
|
||||
return raw == confKey || raw.StartsWith(confKey + "=", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
|
||||
@@ -213,7 +215,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var lineText = string.IsNullOrEmpty(values[i]) ? c.OptionName : c.OptionName + "=" + values[i];
|
||||
var lineKey = confKey;
|
||||
var lineText = string.IsNullOrEmpty(values[i]) ? lineKey : lineKey + "=" + values[i];
|
||||
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
|
||||
maxLineNumber++;
|
||||
list.Insert(insertIdx + i, lineObj);
|
||||
@@ -229,11 +232,11 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
}
|
||||
string rawLine;
|
||||
if (isFlag)
|
||||
rawLine = c.OptionName;
|
||||
rawLine = confKey;
|
||||
else
|
||||
{
|
||||
var v = ToConfValue(c.NewValue);
|
||||
rawLine = string.IsNullOrEmpty(v) ? c.OptionName : c.OptionName + "=" + v;
|
||||
rawLine = string.IsNullOrEmpty(v) ? confKey : confKey + "=" + v;
|
||||
}
|
||||
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
|
||||
maxLineNumber++;
|
||||
@@ -271,6 +274,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
{
|
||||
if (value == null) return "";
|
||||
if (value is bool b) return b ? "1" : "0";
|
||||
if (value is IReadOnlyList<string> list)
|
||||
return string.Join(", ", list);
|
||||
return value.ToString() ?? "";
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@ public class ReloadService : IReloadService
|
||||
|
||||
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
|
||||
var command = !string.IsNullOrWhiteSpace(_options.RestartCommand)
|
||||
? _options.RestartCommand
|
||||
: _options.ReloadCommand;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
_logger.LogDebug("Reload command not configured");
|
||||
_logger.LogDebug("Reload/restart command not configured");
|
||||
return new ReloadResult(true, 0, null, "Reload command not configured");
|
||||
}
|
||||
|
||||
@@ -36,7 +40,7 @@ public class ReloadService : IReloadService
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _processRunner.RunAsync(_options.ReloadCommand, TimeSpan.FromSeconds(30), ct);
|
||||
var result = await _processRunner.RunAsync(command, TimeSpan.FromSeconds(30), ct);
|
||||
var stderr = result.Stderr;
|
||||
if (result.TimedOut)
|
||||
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds.";
|
||||
|
||||
+1
-1
@@ -21,5 +21,5 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
void TrackCommit(EffectiveConfigEditCommittedArgs args);
|
||||
void RevertChange(string sectionId, string optionName);
|
||||
|
||||
Task ApplyAsync(CancellationToken ct = default);
|
||||
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
/// <summary>Orchestrates effective-config save: backup, write, reload; and restore from backup. Keeps IDnsmasqConfigService focused on read/write mechanics.</summary>
|
||||
public interface IEffectiveConfigSaveService : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Creates a timestamped backup (if managed file exists), applies changes, then runs reload. Returns structured result for UI.</summary>
|
||||
Task<EffectiveConfigSaveResult> SaveAsync(
|
||||
IReadOnlyList<PendingEffectiveConfigChange> changes,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Overwrites the managed config with the backup file, then runs reload. Returns structured result for UI.</summary>
|
||||
Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default);
|
||||
}
|
||||
+26
-10
@@ -1,4 +1,3 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
@@ -6,12 +5,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
|
||||
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
{
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IEffectiveConfigSaveService _saveService;
|
||||
private readonly List<PendingEffectiveConfigChange> _pending = new();
|
||||
|
||||
public EffectiveConfigEditSession(IDnsmasqConfigService configService)
|
||||
public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService)
|
||||
{
|
||||
_configService = configService;
|
||||
_saveService = saveService;
|
||||
}
|
||||
|
||||
public bool IsEditMode { get; private set; }
|
||||
@@ -49,7 +48,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
c.SectionId == args.SectionId && c.OptionName == args.OptionName);
|
||||
if (existing != null)
|
||||
_pending.Remove(existing);
|
||||
if (!Equals(args.OldValue, args.NewValue))
|
||||
if (!ValuesEqual(args.OldValue, args.NewValue))
|
||||
_pending.Add(new PendingEffectiveConfigChange(
|
||||
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
|
||||
ActiveFieldKey = null;
|
||||
@@ -63,11 +62,28 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
_pending.Remove(existing);
|
||||
}
|
||||
|
||||
public async Task ApplyAsync(CancellationToken ct = default)
|
||||
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_pending.Count == 0) return;
|
||||
var changes = _pending.ToList();
|
||||
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
|
||||
ExitEditModeDiscard();
|
||||
if (_pending.Count == 0)
|
||||
return EffectiveConfigSaveResult.NoChanges();
|
||||
|
||||
var result = await _saveService.SaveAsync(_pending.ToList(), ct);
|
||||
if (result.Saved && result.Reloaded)
|
||||
ExitEditModeDiscard();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? oldValue, object? newValue)
|
||||
{
|
||||
var oldList = AsStringList(oldValue);
|
||||
var newList = AsStringList(newValue);
|
||||
if (oldList != null || newList != null)
|
||||
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
return Equals(oldValue, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
+138
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -38,7 +38,7 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste
|
||||
_logger.LogInformation(
|
||||
"Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " +
|
||||
"MainConfig={MainConfig}, managedConfig={ManagedConfig}, managedHosts={ManagedHosts}, systemHosts={SystemHosts}. " +
|
||||
"ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
|
||||
"RestartCommand={RestartCmd}, ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
|
||||
version,
|
||||
_env.EnvironmentName,
|
||||
RuntimeInformation.OSDescription.Trim(),
|
||||
@@ -47,6 +47,7 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste
|
||||
managedConfigPath,
|
||||
managedHostsPath,
|
||||
_dnsmasq.SystemHostsPath ?? "(not set)",
|
||||
string.IsNullOrWhiteSpace(_dnsmasq.RestartCommand) ? "(not set)" : _dnsmasq.RestartCommand,
|
||||
string.IsNullOrWhiteSpace(_dnsmasq.ReloadCommand) ? "(not set)" : _dnsmasq.ReloadCommand,
|
||||
string.IsNullOrWhiteSpace(_dnsmasq.StatusCommand) ? "(not set)" : _dnsmasq.StatusCommand);
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for options validators that are registered via assembly scanning.
|
||||
/// Implement this (instead of <see cref="IValidateOptions{TOptions}"/> directly) so only intended validators are registered.
|
||||
/// </summary>
|
||||
public interface IApplicationOptionsValidator<TOptions> : IValidateOptions<TOptions> where TOptions : class
|
||||
{
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions;
|
||||
|
||||
/// <summary>Scoped service to show toast notifications (success, error, warning, info). NotificationHost in MainLayout subscribes and renders toasts. Registered via assembly scanning (<see cref="IApplicationScopedService"/>).</summary>
|
||||
public interface INotificationService : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Raised when the list of toasts changes. NotificationHost subscribes and calls StateHasChanged.</summary>
|
||||
event EventHandler? NotificationsChanged;
|
||||
|
||||
/// <summary>Shows a success toast (e.g. "Config saved and dnsmasq reloaded."). Auto-dismisses after default duration.</summary>
|
||||
void ShowSuccess(string message);
|
||||
|
||||
/// <summary>Shows an error toast.</summary>
|
||||
void ShowError(string message);
|
||||
|
||||
/// <summary>Shows a warning toast.</summary>
|
||||
void ShowWarning(string message);
|
||||
|
||||
/// <summary>Shows an info toast.</summary>
|
||||
void ShowInfo(string message);
|
||||
|
||||
/// <summary>Returns a snapshot of current toasts for rendering. Caller should re-render when <see cref="NotificationsChanged"/> fires.</summary>
|
||||
IReadOnlyList<NotificationItem> GetSnapshot();
|
||||
|
||||
/// <summary>Removes the toast with the given id. Raises <see cref="NotificationsChanged"/>.</summary>
|
||||
void Dismiss(int id);
|
||||
}
|
||||
|
||||
/// <summary>Kind of notification for styling and icon.</summary>
|
||||
public enum NotificationKind
|
||||
{
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>Single toast item.</summary>
|
||||
/// <param name="Id">Unique id for dismiss.</param>
|
||||
/// <param name="Kind">Success, Error, Warning, Info.</param>
|
||||
/// <param name="Message">Text to show.</param>
|
||||
public record NotificationItem(int Id, NotificationKind Kind, string Message);
|
||||
@@ -0,0 +1,42 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications;
|
||||
|
||||
/// <inheritdoc cref="INotificationService" />
|
||||
public sealed class NotificationService : INotificationService
|
||||
{
|
||||
private readonly List<NotificationItem> _items = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public event EventHandler? NotificationsChanged;
|
||||
|
||||
public void ShowSuccess(string message) => Add(NotificationKind.Success, message);
|
||||
public void ShowError(string message) => Add(NotificationKind.Error, message);
|
||||
public void ShowWarning(string message) => Add(NotificationKind.Warning, message);
|
||||
public void ShowInfo(string message) => Add(NotificationKind.Info, message);
|
||||
|
||||
public IReadOnlyList<NotificationItem> GetSnapshot()
|
||||
{
|
||||
lock (_items)
|
||||
return _items.ToList();
|
||||
}
|
||||
|
||||
public void Dismiss(int id)
|
||||
{
|
||||
lock (_items)
|
||||
{
|
||||
var removed = _items.RemoveAll(i => i.Id == id);
|
||||
if (removed > 0)
|
||||
NotificationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void Add(NotificationKind kind, string message)
|
||||
{
|
||||
lock (_items)
|
||||
{
|
||||
_items.Add(new NotificationItem(_nextId++, kind, message ?? ""));
|
||||
NotificationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
|
||||
|
||||
@@ -6,8 +7,9 @@ namespace DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
|
||||
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
|
||||
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
|
||||
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
|
||||
/// Registered via assembly scanning (<see cref="IApplicationScopedService"/>).
|
||||
/// </summary>
|
||||
public interface ISettingsModalService
|
||||
public interface ISettingsModalService : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
|
||||
event Action<SettingsModalContext, string>? OpenRequested;
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service that checks GitHub for a newer release. Runs on a configurable interval (hosted service)
|
||||
/// and can be triggered manually. UI subscribes to <see cref="ResultChanged"/> to refresh when state updates.
|
||||
/// Registered via assembly scanning (<see cref="IApplicationSingleton"/>).
|
||||
/// </summary>
|
||||
public interface IUpdateCheckService
|
||||
public interface IUpdateCheckService : IApplicationSingleton
|
||||
{
|
||||
/// <summary>Raised when a check completes (background or manual). Subscribe and call StateHasChanged from the UI.</summary>
|
||||
event EventHandler? ResultChanged;
|
||||
|
||||
@@ -29,7 +29,10 @@ public class DnsmasqOptions
|
||||
/// <summary>Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in the UI as read-only so users can see those entries. The app never writes to it; editing is via the managed hosts file only. When unset, the system hosts row is not shown.</summary>
|
||||
public string? SystemHostsPath { get; set; }
|
||||
|
||||
/// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary>
|
||||
/// <summary>Command to run to apply config changes (e.g. "systemctl restart dnsmasq"). When set, this is used instead of <see cref="ReloadCommand"/> so that .conf file changes take effect (dnsmasq does not re-read config on SIGHUP). If unset, <see cref="ReloadCommand"/> is used.</summary>
|
||||
public string? RestartCommand { get; set; }
|
||||
|
||||
/// <summary>Command to run after config changes when <see cref="RestartCommand"/> is not set (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). SIGHUP only re-reads hosts/addn-hosts etc., not .conf files. Runs in the same environment as the app.</summary>
|
||||
public string? ReloadCommand { get; set; }
|
||||
|
||||
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
@@ -5,8 +6,9 @@ namespace DnsmasqWebUI.Models.Config;
|
||||
/// <summary>
|
||||
/// Validates required dnsmasq options at startup. If config is missing or default paths don't point at existing files,
|
||||
/// the application exits with a detailed error instead of failing later at runtime.
|
||||
/// Registered via assembly scanning (<see cref="IApplicationOptionsValidator{TOptions}"/>).
|
||||
/// </summary>
|
||||
public sealed class DnsmasqOptionsValidator : IValidateOptions<DnsmasqOptions>
|
||||
public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<DnsmasqOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, DnsmasqOptions options)
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class ConfigOptionHelpRequestEventArgs
|
||||
/// <summary>Option-help file key (e.g. "server", "no-hosts").</summary>
|
||||
public string HelpKey { get; init; } = "";
|
||||
|
||||
/// <summary>Display label for the modal title (e.g. "server / local").</summary>
|
||||
/// <summary>Display label for the modal title (e.g. "server" or "listen-address").</summary>
|
||||
public string OptionLabel { get; init; } = "";
|
||||
|
||||
/// <summary>Id of the label element that triggered the request; used to position the modal and match leave events.</summary>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>Result of restoring the managed config from a backup file, then reloading dnsmasq.</summary>
|
||||
/// <param name="Restored">True when the backup was copied over the managed file.</param>
|
||||
/// <param name="Reloaded">True when dnsmasq reload succeeded after restore.</param>
|
||||
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="ReloadStdErr">Standard error from reload command.</param>
|
||||
/// <param name="UserMessage">Short message for the user.</param>
|
||||
public record EffectiveConfigRestoreResult(
|
||||
bool Restored,
|
||||
bool Reloaded,
|
||||
int ReloadExitCode,
|
||||
string? ReloadStdErr,
|
||||
string? UserMessage);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>Result of the effective-config save flow (backup, write, reload). Used by UI to drive state and show restore/continue options.</summary>
|
||||
/// <param name="BackupCreated">True when a backup file was created before write.</param>
|
||||
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
|
||||
/// <param name="Saved">True when the managed config was written successfully.</param>
|
||||
/// <param name="Reloaded">True when dnsmasq reload succeeded after write.</param>
|
||||
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="ReloadStdOut">Standard output from reload command.</param>
|
||||
/// <param name="ReloadStdErr">Standard error from reload command.</param>
|
||||
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, reload_failed, or null when success.</param>
|
||||
/// <param name="UserMessage">Short message for the user.</param>
|
||||
public record EffectiveConfigSaveResult(
|
||||
bool BackupCreated,
|
||||
string? BackupPath,
|
||||
bool Saved,
|
||||
bool Reloaded,
|
||||
int ReloadExitCode,
|
||||
string? ReloadStdOut,
|
||||
string? ReloadStdErr,
|
||||
string? ErrorCode,
|
||||
string? UserMessage)
|
||||
{
|
||||
/// <summary>Result when there are no pending changes to apply.</summary>
|
||||
public static EffectiveConfigSaveResult NoChanges() =>
|
||||
new(false, null, false, false, -1, null, null, "no_changes", "No pending changes.");
|
||||
}
|
||||
@@ -16,7 +16,8 @@ public record EffectiveConfigSources(
|
||||
ConfigValueSource? HostsdirPath,
|
||||
|
||||
// --- Multi-value (ARG_DUP): source per value ---
|
||||
IReadOnlyList<ValueWithSource> ServerLocalValues,
|
||||
IReadOnlyList<ValueWithSource> ServerValues,
|
||||
IReadOnlyList<ValueWithSource> LocalValues,
|
||||
IReadOnlyList<ValueWithSource> RevServerValues,
|
||||
IReadOnlyList<ValueWithSource> AddressValues,
|
||||
IReadOnlyList<ValueWithSource> Interfaces,
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
///
|
||||
/// Single-value (ARG_ONE): last occurrence wins; can be overridden by writing to the managed file.
|
||||
/// Flags: set if any file contains the option (key-only line).
|
||||
/// Multi-value (ARG_DUP): addn-hosts, server/local, address, interface, listen-address, except-interface,
|
||||
/// Multi-value (ARG_DUP): addn-hosts, server, local, address, interface, listen-address, except-interface,
|
||||
/// dhcp-range, dhcp-host, dhcp-option, resolv-file — all values in order; each has source in EffectiveConfigSources.
|
||||
/// </summary>
|
||||
public record EffectiveDnsmasqConfig(
|
||||
@@ -20,7 +20,8 @@ public record EffectiveDnsmasqConfig(
|
||||
string? HostsdirPath,
|
||||
|
||||
// --- Multi-value (ARG_DUP): all occurrences in order ---
|
||||
IReadOnlyList<string> ServerLocalValues,
|
||||
IReadOnlyList<string> ServerValues,
|
||||
IReadOnlyList<string> LocalValues,
|
||||
IReadOnlyList<string> RevServerValues,
|
||||
IReadOnlyList<string> AddressValues,
|
||||
IReadOnlyList<string> Interfaces,
|
||||
|
||||
@@ -4,10 +4,6 @@ using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Extensions.DependencyInjection;
|
||||
using DnsmasqWebUI.Extensions.Hosting;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||
using DnsmasqWebUI.Infrastructure.Services.UI.Settings;
|
||||
using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Updates;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD.
|
||||
@@ -36,12 +32,9 @@ builder.Services.AddOptions<UpdateCheckOptions>()
|
||||
builder.Services.AddOptions<DnsmasqOptions>()
|
||||
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
|
||||
|
||||
// ---- Application services ----
|
||||
// ---- Application services (assembly-scanned: IApplicationScopedService, IApplicationSingleton, IValidateOptions<>, etc.) ----
|
||||
builder.Services.AddApplicationServices();
|
||||
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
|
||||
builder.Services.AddSingleton<IUpdateCheckService, UpdateCheckService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddDnsmasqApiHttpClients();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"ManagedFileName": "zz-dnsmasq-webui.conf",
|
||||
"ManagedHostsFileName": "zz-dnsmasq-webui.hosts",
|
||||
"SystemHostsPath": "/etc/hosts",
|
||||
"RestartCommand": "systemctl restart dnsmasq",
|
||||
"ReloadCommand": "systemctl reload dnsmasq",
|
||||
"StatusCommand": "systemctl is-active dnsmasq",
|
||||
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# Minimal dnsmasq config for the test harness (use with --minimal-conf).
|
||||
# Single file, no conf-dir; app adds conf-file= for managed config on startup.
|
||||
# Use for testing reload/restart failure flows (fewer readonly options to edit).
|
||||
port=53
|
||||
|
||||
log-facility=/data/dnsmasq.log
|
||||
log-queries
|
||||
Reference in New Issue
Block a user