mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Effective config: unified save pipeline, descriptors, serialization, WriteFailed rollback
- EffectiveConfigFieldRef as value type; PendingDnsmasqChange/PendingOptionChange/PendingManagedHostsChange - Multi-file save plan (DnsmasqSaveWritePlan, DnsmasqManagedBackup) and backup/restore in save flow - Hosts integrated into effective-config save; Hosts.razor uses shared save modal - IEffectiveConfigDescriptorProvider + IEffectiveConfigFieldBuilder (DI); descriptors from EffectiveConfigSections - IEffectiveConfigPageEditor, shared descriptor resolution; addn-hosts/dhcp-host descriptor fix - DnsmasqConfText, IEffectiveConfigDirectiveSerializer; DnsmasqConfigService uses serializer for writes - IStructuredOptionValueHandler/Registry, DhcpHostOptionValueHandler; Dhcp.razor index-based DeleteRow fix - SaveService: roll back partial writes on failure; SaveModal: WriteFailed shows backups and Restore button
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
|
||||||
|
|
||||||
|
public class PendingManagedHostsChangeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HasChanges_WhenEntriesDiffer_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
|
||||||
|
var newEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.2", Names = new List<string> { "host1" } } };
|
||||||
|
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||||
|
Assert.True(pending.HasChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasChanges_WhenEntriesSame_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var entries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
|
||||||
|
var clone = entries.Select(e => new HostEntry { Id = e.Id, Address = e.Address, Names = new List<string>(e.Names) }).ToList();
|
||||||
|
var pending = new PendingManagedHostsChange(entries, clone, "/etc/hosts");
|
||||||
|
Assert.False(pending.HasChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasChanges_WhenCountDiffers_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() } };
|
||||||
|
var newEntries = new List<HostEntry>
|
||||||
|
{
|
||||||
|
new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() },
|
||||||
|
new() { Id = "2", Address = "192.168.1.2", Names = new List<string>() }
|
||||||
|
};
|
||||||
|
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||||
|
Assert.True(pending.HasChanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization;
|
||||||
|
|
||||||
|
public class DnsmasqConfTextTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DirectivePrefix_ReturnsOptionEquals()
|
||||||
|
{
|
||||||
|
Assert.Equal("dhcp-host=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost));
|
||||||
|
Assert.Equal("server=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.Server));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectiveLine_WhenValueNullOrEmpty_ReturnsOptionOnly()
|
||||||
|
{
|
||||||
|
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", null));
|
||||||
|
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectiveLine_WhenValueProvided_ReturnsOptionEqualsValue()
|
||||||
|
{
|
||||||
|
Assert.Equal("dhcp-host=11:22:33:44:55:66,192.168.1.1", DnsmasqConfText.DirectiveLine("dhcp-host", "11:22:33:44:55:66,192.168.1.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripDirectivePrefix_WhenLineStartsWithPrefix_ReturnsRest()
|
||||||
|
{
|
||||||
|
var line = "dhcp-host=mac,ip";
|
||||||
|
Assert.Equal("mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripDirectivePrefix_WhenLineDoesNotStartWithPrefix_ReturnsFullLine()
|
||||||
|
{
|
||||||
|
var line = "other=value";
|
||||||
|
Assert.Equal("other=value", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripDirectivePrefix_WhenLineIsCommented_PreservesLeadingHashInResult()
|
||||||
|
{
|
||||||
|
Assert.Equal("#mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=mac,ip"));
|
||||||
|
Assert.Equal("##mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=mac,ip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasDirectivePrefix_MatchesOptionAndCommentedForms()
|
||||||
|
{
|
||||||
|
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "dhcp-host=value"));
|
||||||
|
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=value"));
|
||||||
|
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=value"));
|
||||||
|
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "server=8.8.8.8"));
|
||||||
|
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "other"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization;
|
||||||
|
|
||||||
|
public class EffectiveConfigDirectiveSerializerTests
|
||||||
|
{
|
||||||
|
private readonly EffectiveConfigDirectiveSerializer _sut = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_Flag_True_WritesBareKey()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, true);
|
||||||
|
Assert.Equal(DnsmasqConfKeys.Conntrack, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_Flag_False_WritesEmpty()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, false);
|
||||||
|
Assert.Equal("", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_SingleValue_WritesKeyEqualsValue()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.Port, 5353);
|
||||||
|
Assert.Equal("port=5353", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_KeyOnlyOrValue_Empty_WritesBareKey()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.UseStaleCache, "");
|
||||||
|
Assert.Equal(DnsmasqConfKeys.UseStaleCache, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeMulti_MultiValue_WritesEachDirective()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeMulti(DnsmasqConfKeys.Server, ["1.1.1.1", "8.8.8.8"]);
|
||||||
|
Assert.Equal(["server=1.1.1.1", "server=8.8.8.8"], result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeMulti_MultiKeyOnlyOrValue_WritesMixedForms()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeMulti(DnsmasqConfKeys.Leasequery, ["", "net:tag"]);
|
||||||
|
Assert.Equal(["leasequery", "leasequery=net:tag"], result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_InversePair_Enabled_WritesPositiveKey()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Enabled);
|
||||||
|
Assert.Equal(DnsmasqConfKeys.Do0x20Encode, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSingle_InversePair_Disabled_WritesNegativeKey()
|
||||||
|
{
|
||||||
|
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled);
|
||||||
|
Assert.Equal(DnsmasqConfKeys.No0x20Encode, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
|
||||||
|
|
||||||
|
public class DhcpHostOptionValueHandlerTests
|
||||||
|
{
|
||||||
|
private readonly DhcpHostOptionValueHandler _sut = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OptionName_EqualsDnsmasqConfKeysDhcpHost()
|
||||||
|
{
|
||||||
|
Assert.Equal(DnsmasqConfKeys.DhcpHost, _sut.OptionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeValue_EmitsValueOnlyForm()
|
||||||
|
{
|
||||||
|
var entry = new DhcpHostEntry
|
||||||
|
{
|
||||||
|
MacAddresses = new List<string> { "11:22:33:44:55:66" },
|
||||||
|
Address = "192.168.1.10"
|
||||||
|
};
|
||||||
|
var value = _sut.SerializeValue(entry);
|
||||||
|
Assert.Equal("11:22:33:44:55:66, 192.168.1.10", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeValue_IgnoreEntry_EmitsIgnoreToken()
|
||||||
|
{
|
||||||
|
var entry = new DhcpHostEntry
|
||||||
|
{
|
||||||
|
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||||
|
Ignore = true
|
||||||
|
};
|
||||||
|
var value = _sut.SerializeValue(entry);
|
||||||
|
Assert.Contains("ignore", value);
|
||||||
|
Assert.Contains("aa:bb:cc:dd:ee:ff", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeValue_EmptyEntry_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var entry = new DhcpHostEntry();
|
||||||
|
var value = _sut.SerializeValue(entry);
|
||||||
|
Assert.Equal("", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseValue_RoundTripsStructuredFields()
|
||||||
|
{
|
||||||
|
var input = new DhcpHostEntry
|
||||||
|
{
|
||||||
|
MacAddresses = new List<string> { "11:22:33:44:55:66" },
|
||||||
|
Address = "192.168.1.10",
|
||||||
|
Extra = new List<string> { "id:01:02:03", "set:test" },
|
||||||
|
Comment = "note",
|
||||||
|
IsComment = true
|
||||||
|
};
|
||||||
|
var value = _sut.SerializeValue(input);
|
||||||
|
var ok = _sut.TryParseValue(value, 1, out var output);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.NotNull(output);
|
||||||
|
Assert.True(output!.IsComment);
|
||||||
|
Assert.Contains("id:01:02:03", output.Extra!);
|
||||||
|
Assert.Equal("note", output.Comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseValue_IsComment_RoundTrips()
|
||||||
|
{
|
||||||
|
var entry = new DhcpHostEntry
|
||||||
|
{
|
||||||
|
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||||
|
Address = "192.168.1.1",
|
||||||
|
IsComment = true
|
||||||
|
};
|
||||||
|
var value = _sut.SerializeValue(entry);
|
||||||
|
Assert.StartsWith("#", value);
|
||||||
|
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.NotNull(roundTripped);
|
||||||
|
Assert.True(roundTripped!.IsComment);
|
||||||
|
Assert.Single(roundTripped.MacAddresses!);
|
||||||
|
Assert.Equal("aa:bb:cc:dd:ee:ff", roundTripped.MacAddresses[0]);
|
||||||
|
Assert.Equal("192.168.1.1", roundTripped.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseValue_Comment_EmitsTrailingComment_AndRoundTrips()
|
||||||
|
{
|
||||||
|
var entry = new DhcpHostEntry
|
||||||
|
{
|
||||||
|
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||||
|
Address = "192.168.1.1",
|
||||||
|
Comment = " my note "
|
||||||
|
};
|
||||||
|
var value = _sut.SerializeValue(entry);
|
||||||
|
Assert.Contains(" # ", value);
|
||||||
|
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.NotNull(roundTripped);
|
||||||
|
Assert.Equal("my note", roundTripped!.Comment?.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
|
||||||
|
|
||||||
|
/// <summary>Ensures structured handlers use DnsmasqConfKeys and registry has no duplicate option names.</summary>
|
||||||
|
public class StructuredOptionValueHandlerRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DhcpHostOptionValueHandler_OptionName_EqualsDnsmasqConfKeysDhcpHost()
|
||||||
|
{
|
||||||
|
var handler = new DhcpHostOptionValueHandler();
|
||||||
|
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DhcpHostOptionValueHandler_OptionName_IsFromDnsmasqConfKeys()
|
||||||
|
{
|
||||||
|
var handler = new DhcpHostOptionValueHandler();
|
||||||
|
var validKeys = GetDnsmasqConfKeysValues();
|
||||||
|
Assert.Contains(handler.OptionName, validKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_WithDhcpHostHandler_ReturnsHandlerForDhcpHost()
|
||||||
|
{
|
||||||
|
var handler = new DhcpHostOptionValueHandler();
|
||||||
|
var registry = new StructuredOptionValueHandlerRegistry(new[] { handler });
|
||||||
|
var resolved = registry.Get<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
|
||||||
|
Assert.NotNull(resolved);
|
||||||
|
Assert.Equal(DnsmasqConfKeys.DhcpHost, resolved.OptionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_IsStructured_WhenHandlerExists_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||||
|
Assert.True(registry.IsStructured(DnsmasqConfKeys.DhcpHost));
|
||||||
|
Assert.False(registry.IsStructured(DnsmasqConfKeys.Server));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_GetRequired_WhenHandlerExists_ReturnsTypedHandler()
|
||||||
|
{
|
||||||
|
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||||
|
var handler = registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
|
||||||
|
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_GetRequired_WhenOptionIsNotStructured_Throws()
|
||||||
|
{
|
||||||
|
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.Server));
|
||||||
|
Assert.Contains("not declared as a structured option", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_DuplicateOptionNames_Throws()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new StructuredOptionValueHandlerRegistry([
|
||||||
|
new DhcpHostOptionValueHandler(),
|
||||||
|
new DuplicateDhcpHostHandler()
|
||||||
|
]));
|
||||||
|
Assert.Contains("Duplicate structured option handlers", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Semantics_DhcpHost_HasStructuredValueTypeMatchingHandler()
|
||||||
|
{
|
||||||
|
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(DnsmasqConfKeys.DhcpHost);
|
||||||
|
Assert.NotNull(structuredType);
|
||||||
|
Assert.Equal(typeof(DhcpHostEntry), structuredType);
|
||||||
|
var handler = new DhcpHostOptionValueHandler();
|
||||||
|
Assert.Equal(structuredType, handler.ValueType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_HandlerWithoutStructuredMetadata_Throws()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new StructuredOptionValueHandlerRegistry([new InvalidServerHandler()]));
|
||||||
|
Assert.Contains("no StructuredValueType", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> GetDnsmasqConfKeysValues()
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (field.FieldType == typeof(string) && field.GetValue(null) is string value)
|
||||||
|
set.Add(value);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DuplicateDhcpHostHandler : IStructuredOptionValueHandler<DhcpHostEntry>
|
||||||
|
{
|
||||||
|
public string OptionName => DnsmasqConfKeys.DhcpHost;
|
||||||
|
public Type ValueType => typeof(DhcpHostEntry);
|
||||||
|
public string SerializeLine(DhcpHostEntry value) => "";
|
||||||
|
public string SerializeValue(DhcpHostEntry value) => "";
|
||||||
|
public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InvalidServerHandler : IStructuredOptionValueHandler<string>
|
||||||
|
{
|
||||||
|
public string OptionName => DnsmasqConfKeys.Server;
|
||||||
|
public Type ValueType => typeof(string);
|
||||||
|
public string SerializeLine(string value) => "";
|
||||||
|
public string SerializeValue(string value) => value;
|
||||||
|
public bool TryParseValue(string text, int lineNumber, out string? value)
|
||||||
|
{
|
||||||
|
value = text;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -194,6 +194,43 @@ public class DnsmasqConfigServiceApplyChangesTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyEffectiveConfigChangesAsync_SingleValueNull_RemovesExistingLine()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||||
|
var managedName = "zz-managed.conf";
|
||||||
|
var managedPath = Path.Combine(dir, managedName);
|
||||||
|
ConfigSetCache? cache = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(mainPath, "port=53\n");
|
||||||
|
File.WriteAllText(managedPath, "port=54\n");
|
||||||
|
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||||
|
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||||
|
cache.Invalidate();
|
||||||
|
var setService = new DnsmasqConfigSetService(cache);
|
||||||
|
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||||
|
|
||||||
|
var changes = new List<PendingEffectiveConfigChange>
|
||||||
|
{
|
||||||
|
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, "54", null, null)
|
||||||
|
};
|
||||||
|
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(managedPath);
|
||||||
|
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||||
|
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.Port || l.StartsWith($"{DnsmasqConfKeys.Port}=", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache?.Dispose();
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
|
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
|
||||||
{
|
{
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
public class EffectiveConfigEditSessionChangedEventTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TrackCommit_RaisesChanged()
|
||||||
|
{
|
||||||
|
var saveService = new StubSaveService();
|
||||||
|
var session = new EffectiveConfigEditSession(saveService);
|
||||||
|
session.EnterEditMode();
|
||||||
|
var raised = false;
|
||||||
|
session.Changed += () => raised = true;
|
||||||
|
session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnterEditMode_RaisesChanged()
|
||||||
|
{
|
||||||
|
var saveService = new StubSaveService();
|
||||||
|
var session = new EffectiveConfigEditSession(saveService);
|
||||||
|
var raised = false;
|
||||||
|
session.Changed += () => raised = true;
|
||||||
|
session.EnterEditMode();
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RevertChange_RaisesChanged()
|
||||||
|
{
|
||||||
|
var saveService = new StubSaveService();
|
||||||
|
var session = new EffectiveConfigEditSession(saveService);
|
||||||
|
session.EnterEditMode();
|
||||||
|
session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
|
||||||
|
var raised = false;
|
||||||
|
session.Changed += () => raised = true;
|
||||||
|
session.RevertChange("resolver", "port");
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrackManagedHostsChange_StoresChange_AndRevertManagedHostsChange_RemovesIt()
|
||||||
|
{
|
||||||
|
var saveService = new StubSaveService();
|
||||||
|
var session = new EffectiveConfigEditSession(saveService);
|
||||||
|
session.EnterEditMode();
|
||||||
|
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
|
||||||
|
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a", "b" } } };
|
||||||
|
var change = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||||
|
session.TrackManagedHostsChange(change);
|
||||||
|
Assert.Single(session.PendingChanges.OfType<PendingManagedHostsChange>());
|
||||||
|
session.RevertManagedHostsChange();
|
||||||
|
Assert.Empty(session.PendingChanges.OfType<PendingManagedHostsChange>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubSaveService : IEffectiveConfigSaveService
|
||||||
|
{
|
||||||
|
public Task<EffectiveConfigSaveResult> SaveAsync(IReadOnlyList<PendingDnsmasqChange> changes, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(EffectiveConfigSaveResult.NoChanges());
|
||||||
|
public Task<EffectiveConfigRestoreResult> RestoreAsync(IReadOnlyList<DnsmasqManagedBackup> backups, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new EffectiveConfigRestoreResult(false, false, -1, null, "Stub"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-2
@@ -1,7 +1,9 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
@@ -43,15 +45,16 @@ public class EffectiveConfigSaveServiceCapabilityTests
|
|||||||
setService,
|
setService,
|
||||||
configService,
|
configService,
|
||||||
cache,
|
cache,
|
||||||
|
new StubHostsFileService(),
|
||||||
new UnexpectedValidationService(),
|
new UnexpectedValidationService(),
|
||||||
semanticValidationService,
|
semanticValidationService,
|
||||||
new UnexpectedReloadService(),
|
new UnexpectedReloadService(),
|
||||||
versionService,
|
versionService,
|
||||||
NullLogger<EffectiveConfigSaveService>.Instance);
|
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||||
|
|
||||||
var changes = new List<PendingEffectiveConfigChange>
|
var changes = new List<PendingDnsmasqChange>
|
||||||
{
|
{
|
||||||
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
|
new PendingOptionChange(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await saveService.SaveAsync(changes);
|
var result = await saveService.SaveAsync(changes);
|
||||||
@@ -107,4 +110,10 @@ public class EffectiveConfigSaveServiceCapabilityTests
|
|||||||
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
|
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
|
||||||
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
|
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class StubHostsFileService : IHostsFileService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||||
|
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Config;
|
||||||
|
using DnsmasqWebUI.Models.Contracts;
|
||||||
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for multi-file backup/restore and managed-hosts-only save.
|
||||||
|
/// </summary>
|
||||||
|
public class EffectiveConfigSaveServiceMultiBackupTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_WhenOnlyManagedHostsChange_BacksUpManagedHostsFile_WritesHosts_ValidatesAndReloads()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hosts-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||||
|
ConfigSetCache? cache = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(mainPath, "port=53\n");
|
||||||
|
var options = Options.Create(new DnsmasqOptions
|
||||||
|
{
|
||||||
|
MainConfigPath = mainPath,
|
||||||
|
ManagedFileName = "zz-managed.conf",
|
||||||
|
ManagedHostsFileName = "zz-dnsmasq-webui.hosts"
|
||||||
|
});
|
||||||
|
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||||
|
var setService = new DnsmasqConfigSetService(cache);
|
||||||
|
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||||
|
|
||||||
|
var set = await setService.GetConfigSetAsync();
|
||||||
|
var managedHostsPath = set.ManagedHostsFilePath!;
|
||||||
|
File.WriteAllText(managedHostsPath, "192.168.1.1 host1\n");
|
||||||
|
|
||||||
|
var writtenEntries = new List<IReadOnlyList<HostEntry>>();
|
||||||
|
var capturingHostsService = new CapturingHostsFileService(writtenEntries);
|
||||||
|
|
||||||
|
var versionService = new StubVersionService(CreateSupportedVersionInfo());
|
||||||
|
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
|
||||||
|
var saveService = new EffectiveConfigSaveService(
|
||||||
|
setService,
|
||||||
|
configService,
|
||||||
|
cache,
|
||||||
|
capturingHostsService,
|
||||||
|
new StubValidationService(success: true),
|
||||||
|
semanticValidationService,
|
||||||
|
new StubReloadService(success: true),
|
||||||
|
versionService,
|
||||||
|
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||||
|
|
||||||
|
var oldEntries = new List<HostEntry> { new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" } };
|
||||||
|
var newEntries = new List<HostEntry>
|
||||||
|
{
|
||||||
|
new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" },
|
||||||
|
new() { Address = "192.168.1.2", Names = new List<string> { "host2" }, Id = "192.168.1.2|host2" }
|
||||||
|
};
|
||||||
|
var changes = new List<PendingDnsmasqChange>
|
||||||
|
{
|
||||||
|
new PendingManagedHostsChange(oldEntries, newEntries, managedHostsPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await saveService.SaveAsync(changes);
|
||||||
|
|
||||||
|
Assert.True(result.Saved);
|
||||||
|
Assert.True(result.Validated);
|
||||||
|
Assert.True(result.Restarted);
|
||||||
|
Assert.Null(result.ErrorCode);
|
||||||
|
Assert.Single(result.Backups);
|
||||||
|
Assert.Equal(DnsmasqManagedTargetKind.ManagedHosts, result.Backups[0].Kind);
|
||||||
|
Assert.Equal(managedHostsPath, result.Backups[0].TargetPath);
|
||||||
|
Assert.True(File.Exists(result.Backups[0].BackupPath));
|
||||||
|
Assert.Single(writtenEntries);
|
||||||
|
Assert.Equal(2, writtenEntries[0].Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache?.Dispose();
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreAsync_WhenBackupsContainTwoTargets_RestoresBothThenReloads()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-restore-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||||
|
File.WriteAllText(mainPath, "port=53\n");
|
||||||
|
var target1 = Path.Combine(dir, "target1.conf");
|
||||||
|
var target2 = Path.Combine(dir, "target2.hosts");
|
||||||
|
var backup1 = Path.Combine(dir, "backup1.bak");
|
||||||
|
var backup2 = Path.Combine(dir, "backup2.bak");
|
||||||
|
File.WriteAllText(target1, "original1");
|
||||||
|
File.WriteAllText(target2, "original2");
|
||||||
|
File.WriteAllText(backup1, "restored1");
|
||||||
|
File.WriteAllText(backup2, "restored2");
|
||||||
|
|
||||||
|
var backups = new List<DnsmasqManagedBackup>
|
||||||
|
{
|
||||||
|
new(DnsmasqManagedTargetKind.ManagedConfig, target1, backup1),
|
||||||
|
new(DnsmasqManagedTargetKind.ManagedHosts, target2, backup2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "x.conf" });
|
||||||
|
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||||
|
var setService = new DnsmasqConfigSetService(cache);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var saveService = new EffectiveConfigSaveService(
|
||||||
|
setService,
|
||||||
|
new StubConfigService(),
|
||||||
|
cache,
|
||||||
|
new StubHostsFileService(),
|
||||||
|
new StubValidationService(success: true),
|
||||||
|
new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>())),
|
||||||
|
new StubReloadService(success: true),
|
||||||
|
new StubVersionService(CreateSupportedVersionInfo()),
|
||||||
|
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||||
|
|
||||||
|
var result = await saveService.RestoreAsync(backups);
|
||||||
|
|
||||||
|
Assert.True(result.Restored);
|
||||||
|
Assert.True(result.Restarted);
|
||||||
|
Assert.Equal("restored1", File.ReadAllText(target1));
|
||||||
|
Assert.Equal("restored2", File.ReadAllText(target2));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache.Dispose();
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DnsmasqVersionInfo CreateSupportedVersionInfo()
|
||||||
|
{
|
||||||
|
var capabilities = new DnsmasqCompileCapabilities(
|
||||||
|
Dhcp: true, Tftp: true, Dnssec: true, Dbus: false,
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
|
||||||
|
return new DnsmasqVersionInfo(
|
||||||
|
new Version(2, 91), new Version(2, 91),
|
||||||
|
ProbeSucceeded: true, IsSupported: true, "dnsmasq --version", null, capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingHostsFileService : IHostsFileService
|
||||||
|
{
|
||||||
|
private readonly List<IReadOnlyList<HostEntry>> _written;
|
||||||
|
|
||||||
|
public CapturingHostsFileService(List<IReadOnlyList<HostEntry>> written) => _written = written;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||||
|
|
||||||
|
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_written.Add(entries);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubVersionService : IDnsmasqVersionService
|
||||||
|
{
|
||||||
|
private readonly DnsmasqVersionInfo _info;
|
||||||
|
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
|
||||||
|
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) => Task.FromResult(_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubValidationService : IConfigValidationService
|
||||||
|
{
|
||||||
|
private readonly bool _success;
|
||||||
|
public StubValidationService(bool success) => _success = success;
|
||||||
|
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new ConfigValidationResult(_success, true, _success ? 0 : 1, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubReloadService : IReloadService
|
||||||
|
{
|
||||||
|
private readonly bool _success;
|
||||||
|
public StubReloadService(bool success) => _success = success;
|
||||||
|
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new ReloadResult(_success, _success ? 0 : 1, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHostsFileService : IHostsFileService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||||
|
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubConfigService : IDnsmasqConfigService
|
||||||
|
{
|
||||||
|
public Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<DhcpHostEntry>>(Array.Empty<DhcpHostEntry>());
|
||||||
|
public Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default) => Task.FromResult(new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), ""));
|
||||||
|
public Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -16,9 +16,9 @@ public class EffectiveConfigSemanticsWiringTests
|
|||||||
public void Registry_WiresSemanticValidator_ForDnsRr()
|
public void Registry_WiresSemanticValidator_ForDnsRr()
|
||||||
{
|
{
|
||||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
|
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
|
||||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
|
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
|
||||||
Assert.NotNull(factory);
|
Assert.NotNull(factory);
|
||||||
var descriptor = factory!(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
|
var descriptor = factory!(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
|
||||||
Assert.NotNull(descriptor.Validator);
|
Assert.NotNull(descriptor.Validator);
|
||||||
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
|
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
|
||||||
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
|
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
|
||||||
@@ -28,11 +28,11 @@ public class EffectiveConfigSemanticsWiringTests
|
|||||||
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
||||||
{
|
{
|
||||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
|
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
|
||||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
||||||
Assert.NotNull(factory);
|
Assert.NotNull(factory);
|
||||||
|
|
||||||
var descriptor = factory!(
|
var descriptor = factory!(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
DnsmasqConfKeys.Leasequery,
|
DnsmasqConfKeys.Leasequery,
|
||||||
status: null,
|
status: null,
|
||||||
getItems: _ => null);
|
getItems: _ => null);
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ public abstract class EffectiveConfigValueDisplayBase : ComponentBase
|
|||||||
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
|
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
|
||||||
protected object? GetEffectiveDisplayValue()
|
protected object? GetEffectiveDisplayValue()
|
||||||
{
|
{
|
||||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
return pending != null ? pending.NewValue : Descriptor.GetValue();
|
return pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
@inject IEffectiveConfigDescriptorProvider DescriptorProvider
|
||||||
|
|
||||||
<div class="ec-sections small">
|
<div class="ec-sections small">
|
||||||
@{
|
@{
|
||||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
|
var descriptorsBySection = Status != null ? DescriptorProvider.GetBySection(Status, views) : new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
|
||||||
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
|
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
|
||||||
var term = SearchTerm?.Trim() ?? "";
|
var term = SearchTerm?.Trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -5,6 +5,7 @@
|
|||||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||||
@inject IEffectiveConfigEditSession Session
|
@inject IEffectiveConfigEditSession Session
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
@if (Status != null)
|
@if (Status != null)
|
||||||
{
|
{
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
Session.Changed += HandleSessionChanged;
|
||||||
_uiContext = new EffectiveConfigUiContext
|
_uiContext = new EffectiveConfigUiContext
|
||||||
{
|
{
|
||||||
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
||||||
@@ -74,6 +76,13 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleSessionChanged() => _ = InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session.Changed -= HandleSessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnSearchTermChanged(string value)
|
private async Task OnSearchTermChanged(string value)
|
||||||
{
|
{
|
||||||
_searchTerm = value ?? "";
|
_searchTerm = value ?? "";
|
||||||
@@ -191,7 +200,8 @@
|
|||||||
|
|
||||||
private void RunCrossOptionEvaluator()
|
private void RunCrossOptionEvaluator()
|
||||||
{
|
{
|
||||||
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
|
var optionChanges = Session.PendingChanges.OfType<PendingOptionChange>().ToList();
|
||||||
|
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, optionChanges);
|
||||||
Session.SetCrossOptionIssues(issues);
|
Session.SetCrossOptionIssues(issues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ else
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
if (pending?.OldValue is ExplicitToggleState previousState)
|
if (pending?.OldValue is ExplicitToggleState previousState)
|
||||||
|
|||||||
+1
-1
@@ -137,7 +137,7 @@ else
|
|||||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||||
_optionHelpKey = Descriptor.OptionName;
|
_optionHelpKey = Descriptor.OptionName;
|
||||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
var descriptorValue = Descriptor.IsMultiValue
|
var descriptorValue = Descriptor.IsMultiValue
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
OnClose="@OnClose"
|
OnClose="@OnClose"
|
||||||
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
|
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
|
||||||
OnSave="@HandleSave"
|
OnSave="@HandleSave"
|
||||||
OnRestore="@HandleRestore"
|
OnRestore="@HandleRestoreBackups"
|
||||||
OnReverted="@HandleReverted"
|
OnReverted="@HandleReverted"
|
||||||
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
|
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Parameter] public bool ShowModal { get; set; }
|
[Parameter] public bool ShowModal { get; set; }
|
||||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
|
||||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
|
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
|
||||||
@@ -107,15 +107,15 @@
|
|||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleRestore(string backupPath)
|
private async Task HandleRestoreBackups(IReadOnlyList<DnsmasqManagedBackup> backups)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(backupPath)) return;
|
if (backups == null || backups.Count == 0) return;
|
||||||
|
|
||||||
_saveState = SaveUiState.RestoreInProgress;
|
_saveState = SaveUiState.RestoreInProgress;
|
||||||
_restoreResult = null;
|
_restoreResult = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
var result = await SaveService.RestoreAsync(backupPath);
|
var result = await SaveService.RestoreAsync(backups);
|
||||||
|
|
||||||
_restoreResult = result;
|
_restoreResult = result;
|
||||||
if (result.Restored && result.Restarted)
|
if (result.Restored && result.Restarted)
|
||||||
|
|||||||
+146
-77
@@ -24,13 +24,19 @@
|
|||||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||||
<div class="ec-save-modal-alert-text">
|
<div class="ec-save-modal-alert-text">
|
||||||
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
|
<strong>Changes were saved, but validation failed.</strong> Restart was not attempted. You can restore the backups or keep editing.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (SaveResult.BackupPath != null)
|
@if (SaveResult.Backups.Count > 0)
|
||||||
{
|
{
|
||||||
<p class="ec-save-modal-backup-path mb-2">
|
<p class="ec-save-modal-backups mb-2">
|
||||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
<span class="text-muted">Backups created:</span>
|
||||||
|
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
|
||||||
|
@foreach (var b in SaveResult.Backups)
|
||||||
|
{
|
||||||
|
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div class="ec-save-modal-stderr mb-3">
|
<div class="ec-save-modal-stderr mb-3">
|
||||||
@@ -41,11 +47,25 @@
|
|||||||
}
|
}
|
||||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger mb-0">
|
<div class="ec-save-modal-reload-failed">
|
||||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||||
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
|
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||||
|
<div class="ec-save-modal-alert-text">
|
||||||
|
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||||
|
@(SaveResult.Backups?.Count > 0 ? " You can restore the backups to revert any partial write, or continue editing." : "")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (SaveResult.Backups?.Count > 0)
|
||||||
{
|
{
|
||||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
|
<p class="ec-save-modal-backups mb-2">
|
||||||
|
<span class="text-muted">Backups created:</span>
|
||||||
|
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
|
||||||
|
@foreach (var b in SaveResult.Backups)
|
||||||
|
{
|
||||||
|
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -55,13 +75,19 @@
|
|||||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||||
<div class="ec-save-modal-alert-text">
|
<div class="ec-save-modal-alert-text">
|
||||||
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
|
<strong>Changes were saved, but the restart command failed.</strong> You can restore the backups or keep editing.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (SaveResult.BackupPath != null)
|
@if (SaveResult.Backups.Count > 0)
|
||||||
{
|
{
|
||||||
<p class="ec-save-modal-backup-path mb-2">
|
<p class="ec-save-modal-backups mb-2">
|
||||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
<span class="text-muted">Backups created:</span>
|
||||||
|
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
|
||||||
|
@foreach (var b in SaveResult.Backups)
|
||||||
|
{
|
||||||
|
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div class="ec-save-modal-stderr mb-3">
|
<div class="ec-save-modal-stderr mb-3">
|
||||||
@@ -115,10 +141,16 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
<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 (TargetFileNames.Count > 0)
|
||||||
{
|
{
|
||||||
<p class="ec-save-modal-target mb-3">
|
<p class="ec-save-modal-targets mb-3">
|
||||||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
<span class="text-muted">Targets:</span>
|
||||||
|
<ul class="small mb-0 mt-1">
|
||||||
|
@foreach (var name in TargetFileNames)
|
||||||
|
{
|
||||||
|
<li><code class="small">@name</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@if (PendingChanges.Count == 0)
|
@if (PendingChanges.Count == 0)
|
||||||
@@ -132,80 +164,97 @@
|
|||||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||||
@foreach (var c in PendingChanges)
|
@foreach (var c in PendingChanges)
|
||||||
{
|
{
|
||||||
var change = c;
|
@if (c is PendingOptionChange change)
|
||||||
var oldList = AsStringList(change.OldValue);
|
{
|
||||||
var newList = AsStringList(change.NewValue);
|
var oldList = AsStringList(change.OldValue);
|
||||||
var isMultiValue = oldList != null || newList != null;
|
var newList = AsStringList(change.NewValue);
|
||||||
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
|
var isMultiValue = oldList != null || newList != null;
|
||||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
<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="d-flex align-items-center gap-2">
|
<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" />
|
<div class="d-flex align-items-center gap-2">
|
||||||
@if (!isMultiValue)
|
<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, change.OptionName)</span>
|
|
||||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
|
||||||
<span>@FormatValue(change.NewValue, change.OptionName)</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (isMultiValue)
|
|
||||||
{
|
|
||||||
var oldSeq = oldList ?? Array.Empty<string>();
|
|
||||||
var newSeq = newList ?? Array.Empty<string>();
|
|
||||||
var added = newSeq.Except(oldSeq).ToList();
|
|
||||||
var removed = oldSeq.Except(newSeq).ToList();
|
|
||||||
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
|
|
||||||
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
|
||||||
@if (orderOrDuplicatesChanged)
|
|
||||||
{
|
{
|
||||||
<div class="text-muted">Order or duplicates changed:</div>
|
<span class="ec-save-modal-diff text-body">
|
||||||
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
|
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
|
||||||
<div class="text-success">After: @string.Join(", ", newSeq)</div>
|
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||||
}
|
<span>@FormatValue(change.NewValue, change.OptionName)</span>
|
||||||
else
|
</span>
|
||||||
{
|
|
||||||
@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>
|
</div>
|
||||||
}
|
@if (isMultiValue)
|
||||||
@if (change.CurrentSourceFilePath != null)
|
{
|
||||||
{
|
var oldSeq = oldList ?? Array.Empty<string>();
|
||||||
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
|
var newSeq = newList ?? Array.Empty<string>();
|
||||||
}
|
var added = newSeq.Except(oldSeq).ToList();
|
||||||
|
var removed = oldSeq.Except(newSeq).ToList();
|
||||||
|
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
|
||||||
|
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
||||||
|
@if (orderOrDuplicatesChanged)
|
||||||
|
{
|
||||||
|
<div class="text-muted">Order or duplicates changed:</div>
|
||||||
|
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
|
||||||
|
<div class="text-success">After: @string.Join(", ", newSeq)</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@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="() => RevertOption(change)" title="Revert this change" aria-label="Revert @change.OptionName">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
||||||
|
</button>
|
||||||
</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">
|
}
|
||||||
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
else if (c is PendingManagedHostsChange hostChange)
|
||||||
</button>
|
{
|
||||||
</div>
|
<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="Managed hosts" Class="ec-save-modal-option me-2" />
|
||||||
|
<span class="ec-save-modal-diff text-body">@hostChange.OldEntries.Count entries → @hostChange.NewEntries.Count entries</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1">@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="RevertManagedHosts" title="Revert managed hosts change" aria-label="Revert managed hosts">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer ec-save-modal-footer">
|
<div class="modal-footer ec-save-modal-footer">
|
||||||
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
|
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed) && SaveResult?.Backups?.Count > 0)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
<button type="button" class="btn btn-primary" @onclick="RestoreAll"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backups</button>
|
||||||
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||||
}
|
}
|
||||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
|
||||||
{
|
|
||||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
|
||||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
|
||||||
}
|
|
||||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||||
}
|
}
|
||||||
|
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||||
|
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||||
|
}
|
||||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||||
@@ -218,7 +267,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
|
||||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||||
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
||||||
@@ -227,13 +276,26 @@
|
|||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
[Parameter] public EventCallback OnContinueEditing { get; set; }
|
[Parameter] public EventCallback OnContinueEditing { get; set; }
|
||||||
[Parameter] public EventCallback OnSave { get; set; }
|
[Parameter] public EventCallback OnSave { get; set; }
|
||||||
[Parameter] public EventCallback<string> OnRestore { get; set; }
|
[Parameter] public EventCallback<IReadOnlyList<DnsmasqManagedBackup>> OnRestore { get; set; }
|
||||||
[Parameter] public EventCallback OnReverted { get; set; }
|
[Parameter] public EventCallback OnReverted { get; set; }
|
||||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||||
|
|
||||||
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
||||||
|
|
||||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
private IReadOnlyList<string> TargetFileNames => GetTargetFileNames();
|
||||||
|
|
||||||
|
private IReadOnlyList<string> GetTargetFileNames()
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
if (Status == null) return list;
|
||||||
|
var hasOptions = PendingChanges.OfType<PendingOptionChange>().Any();
|
||||||
|
if (hasOptions && !string.IsNullOrEmpty(Status.ManagedFilePath))
|
||||||
|
list.Add(System.IO.Path.GetFileName(Status.ManagedFilePath));
|
||||||
|
var hostsChange = PendingChanges.OfType<PendingManagedHostsChange>().FirstOrDefault();
|
||||||
|
if (hostsChange != null)
|
||||||
|
list.Add(System.IO.Path.GetFileName(hostsChange.ManagedHostsFilePath));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||||||
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
|
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
|
||||||
@@ -300,12 +362,18 @@
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Revert(PendingEffectiveConfigChange change)
|
private async Task RevertOption(PendingOptionChange change)
|
||||||
{
|
{
|
||||||
Session.RevertChange(change.SectionId, change.OptionName);
|
Session.RevertChange(change.SectionId, change.OptionName);
|
||||||
await OnReverted.InvokeAsync();
|
await OnReverted.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RevertManagedHosts()
|
||||||
|
{
|
||||||
|
Session.RevertManagedHostsChange();
|
||||||
|
await OnReverted.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnBackdropClick()
|
private async Task OnBackdropClick()
|
||||||
{
|
{
|
||||||
if (!AllowClose) return;
|
if (!AllowClose) return;
|
||||||
@@ -329,9 +397,10 @@
|
|||||||
await OnSave.InvokeAsync();
|
await OnSave.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Restore(string backupPath)
|
private async Task RestoreAll()
|
||||||
{
|
{
|
||||||
await OnRestore.InvokeAsync(backupPath);
|
if (SaveResult == null || SaveResult.Backups.Count == 0) return;
|
||||||
|
await OnRestore.InvokeAsync(SaveResult.Backups);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CloseAndEndEdit()
|
private async Task CloseAndEndEdit()
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
@page "/leases"
|
@page "/leases"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@namespace DnsmasqWebUI.Components.Pages
|
@namespace DnsmasqWebUI.Components.Pages
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions
|
||||||
@inject IStatusClient StatusClient
|
@inject IStatusClient StatusClient
|
||||||
@inject IDhcpHostsClient DhcpHostsClient
|
@inject IDhcpHostsClient DhcpHostsClient
|
||||||
@inject ILeasesClient LeasesClient
|
@inject ILeasesClient LeasesClient
|
||||||
@inject IReloadClient ReloadClient
|
@inject IReloadClient ReloadClient
|
||||||
@inject IClientSettingsService ClientSettingsService
|
@inject IClientSettingsService ClientSettingsService
|
||||||
@inject ISettingsModalService SettingsModalService
|
@inject ISettingsModalService SettingsModalService
|
||||||
|
@inject IEffectiveConfigPageEditor PageEditor
|
||||||
|
@inject IEffectiveConfigEditSession Session
|
||||||
|
@inject IStructuredOptionValueHandlerRegistry HandlerRegistry
|
||||||
@inject IOptions<ApplicationOptions> AppOptions
|
@inject IOptions<ApplicationOptions> AppOptions
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@@ -67,16 +74,17 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var e in _visibleStaticEntries)
|
@foreach (var row in _visibleStaticHostRows)
|
||||||
{
|
{
|
||||||
|
var e = row.Entry;
|
||||||
var editable = IsEditable(e);
|
var editable = IsEditable(e);
|
||||||
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="e.Id">
|
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="@(row.Index + ":" + row.ValueString)">
|
||||||
<td>@e.LineNumber</td>
|
<td>@e.LineNumber</td>
|
||||||
@if (editable)
|
@if (editable)
|
||||||
{
|
{
|
||||||
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
|
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(row)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
|
||||||
<td>
|
<td>
|
||||||
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" style="min-width:140px" placeholder="MAC or pick from leases" />
|
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" @onchange="() => SyncRowToSession(row)" style="min-width:140px" placeholder="MAC or pick from leases" />
|
||||||
@if (_leases != null && _leases.Count > 0)
|
@if (_leases != null && _leases.Count > 0)
|
||||||
{
|
{
|
||||||
<datalist id="static-host-lease-datalist">
|
<datalist id="static-host-lease-datalist">
|
||||||
@@ -87,10 +95,10 @@ else
|
|||||||
</datalist>
|
</datalist>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><input class="form-control form-control-sm" @bind="e.Name" placeholder="Name" /></td>
|
<td><input class="form-control form-control-sm" @bind="e.Name" @bind:after="() => SyncRowToSession(row)" placeholder="Name" /></td>
|
||||||
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" placeholder="Address" /></td>
|
<td><input class="form-control form-control-sm" @bind="e.Address" @bind:after="() => SyncRowToSession(row)" style="min-width:100px" placeholder="Address" /></td>
|
||||||
<td>
|
<td>
|
||||||
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
|
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" @bind:after="() => SyncRowToSession(row)" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
|
||||||
<datalist id="lease-datalist">
|
<datalist id="lease-datalist">
|
||||||
@foreach (var p in LeaseDatalistOptions)
|
@foreach (var p in LeaseDatalistOptions)
|
||||||
{
|
{
|
||||||
@@ -98,10 +106,10 @@ else
|
|||||||
}
|
}
|
||||||
</datalist>
|
</datalist>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
|
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(row)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
|
||||||
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
|
<td><input class="form-control form-control-sm" @bind="e.Comment" @bind:after="() => SyncRowToSession(row)" /></td>
|
||||||
<td class="text-muted small">—</td>
|
<td class="text-muted small">—</td>
|
||||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
|
<td><button class="btn btn-sm btn-danger" @onclick="() => DeleteRow(row)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -121,10 +129,11 @@ else
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none table-cards-mobile">
|
<div class="d-md-none table-cards-mobile">
|
||||||
@foreach (var e in _visibleStaticEntries)
|
@foreach (var row in _visibleStaticHostRows)
|
||||||
{
|
{
|
||||||
|
var e = row.Entry;
|
||||||
var editable = IsEditable(e);
|
var editable = IsEditable(e);
|
||||||
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="e.Id">
|
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="@(row.Index + ":" + row.ValueString)">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
|
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
|
||||||
@if (!string.IsNullOrEmpty(e.SourcePath))
|
@if (!string.IsNullOrEmpty(e.SourcePath))
|
||||||
@@ -139,7 +148,7 @@ else
|
|||||||
<div class="table-card-control">
|
<div class="table-card-control">
|
||||||
@if (editable)
|
@if (editable)
|
||||||
{
|
{
|
||||||
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
|
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(row)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -152,7 +161,7 @@ else
|
|||||||
@if (editable)
|
@if (editable)
|
||||||
{
|
{
|
||||||
<div class="flex-grow-1" style="min-width:0">
|
<div class="flex-grow-1" style="min-width:0">
|
||||||
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
|
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" @onchange="() => SyncRowToSession(row)" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
|
||||||
@if (_leases != null && _leases.Count > 0)
|
@if (_leases != null && _leases.Count > 0)
|
||||||
{
|
{
|
||||||
<datalist id="static-host-lease-datalist-mobile">
|
<datalist id="static-host-lease-datalist-mobile">
|
||||||
@@ -171,17 +180,17 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-card-row">
|
<div class="table-card-row">
|
||||||
<span class="table-card-label">Name</span>
|
<span class="table-card-label">Name</span>
|
||||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" /> } else { <span>@e.Name</span> }
|
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Name</span> }
|
||||||
</div>
|
</div>
|
||||||
<div class="table-card-row">
|
<div class="table-card-row">
|
||||||
<span class="table-card-label">Address</span>
|
<span class="table-card-label">Address</span>
|
||||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" /> } else { <span>@e.Address</span> }
|
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Address</span> }
|
||||||
</div>
|
</div>
|
||||||
<div class="table-card-row">
|
<div class="table-card-row">
|
||||||
<span class="table-card-label">Lease</span>
|
<span class="table-card-label">Lease</span>
|
||||||
@if (editable)
|
@if (editable)
|
||||||
{
|
{
|
||||||
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
|
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" @bind:after="() => SyncRowToSession(row)" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
|
||||||
<datalist id="lease-datalist-mobile">
|
<datalist id="lease-datalist-mobile">
|
||||||
@foreach (var p in LeaseDatalistOptions)
|
@foreach (var p in LeaseDatalistOptions)
|
||||||
{
|
{
|
||||||
@@ -197,17 +206,17 @@ else
|
|||||||
<div class="table-card-row">
|
<div class="table-card-row">
|
||||||
<span class="table-card-label">Ignore</span>
|
<span class="table-card-label">Ignore</span>
|
||||||
<div class="table-card-control">
|
<div class="table-card-control">
|
||||||
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
|
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(row)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-card-row">
|
<div class="table-card-row">
|
||||||
<span class="table-card-label">Comment</span>
|
<span class="table-card-label">Comment</span>
|
||||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" /> } else { <span>@e.Comment</span> }
|
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Comment</span> }
|
||||||
</div>
|
</div>
|
||||||
@if (editable)
|
@if (editable)
|
||||||
{
|
{
|
||||||
<div class="table-card-row mt-2">
|
<div class="table-card-row mt-2">
|
||||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRow(row)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +226,6 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||||
<button class="btn btn-primary" @onclick="Add"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add host</button>
|
<button class="btn btn-primary" @onclick="Add"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add host</button>
|
||||||
<button class="btn btn-primary" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
|
||||||
@if (_status.ReloadCommandConfigured)
|
@if (_status.ReloadCommandConfigured)
|
||||||
{
|
{
|
||||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
|
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
|
||||||
@@ -333,6 +341,7 @@ else
|
|||||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||||
private DnsmasqServiceStatus? _status;
|
private DnsmasqServiceStatus? _status;
|
||||||
private List<DhcpHostEntry> _entries = new();
|
private List<DhcpHostEntry> _entries = new();
|
||||||
|
private List<string> _staticValueStrings = new();
|
||||||
private IReadOnlyList<LeaseEntry>? _leases;
|
private IReadOnlyList<LeaseEntry>? _leases;
|
||||||
private string _filterKeyword = "";
|
private string _filterKeyword = "";
|
||||||
private string _leaseFilterKeyword = "";
|
private string _leaseFilterKeyword = "";
|
||||||
@@ -342,6 +351,17 @@ else
|
|||||||
private int _leasesRefreshIntervalSeconds = 5;
|
private int _leasesRefreshIntervalSeconds = 5;
|
||||||
private string? _message;
|
private string? _message;
|
||||||
private bool _saving;
|
private bool _saving;
|
||||||
|
private IStructuredOptionValueHandler<DhcpHostEntry> _dhcpHostHandler = null!;
|
||||||
|
|
||||||
|
private EffectiveConfigFieldRef DhcpHostField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpHost);
|
||||||
|
|
||||||
|
private List<(int Index, string ValueString, DhcpHostEntry Entry)> _staticHostRows =>
|
||||||
|
Enumerable.Range(0, Math.Min(_entries.Count, _staticValueStrings.Count))
|
||||||
|
.Select(i => (i, _staticValueStrings[i], _entries[i]))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private IEnumerable<(int Index, string ValueString, DhcpHostEntry Entry)> _visibleStaticHostRows =>
|
||||||
|
_staticHostRows.Where(x => !x.Entry.IsDeleted && StaticEntryMatchesKeyword(x.Entry, _filterKeyword));
|
||||||
|
|
||||||
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
|
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
|
||||||
_entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword));
|
_entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword));
|
||||||
@@ -375,6 +395,15 @@ else
|
|||||||
{
|
{
|
||||||
SetMac(e, value);
|
SetMac(e, value);
|
||||||
TryFillFromLeaseByMac(e, value);
|
TryFillFromLeaseByMac(e, value);
|
||||||
|
SyncRowForEntry(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncRowForEntry(DhcpHostEntry e)
|
||||||
|
{
|
||||||
|
var idx = _entries.IndexOf(e);
|
||||||
|
if (idx < 0 || idx >= _staticValueStrings.Count) return;
|
||||||
|
var row = (idx, _staticValueStrings[idx], e);
|
||||||
|
SyncRowToSession(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value)
|
private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value)
|
||||||
@@ -406,7 +435,15 @@ else
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
_dhcpHostHandler = HandlerRegistry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
|
||||||
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
||||||
|
Session.Changed += OnSessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSessionChanged()
|
||||||
|
{
|
||||||
|
RefreshStaticHostsFromSession();
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
@@ -416,6 +453,20 @@ else
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshStaticHostsFromSession()
|
||||||
|
{
|
||||||
|
if (_status == null) return;
|
||||||
|
var values = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList();
|
||||||
|
_staticValueStrings = values;
|
||||||
|
_entries = values
|
||||||
|
.Select((v, i) => _dhcpHostHandler.TryParseValue(v, i + 1, out var parsed) && parsed != null
|
||||||
|
? parsed
|
||||||
|
: new DhcpHostEntry { LineNumber = i + 1, IsComment = true })
|
||||||
|
.ToList();
|
||||||
|
foreach (var e in _entries)
|
||||||
|
e.IsEditable = true;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RefreshStatusAsync()
|
private async Task RefreshStatusAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -435,8 +486,7 @@ else
|
|||||||
await ClientSettingsService.LoadSettingsAsync();
|
await ClientSettingsService.LoadSettingsAsync();
|
||||||
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
|
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
|
||||||
_status = await StatusClient.GetStatusAsync();
|
_status = await StatusClient.GetStatusAsync();
|
||||||
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
|
RefreshStaticHostsFromSession();
|
||||||
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
|
|
||||||
await LoadLeases(showLoading: true);
|
await LoadLeases(showLoading: true);
|
||||||
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
|
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
|
||||||
StartLeasesRefreshTimer();
|
StartLeasesRefreshTimer();
|
||||||
@@ -495,7 +545,7 @@ else
|
|||||||
private void Add()
|
private void Add()
|
||||||
{
|
{
|
||||||
var nextAddress = GetNextAvailableAddress();
|
var nextAddress = GetNextAvailableAddress();
|
||||||
_entries.Add(new DhcpHostEntry
|
var entry = new DhcpHostEntry
|
||||||
{
|
{
|
||||||
Id = "new:" + Guid.NewGuid().ToString("N"),
|
Id = "new:" + Guid.NewGuid().ToString("N"),
|
||||||
LineNumber = 0,
|
LineNumber = 0,
|
||||||
@@ -504,7 +554,13 @@ else
|
|||||||
Name = "*",
|
Name = "*",
|
||||||
Address = nextAddress,
|
Address = nextAddress,
|
||||||
IsEditable = true
|
IsEditable = true
|
||||||
});
|
};
|
||||||
|
if (_status != null)
|
||||||
|
{
|
||||||
|
PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry));
|
||||||
|
PageEditor.Activate(DhcpHostField);
|
||||||
|
}
|
||||||
|
RefreshStaticHostsFromSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,21 +608,35 @@ else
|
|||||||
return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}";
|
return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Delete(DhcpHostEntry e)
|
private void DeleteRow((int Index, string ValueString, DhcpHostEntry Entry) row)
|
||||||
{
|
{
|
||||||
e.IsDeleted = true;
|
if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return;
|
||||||
|
var newList = _staticValueStrings.ToList();
|
||||||
|
newList.RemoveAt(row.Index);
|
||||||
|
PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList);
|
||||||
|
RefreshStaticHostsFromSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleValid(DhcpHostEntry e)
|
private void ToggleValid((int Index, string ValueString, DhcpHostEntry Entry) row)
|
||||||
{
|
{
|
||||||
e.IsComment = !e.IsComment;
|
row.Entry.IsComment = !row.Entry.IsComment;
|
||||||
StateHasChanged();
|
SyncRowToSession(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleIgnore(DhcpHostEntry e)
|
private void ToggleIgnore((int Index, string ValueString, DhcpHostEntry Entry) row)
|
||||||
{
|
{
|
||||||
e.Ignore = !e.Ignore;
|
row.Entry.Ignore = !row.Entry.Ignore;
|
||||||
|
SyncRowToSession(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncRowToSession((int Index, string ValueString, DhcpHostEntry Entry) row)
|
||||||
|
{
|
||||||
|
if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return;
|
||||||
|
var newList = _staticValueStrings.ToList();
|
||||||
|
newList[row.Index] = _dhcpHostHandler!.SerializeValue(row.Entry);
|
||||||
|
PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList);
|
||||||
|
RefreshStaticHostsFromSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +658,8 @@ else
|
|||||||
|
|
||||||
private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore)
|
private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore)
|
||||||
{
|
{
|
||||||
_entries.Add(new DhcpHostEntry
|
if (_status == null) return;
|
||||||
|
var entry = new DhcpHostEntry
|
||||||
{
|
{
|
||||||
Id = "new:" + Guid.NewGuid().ToString("N"),
|
Id = "new:" + Guid.NewGuid().ToString("N"),
|
||||||
LineNumber = 0,
|
LineNumber = 0,
|
||||||
@@ -601,36 +672,13 @@ else
|
|||||||
Lease = "infinite",
|
Lease = "infinite",
|
||||||
Extra = new List<string>(),
|
Extra = new List<string>(),
|
||||||
Comment = null
|
Comment = null
|
||||||
});
|
};
|
||||||
|
PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler!.SerializeValue(entry));
|
||||||
|
PageEditor.Activate(DhcpHostField);
|
||||||
|
RefreshStaticHostsFromSession();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Save()
|
|
||||||
{
|
|
||||||
_saving = true;
|
|
||||||
_error = null;
|
|
||||||
_message = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await DhcpHostsClient.SaveDhcpHostsAsync(_entries);
|
|
||||||
if (result.Reload.Success)
|
|
||||||
_message = "Saved and dnsmasq reloaded.";
|
|
||||||
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
|
|
||||||
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
|
|
||||||
else
|
|
||||||
_message = "Saved.";
|
|
||||||
await Load();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Reload()
|
private async Task Reload()
|
||||||
{
|
{
|
||||||
_saving = true;
|
_saving = true;
|
||||||
@@ -656,6 +704,7 @@ else
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
Session.Changed -= OnSessionChanged;
|
||||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||||
_leasesRefreshTimer?.Dispose();
|
_leasesRefreshTimer?.Dispose();
|
||||||
_leasesRefreshTimer = null;
|
_leasesRefreshTimer = null;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
@page "/dns-records"
|
@page "/dns-records"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@namespace DnsmasqWebUI.Components.Pages
|
@namespace DnsmasqWebUI.Components.Pages
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||||
@inject IStatusClient StatusClient
|
@inject IStatusClient StatusClient
|
||||||
|
@inject IEffectiveConfigPageEditor PageEditor
|
||||||
@inject IOptions<ApplicationOptions> AppOptions
|
@inject IOptions<ApplicationOptions> AppOptions
|
||||||
|
|
||||||
<PageTitle>DNS records | @AppTitle</PageTitle>
|
<PageTitle>DNS records | @AppTitle</PageTitle>
|
||||||
@@ -20,6 +24,30 @@ else
|
|||||||
<section class="page-section">
|
<section class="page-section">
|
||||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.DnsRecords" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.DnsRecords" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||||
</section>
|
</section>
|
||||||
|
<section class="page-section">
|
||||||
|
<h2 class="page-section-title">Quick add</h2>
|
||||||
|
<div class="d-flex flex-wrap gap-3 align-items-end">
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:12rem" placeholder="Alias (e.g. www.example.com)" @bind="_cnameAlias" />
|
||||||
|
<input class="form-control form-control-sm" style="width:12rem" placeholder="Target (e.g. example.com)" @bind="_cnameTarget" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="AddCname">Add CNAME</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:10rem" placeholder="Host name" @bind="_hostRecordName" />
|
||||||
|
<input class="form-control form-control-sm" style="width:10rem" placeholder="IP address" @bind="_hostRecordIp" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="AddHostRecord">Add A/AAAA record</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:10rem" placeholder="Name" @bind="_txtName" />
|
||||||
|
<input class="form-control form-control-sm" style="width:12rem" placeholder="Text value" @bind="_txtValue" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="AddTxtRecord">Add TXT record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_recordError != null)
|
||||||
|
{
|
||||||
|
<p class="text-danger small mt-2 mb-0">@_recordError</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
<div class="page-chips" role="group" aria-label="Record type">
|
<div class="page-chips" role="group" aria-label="Record type">
|
||||||
<span class="page-chip active">All</span>
|
<span class="page-chip active">All</span>
|
||||||
<span class="page-chip">CNAME</span>
|
<span class="page-chip">CNAME</span>
|
||||||
@@ -34,6 +62,80 @@ else
|
|||||||
@code {
|
@code {
|
||||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||||
private DnsmasqServiceStatus? _status;
|
private DnsmasqServiceStatus? _status;
|
||||||
|
private string _cnameAlias = "";
|
||||||
|
private string _cnameTarget = "";
|
||||||
|
private string _hostRecordName = "";
|
||||||
|
private string _hostRecordIp = "";
|
||||||
|
private string _txtName = "";
|
||||||
|
private string _txtValue = "";
|
||||||
|
private string? _recordError;
|
||||||
|
|
||||||
|
private static EffectiveConfigFieldRef CnameField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.Cname);
|
||||||
|
private static EffectiveConfigFieldRef HostRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.HostRecord);
|
||||||
|
private static EffectiveConfigFieldRef TxtRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.TxtRecord);
|
||||||
|
|
||||||
|
private void AddCname()
|
||||||
|
{
|
||||||
|
_recordError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_cnameAlias) || string.IsNullOrWhiteSpace(_cnameTarget))
|
||||||
|
{
|
||||||
|
PageEditor.SetFieldIssues(CnameField(EffectiveConfigSections.SectionDnsRecords), [new FieldIssue(CnameField(EffectiveConfigSections.SectionDnsRecords).FieldKey, "Alias and target are required.", FieldIssueSeverity.Error, null)]);
|
||||||
|
_recordError = "Alias and target are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Cname);
|
||||||
|
PageEditor.ClearFieldIssues(field);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var next = current.Append($"{_cnameAlias.Trim()},{_cnameTarget.Trim()}").ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_cnameAlias = "";
|
||||||
|
_cnameTarget = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHostRecord()
|
||||||
|
{
|
||||||
|
_recordError = null;
|
||||||
|
var hostField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.HostRecord);
|
||||||
|
if (string.IsNullOrWhiteSpace(_hostRecordName) || string.IsNullOrWhiteSpace(_hostRecordIp))
|
||||||
|
{
|
||||||
|
PageEditor.SetFieldIssues(hostField, [new FieldIssue(hostField.FieldKey, "Host name and IP are required.", FieldIssueSeverity.Error, null)]);
|
||||||
|
_recordError = "Host name and IP are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = hostField;
|
||||||
|
PageEditor.ClearFieldIssues(field);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var next = current.Append($"{_hostRecordName.Trim()},{_hostRecordIp.Trim()}").ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_hostRecordName = "";
|
||||||
|
_hostRecordIp = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddTxtRecord()
|
||||||
|
{
|
||||||
|
_recordError = null;
|
||||||
|
var txtField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.TxtRecord);
|
||||||
|
if (string.IsNullOrWhiteSpace(_txtName))
|
||||||
|
{
|
||||||
|
PageEditor.SetFieldIssues(txtField, [new FieldIssue(txtField.FieldKey, "Name is required.", FieldIssueSeverity.Error, null)]);
|
||||||
|
_recordError = "Name is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = txtField;
|
||||||
|
PageEditor.ClearFieldIssues(field);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var value = string.IsNullOrWhiteSpace(_txtValue) ? _txtName.Trim() : $"{_txtName.Trim()},\"{_txtValue.Trim()}\"";
|
||||||
|
var next = current.Append(value).ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_txtName = "";
|
||||||
|
_txtValue = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
@page "/filters"
|
@page "/filters"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@namespace DnsmasqWebUI.Components.Pages
|
@namespace DnsmasqWebUI.Components.Pages
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||||
@inject IStatusClient StatusClient
|
@inject IStatusClient StatusClient
|
||||||
|
@inject IEffectiveConfigPageEditor PageEditor
|
||||||
@inject IOptions<ApplicationOptions> AppOptions
|
@inject IOptions<ApplicationOptions> AppOptions
|
||||||
|
|
||||||
<PageTitle>Filters | @AppTitle</PageTitle>
|
<PageTitle>Filters | @AppTitle</PageTitle>
|
||||||
@@ -20,6 +24,28 @@ else
|
|||||||
<section class="page-section">
|
<section class="page-section">
|
||||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Filters" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Filters" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||||
</section>
|
</section>
|
||||||
|
<section class="page-section">
|
||||||
|
<h2 class="page-section-title">Quick add</h2>
|
||||||
|
<div class="d-flex flex-wrap gap-3 align-items-end">
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain to block (e.g. ads.example.com)" @bind="_blockDomain" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="BlockDomain">Block domain</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:12rem" placeholder="Domain (e.g. internal.local)" @bind="_splitDomain" />
|
||||||
|
<input class="form-control form-control-sm" style="width:10rem" placeholder="Upstream (e.g. 10.0.0.1)" @bind="_splitUpstream" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="AddSplitDnsRoute">Split DNS route</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||||
|
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain (local only, e.g. lan)" @bind="_localDomain" />
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="AddLocalOnlyDomain">Local-only domain</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_filterError != null)
|
||||||
|
{
|
||||||
|
<p class="text-danger small mt-2 mb-0">@_filterError</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
<div class="page-chips" role="group" aria-label="Filter policy category">
|
<div class="page-chips" role="group" aria-label="Filter policy category">
|
||||||
<span class="page-chip active">All</span>
|
<span class="page-chip active">All</span>
|
||||||
<span class="page-chip">Blocking / sinkhole</span>
|
<span class="page-chip">Blocking / sinkhole</span>
|
||||||
@@ -33,6 +59,70 @@ else
|
|||||||
@code {
|
@code {
|
||||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||||
private DnsmasqServiceStatus? _status;
|
private DnsmasqServiceStatus? _status;
|
||||||
|
private string _blockDomain = "";
|
||||||
|
private string _splitDomain = "";
|
||||||
|
private string _splitUpstream = "";
|
||||||
|
private string _localDomain = "";
|
||||||
|
private string? _filterError;
|
||||||
|
|
||||||
|
private void BlockDomain()
|
||||||
|
{
|
||||||
|
_filterError = null;
|
||||||
|
var domain = _blockDomain?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(domain))
|
||||||
|
{
|
||||||
|
_filterError = "Enter a domain to block.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Address);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var rule = $"/{domain}/#";
|
||||||
|
var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_blockDomain = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSplitDnsRoute()
|
||||||
|
{
|
||||||
|
_filterError = null;
|
||||||
|
var domain = _splitDomain?.Trim() ?? "";
|
||||||
|
var upstream = _splitUpstream?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(upstream))
|
||||||
|
{
|
||||||
|
_filterError = "Domain and upstream are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var rule = $"/{domain}/{upstream}";
|
||||||
|
var next = current.Append(rule).ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_splitDomain = "";
|
||||||
|
_splitUpstream = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLocalOnlyDomain()
|
||||||
|
{
|
||||||
|
_filterError = null;
|
||||||
|
var domain = _localDomain?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(domain))
|
||||||
|
{
|
||||||
|
_filterError = "Enter a domain.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Local);
|
||||||
|
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
|
||||||
|
var rule = $"/{domain}/";
|
||||||
|
var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList();
|
||||||
|
PageEditor.ReplaceMultiValues(_status!, field, next);
|
||||||
|
PageEditor.Activate(field);
|
||||||
|
_localDomain = "";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
@page "/hosts"
|
@page "/hosts"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@namespace DnsmasqWebUI.Components.Pages
|
@namespace DnsmasqWebUI.Components.Pages
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
@inject IStatusClient StatusClient
|
@inject IStatusClient StatusClient
|
||||||
@inject IHostsClient HostsClient
|
@inject IHostsClient HostsClient
|
||||||
|
@inject IEffectiveConfigEditSession Session
|
||||||
@inject IOptions<ApplicationOptions> AppOptions
|
@inject IOptions<ApplicationOptions> AppOptions
|
||||||
<PageTitle>Hosts | @AppTitle</PageTitle>
|
<PageTitle>Hosts | @AppTitle</PageTitle>
|
||||||
|
|
||||||
@@ -38,13 +41,9 @@ else
|
|||||||
{
|
{
|
||||||
<div class="alert alert-danger">@_error</div>
|
<div class="alert alert-danger">@_error</div>
|
||||||
}
|
}
|
||||||
@if (_message != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-success">@_message</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
||||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
<p class="text-muted small mt-2 mb-0">Save changes via the toolbar above.</p>
|
||||||
|
|
||||||
@if (_readOnlyHosts?.Count > 0)
|
@if (_readOnlyHosts?.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -60,10 +59,36 @@ else
|
|||||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||||
private DnsmasqServiceStatus? _status;
|
private DnsmasqServiceStatus? _status;
|
||||||
private List<HostEntry> _entries = new();
|
private List<HostEntry> _entries = new();
|
||||||
|
private List<HostEntry> _originalManagedEntries = new();
|
||||||
private IReadOnlyList<ReadOnlyHostsFile>? _readOnlyHosts;
|
private IReadOnlyList<ReadOnlyHostsFile>? _readOnlyHosts;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private string? _message;
|
|
||||||
private bool _saving;
|
private static List<HostEntry> CloneEntries(IReadOnlyList<HostEntry> entries)
|
||||||
|
{
|
||||||
|
return entries.Select(e => new HostEntry
|
||||||
|
{
|
||||||
|
LineNumber = e.LineNumber,
|
||||||
|
Id = e.Id,
|
||||||
|
Address = e.Address,
|
||||||
|
Names = e.Names != null ? new List<string>(e.Names) : new List<string>(),
|
||||||
|
RawLine = e.RawLine,
|
||||||
|
IsComment = e.IsComment,
|
||||||
|
IsPassthrough = e.IsPassthrough
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncManagedHostsDraft()
|
||||||
|
{
|
||||||
|
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
|
||||||
|
return;
|
||||||
|
var draft = CloneEntries(_entries);
|
||||||
|
var original = CloneEntries(_originalManagedEntries);
|
||||||
|
var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath);
|
||||||
|
if (pending.HasChanges)
|
||||||
|
Session.TrackManagedHostsChange(pending);
|
||||||
|
else
|
||||||
|
Session.RevertManagedHostsChange();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
|
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
|
||||||
private MarkupString RenderAddnHostsList()
|
private MarkupString RenderAddnHostsList()
|
||||||
@@ -93,7 +118,7 @@ else
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_status = await StatusClient.GetStatusAsync();
|
await Load();
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
@@ -102,13 +127,14 @@ else
|
|||||||
private async Task Load()
|
private async Task Load()
|
||||||
{
|
{
|
||||||
_error = null;
|
_error = null;
|
||||||
_message = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_status = await StatusClient.GetStatusAsync();
|
_status = await StatusClient.GetStatusAsync();
|
||||||
var entries = await HostsClient.GetHostsAsync();
|
var entries = await HostsClient.GetHostsAsync();
|
||||||
_entries = entries?.ToList() ?? new List<HostEntry>();
|
_entries = entries?.ToList() ?? new List<HostEntry>();
|
||||||
|
_originalManagedEntries = CloneEntries(_entries);
|
||||||
_readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync();
|
_readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync();
|
||||||
|
SyncManagedHostsDraft();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -116,29 +142,4 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Save()
|
|
||||||
{
|
|
||||||
_saving = true;
|
|
||||||
_error = null;
|
|
||||||
_message = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await HostsClient.SaveHostsAsync(_entries);
|
|
||||||
if (result.Reload.Success)
|
|
||||||
_message = "Saved and dnsmasq reloaded.";
|
|
||||||
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
|
|
||||||
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
|
|
||||||
else
|
|
||||||
_message = "Saved.";
|
|
||||||
_status = await StatusClient.GetStatusAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
|
||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -12,13 +10,11 @@ namespace DnsmasqWebUI.Controllers;
|
|||||||
public class DhcpController : ControllerBase
|
public class DhcpController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDnsmasqConfigService _configService;
|
private readonly IDnsmasqConfigService _configService;
|
||||||
private readonly IReloadService _reloadService;
|
|
||||||
private readonly ILogger<DhcpController> _logger;
|
private readonly ILogger<DhcpController> _logger;
|
||||||
|
|
||||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger<DhcpController> logger)
|
public DhcpController(IDnsmasqConfigService configService, ILogger<DhcpController> logger)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_reloadService = reloadService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,28 +33,4 @@ public class DhcpController : ControllerBase
|
|||||||
return StatusCode(500, new { error = ex.Message });
|
return StatusCode(500, new { error = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("hosts")]
|
|
||||||
public async Task<ActionResult<SaveWithReloadResult>> PutHosts([FromBody] List<DhcpHostEntry>? entries, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (entries == null)
|
|
||||||
return BadRequest(new { error = "Body required" });
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _configService.WriteDhcpHostsAsync(entries, ct);
|
|
||||||
var reload = await _reloadService.ReloadAsync(ct);
|
|
||||||
_logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
|
||||||
return Ok(new SaveWithReloadResult(true, reload));
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "DHCP hosts put validation failed");
|
|
||||||
return BadRequest(new { error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Put DHCP hosts failed");
|
|
||||||
return StatusCode(500, new { error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
|
||||||
using DnsmasqWebUI.Models.Hosts;
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -12,14 +10,12 @@ namespace DnsmasqWebUI.Controllers;
|
|||||||
public class HostsController : ControllerBase
|
public class HostsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IHostsFileService _hostsService;
|
private readonly IHostsFileService _hostsService;
|
||||||
private readonly IReloadService _reloadService;
|
|
||||||
private readonly IHostsCache _hostsCache;
|
private readonly IHostsCache _hostsCache;
|
||||||
private readonly ILogger<HostsController> _logger;
|
private readonly ILogger<HostsController> _logger;
|
||||||
|
|
||||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache, ILogger<HostsController> logger)
|
public HostsController(IHostsFileService hostsService, IHostsCache hostsCache, ILogger<HostsController> logger)
|
||||||
{
|
{
|
||||||
_hostsService = hostsService;
|
_hostsService = hostsService;
|
||||||
_reloadService = reloadService;
|
|
||||||
_hostsCache = hostsCache;
|
_hostsCache = hostsCache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -56,28 +52,4 @@ public class HostsController : ControllerBase
|
|||||||
return StatusCode(500, new { error = ex.Message });
|
return StatusCode(500, new { error = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut]
|
|
||||||
public async Task<ActionResult<SaveWithReloadResult>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (entries == null)
|
|
||||||
return BadRequest(new { error = "Body required" });
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _hostsService.WriteAsync(entries, ct);
|
|
||||||
var reload = await _reloadService.ReloadAsync(ct);
|
|
||||||
_logger.LogInformation("Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
|
||||||
return Ok(new SaveWithReloadResult(true, reload));
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Hosts put validation failed");
|
|
||||||
return BadRequest(new { error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Put hosts failed");
|
|
||||||
return StatusCode(500, new { error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
|
|
||||||
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
|
/// <summary>Typed client for GET api/dhcp/hosts. Save goes through effective-config save flow.</summary>
|
||||||
public interface IDhcpHostsClient
|
public interface IDhcpHostsClient
|
||||||
{
|
{
|
||||||
/// <summary>Gets DHCP host entries from GET api/dhcp/hosts.</summary>
|
/// <summary>Gets DHCP host entries from GET api/dhcp/hosts.</summary>
|
||||||
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Writes DHCP host entries and triggers reload via PUT api/dhcp/hosts.</summary>
|
|
||||||
Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
|
|
||||||
/// <summary>Typed client for GET/PUT api/hosts.</summary>
|
/// <summary>Typed client for GET api/hosts. Save goes through effective-config save flow.</summary>
|
||||||
public interface IHostsClient
|
public interface IHostsClient
|
||||||
{
|
{
|
||||||
/// <summary>Gets managed hosts file entries from GET api/hosts.</summary>
|
/// <summary>Gets managed hosts file entries from GET api/hosts.</summary>
|
||||||
@@ -12,7 +12,4 @@ public interface IHostsClient
|
|||||||
|
|
||||||
/// <summary>Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly.</summary>
|
/// <summary>Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly.</summary>
|
||||||
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Writes managed hosts file and triggers reload via PUT api/hosts.</summary>
|
|
||||||
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||||
|
|
||||||
@@ -19,29 +16,4 @@ public sealed class DhcpHostsClient : IDhcpHostsClient
|
|||||||
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ApiJsonOptions.ClientOptions, ct);
|
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ApiJsonOptions.ClientOptions, ct);
|
||||||
return list ?? new List<DhcpHostEntry>();
|
return list ?? new List<DhcpHostEntry>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ApiJsonOptions.ClientOptions, ct);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
var msg = response.ReasonPhrase ?? response.StatusCode.ToString();
|
|
||||||
if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{"))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var err = System.Text.Json.JsonSerializer.Deserialize<JsonError>(body, ApiJsonOptions.ClientOptions);
|
|
||||||
if (!string.IsNullOrEmpty(err?.Error))
|
|
||||||
msg = err.Error;
|
|
||||||
}
|
|
||||||
catch { /* use msg as-is */ }
|
|
||||||
}
|
|
||||||
throw new HttpRequestException(msg);
|
|
||||||
}
|
|
||||||
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ApiJsonOptions.ClientOptions, ct)
|
|
||||||
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||||
using DnsmasqWebUI.Models.Hosts;
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||||
@@ -24,12 +22,4 @@ public sealed class HostsClient : IHostsClient
|
|||||||
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct);
|
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct);
|
||||||
return list ?? new List<ReadOnlyHostsFile>();
|
return list ?? new List<ReadOnlyHostsFile>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var response = await _http.PutAsJsonAsync("api/hosts", entries, ApiJsonOptions.ClientOptions, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ApiJsonOptions.ClientOptions, ct)
|
|
||||||
?? throw new InvalidOperationException("Unexpected null from api/hosts.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Serializes simple (non-structured) options to directive lines using <see cref="EffectiveConfigWriteSemantics"/>.</summary>
|
||||||
|
public interface IEffectiveConfigDirectiveSerializer : IApplicationSingleton
|
||||||
|
{
|
||||||
|
string SerializeSingle(string optionName, object? value);
|
||||||
|
IReadOnlyList<string> SerializeMulti(string optionName, IReadOnlyList<string> values);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared dnsmasq directive text mechanics: key=, key=value, strip prefix, and match checks for option, #option=, ##option=.
|
||||||
|
/// Use with <see cref="DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.DnsmasqConfKeys"/> for option names.
|
||||||
|
/// </summary>
|
||||||
|
public static class DnsmasqConfText
|
||||||
|
{
|
||||||
|
/// <summary>Returns the directive prefix for an option (e.g. "dhcp-host=").</summary>
|
||||||
|
public static string DirectivePrefix(string optionName) => $"{optionName}=";
|
||||||
|
|
||||||
|
/// <summary>Returns a full directive line: option=value, or just option when value is null/empty.</summary>
|
||||||
|
public static string DirectiveLine(string optionName, string? value) =>
|
||||||
|
string.IsNullOrEmpty(value) ? optionName : $"{optionName}={value}";
|
||||||
|
|
||||||
|
/// <summary>Strips the option= prefix from a line (option=, #option=, ##option=); preserves leading #/## so value round-trips. Returns the full line if it does not match.</summary>
|
||||||
|
public static string StripDirectivePrefix(string optionName, string line)
|
||||||
|
{
|
||||||
|
var prefix = DirectivePrefix(optionName);
|
||||||
|
if (line.StartsWith("##" + prefix, StringComparison.Ordinal))
|
||||||
|
return "##" + line[(2 + prefix.Length)..];
|
||||||
|
if (line.StartsWith("#" + prefix, StringComparison.Ordinal))
|
||||||
|
return "#" + line[(1 + prefix.Length)..];
|
||||||
|
if (line.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
return line[prefix.Length..];
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the line is an option directive, optionally with # or ## prefix.</summary>
|
||||||
|
public static bool HasDirectivePrefix(string optionName, string line)
|
||||||
|
{
|
||||||
|
var prefix = DirectivePrefix(optionName);
|
||||||
|
return line.StartsWith(prefix, StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("#" + prefix, StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("##" + prefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
|
||||||
|
/// <summary>Serializes simple options to directive lines using <see cref="EffectiveConfigWriteSemantics"/> and <see cref="DnsmasqConfText"/>.</summary>
|
||||||
|
public sealed class EffectiveConfigDirectiveSerializer : IEffectiveConfigDirectiveSerializer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SerializeSingle(string optionName, object? value)
|
||||||
|
{
|
||||||
|
return EffectiveConfigWriteSemantics.GetBehavior(optionName) switch
|
||||||
|
{
|
||||||
|
EffectiveConfigWriteBehavior.Flag =>
|
||||||
|
(value is bool b && b) ? optionName : "",
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue =>
|
||||||
|
DnsmasqConfText.DirectiveLine(optionName, value?.ToString()),
|
||||||
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue =>
|
||||||
|
string.IsNullOrWhiteSpace(value?.ToString())
|
||||||
|
? optionName
|
||||||
|
: DnsmasqConfText.DirectiveLine(optionName, value?.ToString()),
|
||||||
|
EffectiveConfigWriteBehavior.InversePair =>
|
||||||
|
SerializeInversePair(optionName, value),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported single-value write behavior for '{optionName}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> SerializeMulti(string optionName, IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
return EffectiveConfigWriteSemantics.GetBehavior(optionName) switch
|
||||||
|
{
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue =>
|
||||||
|
values.Select(v => DnsmasqConfText.DirectiveLine(optionName, v)).ToList(),
|
||||||
|
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue =>
|
||||||
|
values.Select(v => string.IsNullOrWhiteSpace(v) ? optionName : DnsmasqConfText.DirectiveLine(optionName, v)).ToList(),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported multi-value write behavior for '{optionName}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeInversePair(string optionName, object? value)
|
||||||
|
{
|
||||||
|
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName);
|
||||||
|
if (pair is null || value is not ExplicitToggleState state)
|
||||||
|
return "";
|
||||||
|
return state switch
|
||||||
|
{
|
||||||
|
ExplicitToggleState.Enabled => pair.Value.KeyA,
|
||||||
|
ExplicitToggleState.Disabled => pair.Value.KeyB,
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base contract for structured dnsmasq option value handlers. Keyed by <see cref="OptionName"/> (use <see cref="DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.DnsmasqConfKeys"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IStructuredOptionValueHandler : IApplicationMultiSingleton
|
||||||
|
{
|
||||||
|
string OptionName { get; }
|
||||||
|
Type ValueType { get; }
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed structured option handler: serialize/parse value and full directive line. Keyed by <see cref="IStructuredOptionValueHandler.OptionName"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IStructuredOptionValueHandler<T> : IStructuredOptionValueHandler, IValueSerializer<T>, IValueParser<T>
|
||||||
|
{
|
||||||
|
/// <summary>Full directive line (option=value) for the given value.</summary>
|
||||||
|
string SerializeLine(T value);
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Lookup for structured option value handlers by option name (use <see cref="DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.DnsmasqConfKeys"/>).</summary>
|
||||||
|
public interface IStructuredOptionValueHandlerRegistry : IApplicationSingleton
|
||||||
|
{
|
||||||
|
bool IsStructured(string optionName);
|
||||||
|
IStructuredOptionValueHandler? Get(string optionName);
|
||||||
|
IStructuredOptionValueHandler<T>? Get<T>(string optionName);
|
||||||
|
IStructuredOptionValueHandler<T> GetRequired<T>(string optionName);
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||||
|
|
||||||
|
/// <summary>Canonical typed handler for dhcp-host= values. Replaces ad-hoc mapper; uses <see cref="DnsmasqConfDhcpHostLineParser"/> and <see cref="DnsmasqConfText"/>.</summary>
|
||||||
|
public sealed class DhcpHostOptionValueHandler : IStructuredOptionValueHandler<DhcpHostEntry>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string OptionName => DnsmasqConfKeys.DhcpHost;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Type ValueType => typeof(DhcpHostEntry);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SerializeLine(DhcpHostEntry value) =>
|
||||||
|
DnsmasqConfDhcpHostLineParser.ToLine(value);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SerializeValue(DhcpHostEntry value)
|
||||||
|
{
|
||||||
|
var line = SerializeLine(value);
|
||||||
|
var valuePart = DnsmasqConfText.StripDirectivePrefix(OptionName, line);
|
||||||
|
return (valuePart is "#" or "##") ? "" : valuePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value)
|
||||||
|
{
|
||||||
|
value = DnsmasqConfDhcpHostLineParser.ParseLine(
|
||||||
|
DnsmasqConfText.DirectiveLine(OptionName, text),
|
||||||
|
lineNumber);
|
||||||
|
return value is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of structured option handlers. Validates handler option names and semantics metadata at construction time
|
||||||
|
/// so runtime resolution is fail-fast and consistent.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StructuredOptionValueHandlerRegistry : IStructuredOptionValueHandlerRegistry
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, IStructuredOptionValueHandler> _byOption;
|
||||||
|
|
||||||
|
public StructuredOptionValueHandlerRegistry(IEnumerable<IStructuredOptionValueHandler> handlers)
|
||||||
|
{
|
||||||
|
var handlerList = (handlers ?? Array.Empty<IStructuredOptionValueHandler>()).ToList();
|
||||||
|
var validOptionNames = GetDnsmasqConfKeyValues();
|
||||||
|
|
||||||
|
foreach (var handler in handlerList)
|
||||||
|
{
|
||||||
|
if (!validOptionNames.Contains(handler.OptionName))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Structured handler '{handler.GetType().Name}' uses unknown option name '{handler.OptionName}'. Use DnsmasqConfKeys.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_byOption = handlerList.ToDictionary(h => h.OptionName, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Duplicate structured option handlers registered for the same option name.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateSemanticsAlignment(_byOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsStructured(string optionName) =>
|
||||||
|
_byOption.ContainsKey(optionName);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IStructuredOptionValueHandler? Get(string optionName) =>
|
||||||
|
_byOption.TryGetValue(optionName, out var handler) ? handler : null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IStructuredOptionValueHandler<T>? Get<T>(string optionName) =>
|
||||||
|
Get(optionName) as Abstractions.IStructuredOptionValueHandler<T>;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IStructuredOptionValueHandler<T> GetRequired<T>(string optionName)
|
||||||
|
{
|
||||||
|
var handler = Get<T>(optionName);
|
||||||
|
if (handler != null)
|
||||||
|
return handler;
|
||||||
|
|
||||||
|
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
structuredType is null
|
||||||
|
? $"Option '{optionName}' is not declared as a structured option."
|
||||||
|
: $"No structured handler registered for option '{optionName}' with value type '{typeof(T).Name}'. Declared type is '{structuredType.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateSemanticsAlignment(IReadOnlyDictionary<string, IStructuredOptionValueHandler> handlers)
|
||||||
|
{
|
||||||
|
foreach (var (optionName, handler) in handlers)
|
||||||
|
{
|
||||||
|
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName);
|
||||||
|
if (structuredType is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Structured handler '{handler.GetType().Name}' is registered for '{optionName}', but semantics has no StructuredValueType.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler.ValueType != structuredType)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Structured handler type mismatch for '{optionName}': handler type '{handler.ValueType.Name}' does not match semantics type '{structuredType.Name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var optionName in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
|
||||||
|
{
|
||||||
|
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName);
|
||||||
|
if (structuredType is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!handlers.ContainsKey(optionName))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Semantics declares structured option '{optionName}' with type '{structuredType.Name}', but no structured handler is registered.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> GetDnsmasqConfKeyValues()
|
||||||
|
{
|
||||||
|
var values = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (field.FieldType == typeof(string) && field.GetValue(null) is string value)
|
||||||
|
values.Add(value);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-7
@@ -1,3 +1,5 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using Superpower;
|
using Superpower;
|
||||||
using Superpower.Model;
|
using Superpower.Model;
|
||||||
@@ -9,9 +11,12 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
|||||||
/// Parses a single <c>dhcp-host=</c> line from a dnsmasq .conf file. Same format as long option without <c>--</c>.
|
/// Parses a single <c>dhcp-host=</c> line from a dnsmasq .conf file. Same format as long option without <c>--</c>.
|
||||||
/// Official format: --dhcp-host=[hwaddr][,id:client_id|*][,set:tag][,tag:tag][,ipaddr][,hostname][,lease_time][,ignore]
|
/// Official format: --dhcp-host=[hwaddr][,id:client_id|*][,set:tag][,tag:tag][,ipaddr][,hostname][,lease_time][,ignore]
|
||||||
/// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
/// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
||||||
|
/// Directive text from <see cref="DnsmasqConfKeys.DhcpHost"/> and <see cref="DnsmasqConfText"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DnsmasqConfDhcpHostLineParser
|
public static class DnsmasqConfDhcpHostLineParser
|
||||||
{
|
{
|
||||||
|
private static readonly string OptionName = DnsmasqConfKeys.DhcpHost;
|
||||||
|
private static readonly string DirectivePrefix = DnsmasqConfText.DirectivePrefix(OptionName);
|
||||||
// Hostname: letter then (letter/digit/_/-)*. Full string.
|
// Hostname: letter then (letter/digit/_/-)*. Full string.
|
||||||
private static readonly TextParser<string> HostnameParser =
|
private static readonly TextParser<string> HostnameParser =
|
||||||
Character.Letter.Then(first => (Character.LetterOrDigit.Or(Character.In('_', '-')).Many().Text())
|
Character.Letter.Then(first => (Character.LetterOrDigit.Or(Character.In('_', '-')).Many().Text())
|
||||||
@@ -47,7 +52,7 @@ public static class DnsmasqConfDhcpHostLineParser
|
|||||||
|
|
||||||
// Literal "dhcp-host=" (consumed, value discarded)
|
// Literal "dhcp-host=" (consumed, value discarded)
|
||||||
private static readonly TextParser<Unit> DhcpHostTag =
|
private static readonly TextParser<Unit> DhcpHostTag =
|
||||||
ConfParserHelpers.Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value);
|
ConfParserHelpers.Token(Span.EqualTo(DirectivePrefix)).Value(Unit.Value);
|
||||||
|
|
||||||
// One field: no comma, no # (stops at next comma or trailing comment)
|
// One field: no comma, no # (stops at next comma or trailing comment)
|
||||||
private static readonly TextParser<string> Field =
|
private static readonly TextParser<string> Field =
|
||||||
@@ -61,19 +66,24 @@ public static class DnsmasqConfDhcpHostLineParser
|
|||||||
.Select(s => (string?)s).OptionalOrDefault(null)
|
.Select(s => (string?)s).OptionalOrDefault(null)
|
||||||
select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim());
|
select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim());
|
||||||
|
|
||||||
// Full line: optional ##/# prefix, "dhcp-host=", comma-separated fields, optional # comment
|
// Optional ## or # at start of value (after "dhcp-host=") for value-level comment/deleted (e.g. "dhcp-host=#mac,addr")
|
||||||
|
private static readonly TextParser<(bool isComment, bool isDeleted)> ValueLevelPrefix =
|
||||||
|
Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try()
|
||||||
|
.Or(Character.EqualTo('#').Select(_ => (true, false)))
|
||||||
|
.OptionalOrDefault((false, false));
|
||||||
|
|
||||||
|
// Full line: optional ##/# prefix, "dhcp-host=", optional value-level ##/#, comma-separated fields, optional # comment
|
||||||
private static readonly TextParser<(bool isComment, bool isDeleted, List<string> fields, string? comment)> LineParser =
|
private static readonly TextParser<(bool isComment, bool isDeleted, List<string> fields, string? comment)> LineParser =
|
||||||
from prefix in Prefix
|
from prefix in Prefix
|
||||||
from _ in DhcpHostTag
|
from _ in DhcpHostTag
|
||||||
|
from valuePrefix in ValueLevelPrefix
|
||||||
from fc in FieldsAndComment
|
from fc in FieldsAndComment
|
||||||
select (prefix.isComment, prefix.isDeleted, fc.fields, fc.comment);
|
select (prefix.isComment || valuePrefix.isComment, prefix.isDeleted || valuePrefix.isDeleted, fc.fields, fc.comment);
|
||||||
|
|
||||||
public static DhcpHostEntry? ParseLine(string line, int lineNumber)
|
public static DhcpHostEntry? ParseLine(string line, int lineNumber)
|
||||||
{
|
{
|
||||||
var remain = line.Trim();
|
var remain = line.Trim();
|
||||||
if (!remain.StartsWith("dhcp-host=", StringComparison.Ordinal) &&
|
if (!DnsmasqConfText.HasDirectivePrefix(OptionName, remain))
|
||||||
!remain.StartsWith("#dhcp-host=", StringComparison.Ordinal) &&
|
|
||||||
!remain.StartsWith("##dhcp-host=", StringComparison.Ordinal))
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var result = LineParser.TryParse(remain);
|
var result = LineParser.TryParse(remain);
|
||||||
@@ -143,7 +153,7 @@ public static class DnsmasqConfDhcpHostLineParser
|
|||||||
if (h.Ignore)
|
if (h.Ignore)
|
||||||
parts.Add("ignore");
|
parts.Add("ignore");
|
||||||
|
|
||||||
var line = prefix + "dhcp-host=" + string.Join(", ", parts);
|
var line = prefix + DnsmasqConfText.DirectivePrefix(OptionName) + string.Join(", ", parts);
|
||||||
if (!string.IsNullOrEmpty(h.Comment))
|
if (!string.IsNullOrEmpty(h.Comment))
|
||||||
line += " # " + h.Comment;
|
line += " # " + h.Comment;
|
||||||
return line;
|
return line;
|
||||||
|
|||||||
+7
-3
@@ -1,3 +1,5 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Config;
|
using DnsmasqWebUI.Models.Config;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
using Superpower;
|
using Superpower;
|
||||||
@@ -13,6 +15,8 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DnsmasqConfFileLineParser
|
public static class DnsmasqConfFileLineParser
|
||||||
{
|
{
|
||||||
|
private static readonly string DhcpHostPrefix = DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost);
|
||||||
|
|
||||||
private enum ConfLineParseKind { Blank, Comment, AddnHosts, DhcpHostCandidate, Other }
|
private enum ConfLineParseKind { Blank, Comment, AddnHosts, DhcpHostCandidate, Other }
|
||||||
|
|
||||||
private static readonly TextParser<(ConfLineParseKind kind, string content)> Blank =
|
private static readonly TextParser<(ConfLineParseKind kind, string content)> Blank =
|
||||||
@@ -23,12 +27,12 @@ public static class DnsmasqConfFileLineParser
|
|||||||
.Select(_ => (ConfLineParseKind.Comment, "")).Named("comment");
|
.Select(_ => (ConfLineParseKind.Comment, "")).Named("comment");
|
||||||
|
|
||||||
private static readonly TextParser<(ConfLineParseKind kind, string content)> AddnHosts =
|
private static readonly TextParser<(ConfLineParseKind kind, string content)> AddnHosts =
|
||||||
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("addn-hosts="))
|
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo(DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.AddnHosts)))
|
||||||
.IgnoreThen(Character.AnyChar.Many().Text())
|
.IgnoreThen(Character.AnyChar.Many().Text())
|
||||||
.Select(s => (ConfLineParseKind.AddnHosts, s.Trim())).Named("addn-hosts line");
|
.Select(s => (ConfLineParseKind.AddnHosts, s.Trim())).Named("addn-hosts line");
|
||||||
|
|
||||||
private static readonly TextParser<(ConfLineParseKind kind, string content)> DhcpHostCandidate =
|
private static readonly TextParser<(ConfLineParseKind kind, string content)> DhcpHostCandidate =
|
||||||
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("dhcp-host="))
|
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo(DhcpHostPrefix))
|
||||||
.IgnoreThen(Character.AnyChar.Many())
|
.IgnoreThen(Character.AnyChar.Many())
|
||||||
.Select(_ => (ConfLineParseKind.DhcpHostCandidate, "")).Named("dhcp-host line");
|
.Select(_ => (ConfLineParseKind.DhcpHostCandidate, "")).Named("dhcp-host line");
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@ public static class DnsmasqConfFileLineParser
|
|||||||
{
|
{
|
||||||
BlankLine b => b.RawLine.Length > 0 ? b.RawLine : "",
|
BlankLine b => b.RawLine.Length > 0 ? b.RawLine : "",
|
||||||
CommentLine c => c.RawLine,
|
CommentLine c => c.RawLine,
|
||||||
AddnHostsLine a => "addn-hosts=" + a.AddnHostsPath,
|
AddnHostsLine a => DnsmasqConfText.DirectiveLine(DnsmasqConfKeys.AddnHosts, a.AddnHostsPath),
|
||||||
DhcpHostLine d => DnsmasqConfDhcpHostLineParser.ToLine(d.DhcpHost),
|
DhcpHostLine d => DnsmasqConfDhcpHostLineParser.ToLine(d.DhcpHost),
|
||||||
OtherLine o => o.RawLine,
|
OtherLine o => o.RawLine,
|
||||||
_ => ""
|
_ => ""
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Generic contract to parse a value from text (e.g. for line-number context in errors).</summary>
|
||||||
|
public interface IValueParser<T>
|
||||||
|
{
|
||||||
|
bool TryParseValue(string text, int lineNumber, out T? value);
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Generic contract to serialize a value to a string.</summary>
|
||||||
|
public interface IValueSerializer<in T>
|
||||||
|
{
|
||||||
|
string SerializeValue(T value);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Serialization.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Config;
|
using DnsmasqWebUI.Models.Config;
|
||||||
using DnsmasqWebUI.Models.Contracts;
|
using DnsmasqWebUI.Models.Contracts;
|
||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
@@ -16,12 +18,18 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
{
|
{
|
||||||
private readonly IDnsmasqConfigSetService _configSetService;
|
private readonly IDnsmasqConfigSetService _configSetService;
|
||||||
private readonly IConfigSetCache _configSetCache;
|
private readonly IConfigSetCache _configSetCache;
|
||||||
|
private readonly IEffectiveConfigDirectiveSerializer _directiveSerializer;
|
||||||
private readonly ILogger<DnsmasqConfigService> _logger;
|
private readonly ILogger<DnsmasqConfigService> _logger;
|
||||||
|
|
||||||
public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IConfigSetCache configSetCache, ILogger<DnsmasqConfigService> logger)
|
public DnsmasqConfigService(
|
||||||
|
IDnsmasqConfigSetService configSetService,
|
||||||
|
IConfigSetCache configSetCache,
|
||||||
|
ILogger<DnsmasqConfigService> logger,
|
||||||
|
IEffectiveConfigDirectiveSerializer? directiveSerializer = null)
|
||||||
{
|
{
|
||||||
_configSetService = configSetService;
|
_configSetService = configSetService;
|
||||||
_configSetCache = configSetCache;
|
_configSetCache = configSetCache;
|
||||||
|
_directiveSerializer = directiveSerializer ?? new EffectiveConfigDirectiveSerializer();
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +112,21 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
|
|
||||||
if (matchingIndices.Count == 0)
|
if (matchingIndices.Count == 0)
|
||||||
{
|
{
|
||||||
configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine { LineNumber = 1, RawLine = $"{key}={value}" });
|
configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine
|
||||||
|
{
|
||||||
|
LineNumber = 1,
|
||||||
|
RawLine = DnsmasqConfText.DirectiveLine(key, value)
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var keepIdx = matchingIndices[0];
|
var keepIdx = matchingIndices[0];
|
||||||
var keepLineNumber = configLines[keepIdx].LineNumber;
|
var keepLineNumber = configLines[keepIdx].LineNumber;
|
||||||
configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine { LineNumber = keepLineNumber, RawLine = $"{key}={value}" };
|
configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine
|
||||||
|
{
|
||||||
|
LineNumber = keepLineNumber,
|
||||||
|
RawLine = DnsmasqConfText.DirectiveLine(key, value)
|
||||||
|
};
|
||||||
for (var i = matchingIndices.Count - 1; i >= 1; i--)
|
for (var i = matchingIndices.Count - 1; i >= 1; i--)
|
||||||
configLines.RemoveAt(matchingIndices[i]);
|
configLines.RemoveAt(matchingIndices[i]);
|
||||||
}
|
}
|
||||||
@@ -248,7 +264,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
var value = c.NewValue as string;
|
var value = c.NewValue as string;
|
||||||
if (value is null)
|
if (value is null)
|
||||||
continue;
|
continue;
|
||||||
var lineText = value.Length == 0 ? confKey : $"{confKey}={value.Trim()}";
|
var lineText = _directiveSerializer.SerializeSingle(confKey, value.Trim());
|
||||||
list.Add(new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText });
|
list.Add(new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText });
|
||||||
maxLineNumber++;
|
maxLineNumber++;
|
||||||
continue;
|
continue;
|
||||||
@@ -259,9 +275,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
|
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
|
||||||
var valuesToWrite = FilterManagedOnly(multiKeyOnlyValues, readonlyValues);
|
var valuesToWrite = FilterManagedOnly(multiKeyOnlyValues, readonlyValues);
|
||||||
RemoveAllMatchingLines(list, MatchesOption);
|
RemoveAllMatchingLines(list, MatchesOption);
|
||||||
foreach (var v in valuesToWrite)
|
foreach (var raw in _directiveSerializer.SerializeMulti(confKey, valuesToWrite.Select(v => v.Trim()).ToList()))
|
||||||
{
|
{
|
||||||
var raw = string.IsNullOrEmpty(v) ? confKey : $"{confKey}={v.Trim()}";
|
|
||||||
list.Add(new OtherLine { LineNumber = ++maxLineNumber, RawLine = raw });
|
list.Add(new OtherLine { LineNumber = ++maxLineNumber, RawLine = raw });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -303,11 +318,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
for (var i = matchingIndices.Count - 1; i >= 0; i--)
|
for (var i = matchingIndices.Count - 1; i >= 0; i--)
|
||||||
list.RemoveAt(matchingIndices[i]);
|
list.RemoveAt(matchingIndices[i]);
|
||||||
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
|
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
|
||||||
for (var i = 0; i < valuesToWrite.Count; i++)
|
var serialized = _directiveSerializer.SerializeMulti(confKey, valuesToWrite.Select(v => v.Trim()).ToList());
|
||||||
|
for (var i = 0; i < serialized.Count; i++)
|
||||||
{
|
{
|
||||||
var lineKey = confKey;
|
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = serialized[i] };
|
||||||
var lineText = string.IsNullOrEmpty(valuesToWrite[i]) ? lineKey : lineKey + "=" + valuesToWrite[i];
|
|
||||||
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
|
|
||||||
maxLineNumber++;
|
maxLineNumber++;
|
||||||
list.Insert(insertIdx + i, lineObj);
|
list.Insert(insertIdx + i, lineObj);
|
||||||
}
|
}
|
||||||
@@ -320,23 +334,18 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
if (idx >= 0) list.RemoveAt(idx);
|
if (idx >= 0) list.RemoveAt(idx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isFlag)
|
if (writeBehavior == EffectiveConfigWriteBehavior.SingleValue &&
|
||||||
|
string.IsNullOrWhiteSpace(c.NewValue?.ToString()))
|
||||||
{
|
{
|
||||||
var v = ToConfValue(c.NewValue);
|
if (idx >= 0) list.RemoveAt(idx);
|
||||||
if (string.IsNullOrWhiteSpace(v))
|
continue;
|
||||||
{
|
|
||||||
// Non-flag option with no value: remove existing line if any; never write key-only (malformed).
|
|
||||||
if (idx >= 0) list.RemoveAt(idx);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
string rawLine;
|
|
||||||
if (isFlag)
|
var rawLine = _directiveSerializer.SerializeSingle(confKey, c.NewValue);
|
||||||
rawLine = confKey;
|
if (string.IsNullOrWhiteSpace(rawLine))
|
||||||
else
|
|
||||||
{
|
{
|
||||||
var v = ToConfValue(c.NewValue).Trim();
|
if (idx >= 0) list.RemoveAt(idx);
|
||||||
rawLine = confKey + "=" + v;
|
continue;
|
||||||
}
|
}
|
||||||
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
|
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
|
||||||
maxLineNumber++;
|
maxLineNumber++;
|
||||||
@@ -434,15 +443,6 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToConfValue(object? value)
|
|
||||||
{
|
|
||||||
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() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
|
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical API for descriptor lookup and filtering. Scoped so UI and PageEditor share the same descriptor graph per status snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEffectiveConfigDescriptorProvider : IApplicationScopedService
|
||||||
|
{
|
||||||
|
/// <summary>All field descriptors for the given status (registry-built; may be cached per status for the scope).</summary>
|
||||||
|
IReadOnlyList<EffectiveConfigFieldDescriptor> GetAll(DnsmasqServiceStatus status);
|
||||||
|
|
||||||
|
/// <summary>Descriptors grouped by section, filtered by the given views (section + optional option allow-list).</summary>
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>> GetBySection(
|
||||||
|
DnsmasqServiceStatus status,
|
||||||
|
IReadOnlyList<EffectiveConfigSectionView> views);
|
||||||
|
|
||||||
|
/// <summary>Resolves a single descriptor by field ref; null if not found.</summary>
|
||||||
|
EffectiveConfigFieldDescriptor? Resolve(DnsmasqServiceStatus status, EffectiveConfigFieldRef field);
|
||||||
|
}
|
||||||
+6
-1
@@ -9,9 +9,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IEffectiveConfigEditSession : IApplicationScopedService
|
public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||||
{
|
{
|
||||||
|
/// <summary>Raised when session state changes so UI (section and external widgets) can refresh.</summary>
|
||||||
|
event Action? Changed;
|
||||||
|
|
||||||
bool IsEditMode { get; }
|
bool IsEditMode { get; }
|
||||||
string? ActiveFieldKey { get; }
|
string? ActiveFieldKey { get; }
|
||||||
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; }
|
IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; }
|
||||||
|
|
||||||
/// <summary>Per-field validation issues (errors block save; warnings can be confirmed).</summary>
|
/// <summary>Per-field validation issues (errors block save; warnings can be confirmed).</summary>
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues { get; }
|
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues { get; }
|
||||||
@@ -23,6 +26,8 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
|||||||
|
|
||||||
void TrackCommit(EffectiveConfigEditCommittedArgs args);
|
void TrackCommit(EffectiveConfigEditCommittedArgs args);
|
||||||
void RevertChange(string sectionId, string optionName);
|
void RevertChange(string sectionId, string optionName);
|
||||||
|
void TrackManagedHostsChange(PendingManagedHostsChange change);
|
||||||
|
void RevertManagedHostsChange();
|
||||||
|
|
||||||
void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues);
|
void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues);
|
||||||
void ClearFieldIssues(string fieldKey);
|
void ClearFieldIssues(string fieldKey);
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the full effective-config descriptor graph for a status using the render-fragment registry.
|
||||||
|
/// Registered via DI so registry-backed building stays explicit and testable.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEffectiveConfigFieldBuilder : IApplicationSingleton
|
||||||
|
{
|
||||||
|
/// <summary>Builds all field descriptors for the given status.</summary>
|
||||||
|
IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus status);
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page-facing orchestrator for specialized effective-config UI. Resolves descriptors from status,
|
||||||
|
/// overlays pending changes, and writes through the shared edit session so the section and widgets stay in sync.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEffectiveConfigPageEditor : IApplicationScopedService
|
||||||
|
{
|
||||||
|
void EnsureEditMode();
|
||||||
|
|
||||||
|
EffectiveConfigFieldRef Field(string sectionId, string optionName);
|
||||||
|
|
||||||
|
object? GetEffectiveValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field);
|
||||||
|
IReadOnlyList<string> GetEffectiveMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field);
|
||||||
|
|
||||||
|
void SetSingleValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, object? newValue);
|
||||||
|
void ReplaceMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, IReadOnlyList<string> newValues);
|
||||||
|
void AppendMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string newValue);
|
||||||
|
void RemoveMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string valueToRemove);
|
||||||
|
|
||||||
|
void Revert(EffectiveConfigFieldRef field);
|
||||||
|
void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList<FieldIssue> issues);
|
||||||
|
void ClearFieldIssues(EffectiveConfigFieldRef field);
|
||||||
|
void RefreshCrossOptionIssues(DnsmasqServiceStatus status);
|
||||||
|
void Activate(EffectiveConfigFieldRef field);
|
||||||
|
}
|
||||||
+5
-5
@@ -3,14 +3,14 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
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>
|
/// <summary>Orchestrates effective-config save: backup managed config and/or managed hosts, write, validate, reload; and restore from backups. Keeps IDnsmasqConfigService focused on read/write mechanics.</summary>
|
||||||
public interface IEffectiveConfigSaveService : IApplicationScopedService
|
public interface IEffectiveConfigSaveService : IApplicationScopedService
|
||||||
{
|
{
|
||||||
/// <summary>Creates a timestamped backup (if managed file exists), applies changes, then runs reload. Returns structured result for UI.</summary>
|
/// <summary>Creates timestamped backups for each target that exists, applies option and managed-hosts changes, then validates and reloads. Returns structured result for UI including backups for restore.</summary>
|
||||||
Task<EffectiveConfigSaveResult> SaveAsync(
|
Task<EffectiveConfigSaveResult> SaveAsync(
|
||||||
IReadOnlyList<PendingEffectiveConfigChange> changes,
|
IReadOnlyList<PendingDnsmasqChange> changes,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Overwrites the managed config with the backup file, then runs reload. Returns structured result for UI.</summary>
|
/// <summary>Overwrites each target file with its backup, then runs reload. Returns structured result for UI.</summary>
|
||||||
Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default);
|
Task<EffectiveConfigRestoreResult> RestoreAsync(IReadOnlyList<DnsmasqManagedBackup> backups, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical descriptor lookup/filter; uses the DI-managed field builder and caches per status reference
|
||||||
|
/// so UI and PageEditor share the same graph.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EffectiveConfigDescriptorProvider : IEffectiveConfigDescriptorProvider
|
||||||
|
{
|
||||||
|
private readonly IEffectiveConfigFieldBuilder _fieldBuilder;
|
||||||
|
private DnsmasqServiceStatus? _cachedStatus;
|
||||||
|
private IReadOnlyList<EffectiveConfigFieldDescriptor>? _cachedList;
|
||||||
|
|
||||||
|
public EffectiveConfigDescriptorProvider(IEffectiveConfigFieldBuilder fieldBuilder)
|
||||||
|
{
|
||||||
|
_fieldBuilder = fieldBuilder ?? throw new ArgumentNullException(nameof(fieldBuilder));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<EffectiveConfigFieldDescriptor> GetAll(DnsmasqServiceStatus status)
|
||||||
|
{
|
||||||
|
if (status == null)
|
||||||
|
throw new ArgumentNullException(nameof(status));
|
||||||
|
if (ReferenceEquals(_cachedStatus, status) && _cachedList != null)
|
||||||
|
return _cachedList;
|
||||||
|
_cachedStatus = status;
|
||||||
|
_cachedList = _fieldBuilder.BuildFieldDescriptors(status);
|
||||||
|
return _cachedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>> GetBySection(
|
||||||
|
DnsmasqServiceStatus status,
|
||||||
|
IReadOnlyList<EffectiveConfigSectionView> views)
|
||||||
|
{
|
||||||
|
if (status == null)
|
||||||
|
throw new ArgumentNullException(nameof(status));
|
||||||
|
if (views == null || views.Count == 0)
|
||||||
|
return new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var all = GetAll(status);
|
||||||
|
var result = new Dictionary<string, List<EffectiveConfigFieldDescriptor>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var view in views)
|
||||||
|
{
|
||||||
|
var list = all
|
||||||
|
.Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(d => view.AllowedOptionNames == null
|
||||||
|
|| view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (list.Count > 0)
|
||||||
|
result[view.SectionId] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<EffectiveConfigFieldDescriptor>)kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public EffectiveConfigFieldDescriptor? Resolve(DnsmasqServiceStatus status, EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
if (status == null)
|
||||||
|
throw new ArgumentNullException(nameof(status));
|
||||||
|
var all = GetAll(status);
|
||||||
|
return all.FirstOrDefault(d =>
|
||||||
|
string.Equals(d.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(d.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-11
@@ -6,7 +6,7 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
|||||||
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||||
{
|
{
|
||||||
private readonly IEffectiveConfigSaveService _saveService;
|
private readonly IEffectiveConfigSaveService _saveService;
|
||||||
private readonly List<PendingEffectiveConfigChange> _pending = new();
|
private readonly List<PendingDnsmasqChange> _pending = new();
|
||||||
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly List<FieldIssue> _crossOptionIssues = new();
|
private readonly List<FieldIssue> _crossOptionIssues = new();
|
||||||
|
|
||||||
@@ -15,9 +15,13 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
_saveService = saveService;
|
_saveService = saveService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
private void NotifyChanged() => Changed?.Invoke();
|
||||||
|
|
||||||
public bool IsEditMode { get; private set; }
|
public bool IsEditMode { get; private set; }
|
||||||
public string? ActiveFieldKey { get; private set; }
|
public string? ActiveFieldKey { get; private set; }
|
||||||
public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges => _pending;
|
public IReadOnlyList<PendingDnsmasqChange> PendingChanges => _pending;
|
||||||
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
|
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -42,6 +46,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
_pending.Clear();
|
_pending.Clear();
|
||||||
_fieldIssues.Clear();
|
_fieldIssues.Clear();
|
||||||
_crossOptionIssues.Clear();
|
_crossOptionIssues.Clear();
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ExitEditModeDiscard()
|
public void ExitEditModeDiscard()
|
||||||
@@ -51,29 +56,34 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
_crossOptionIssues.Clear();
|
_crossOptionIssues.Clear();
|
||||||
ActiveFieldKey = null;
|
ActiveFieldKey = null;
|
||||||
IsEditMode = false;
|
IsEditMode = false;
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ActivateField(string fieldKey)
|
public void ActivateField(string fieldKey)
|
||||||
{
|
{
|
||||||
IsEditMode = true;
|
IsEditMode = true;
|
||||||
ActiveFieldKey = fieldKey;
|
ActiveFieldKey = fieldKey;
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeactivateField()
|
public void DeactivateField()
|
||||||
{
|
{
|
||||||
ActiveFieldKey = null;
|
ActiveFieldKey = null;
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues)
|
public void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(fieldKey)) return;
|
if (string.IsNullOrEmpty(fieldKey)) return;
|
||||||
_fieldIssues[fieldKey] = issues.ToList();
|
_fieldIssues[fieldKey] = issues.ToList();
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearFieldIssues(string fieldKey)
|
public void ClearFieldIssues(string fieldKey)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(fieldKey)) return;
|
if (string.IsNullOrEmpty(fieldKey)) return;
|
||||||
_fieldIssues.Remove(fieldKey);
|
_fieldIssues.Remove(fieldKey);
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues)
|
public void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues)
|
||||||
@@ -81,6 +91,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
_crossOptionIssues.Clear();
|
_crossOptionIssues.Clear();
|
||||||
if (issues != null)
|
if (issues != null)
|
||||||
_crossOptionIssues.AddRange(issues);
|
_crossOptionIssues.AddRange(issues);
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasBlockingValidationErrors()
|
public bool HasBlockingValidationErrors()
|
||||||
@@ -99,23 +110,32 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
|
|
||||||
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
|
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
|
||||||
{
|
{
|
||||||
var existing = _pending.FirstOrDefault(c =>
|
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName);
|
||||||
c.SectionId == args.SectionId && c.OptionName == args.OptionName);
|
|
||||||
if (existing != null)
|
|
||||||
_pending.Remove(existing);
|
|
||||||
if (!ValuesEqual(args.OldValue, args.NewValue))
|
if (!ValuesEqual(args.OldValue, args.NewValue))
|
||||||
_pending.Add(new PendingEffectiveConfigChange(
|
_pending.Add(new PendingOptionChange(
|
||||||
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
|
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
|
||||||
ActiveFieldKey = null;
|
ActiveFieldKey = null;
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RevertChange(string sectionId, string optionName)
|
public void RevertChange(string sectionId, string optionName)
|
||||||
{
|
{
|
||||||
var existing = _pending.FirstOrDefault(c =>
|
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == optionName);
|
||||||
c.SectionId == sectionId && c.OptionName == optionName);
|
|
||||||
if (existing != null)
|
|
||||||
_pending.Remove(existing);
|
|
||||||
ClearFieldIssues($"{sectionId}:{optionName}");
|
ClearFieldIssues($"{sectionId}:{optionName}");
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackManagedHostsChange(PendingManagedHostsChange change)
|
||||||
|
{
|
||||||
|
_pending.RemoveAll(c => c is PendingManagedHostsChange);
|
||||||
|
_pending.Add(change);
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RevertManagedHostsChange()
|
||||||
|
{
|
||||||
|
_pending.RemoveAll(c => c is PendingManagedHostsChange);
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
|
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
|
||||||
@@ -126,6 +146,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
|||||||
var result = await _saveService.SaveAsync(_pending.ToList(), ct);
|
var result = await _saveService.SaveAsync(_pending.ToList(), ct);
|
||||||
if (result.Saved && result.Restarted)
|
if (result.Saved && result.Restarted)
|
||||||
ExitEditModeDiscard();
|
ExitEditModeDiscard();
|
||||||
|
else
|
||||||
|
NotifyChanged();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page-facing orchestrator: resolves descriptors via the shared provider, overlays pending changes,
|
||||||
|
/// and writes through the shared edit session. Runs cross-option evaluation after mutations.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor
|
||||||
|
{
|
||||||
|
private readonly IEffectiveConfigEditSession _session;
|
||||||
|
private readonly IEffectiveConfigDescriptorProvider _descriptorProvider;
|
||||||
|
|
||||||
|
public EffectiveConfigPageEditor(IEffectiveConfigEditSession session, IEffectiveConfigDescriptorProvider descriptorProvider)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_descriptorProvider = descriptorProvider ?? throw new ArgumentNullException(nameof(descriptorProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnsureEditMode()
|
||||||
|
{
|
||||||
|
if (!_session.IsEditMode)
|
||||||
|
_session.EnterEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public EffectiveConfigFieldRef Field(string sectionId, string optionName)
|
||||||
|
=> EffectiveConfigFieldRef.For(sectionId, optionName);
|
||||||
|
|
||||||
|
public object? GetEffectiveValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
var descriptor = ResolveDescriptor(status, field);
|
||||||
|
if (descriptor == null) return null;
|
||||||
|
|
||||||
|
var pending = GetPendingOptionChange(field);
|
||||||
|
var fromDescriptor = descriptor.IsMultiValue
|
||||||
|
? (object?)(descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||||
|
: descriptor.GetValue();
|
||||||
|
return pending != null ? pending.NewValue : fromDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> GetEffectiveMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
var descriptor = ResolveDescriptor(status, field);
|
||||||
|
if (descriptor == null) return new List<string>();
|
||||||
|
|
||||||
|
var pending = GetPendingOptionChange(field);
|
||||||
|
if (pending?.NewValue is IReadOnlyList<string> list)
|
||||||
|
return list;
|
||||||
|
return descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSingleValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, object? newValue)
|
||||||
|
{
|
||||||
|
EnsureEditMode();
|
||||||
|
var descriptor = ResolveDescriptor(status, field);
|
||||||
|
if (descriptor == null) return;
|
||||||
|
|
||||||
|
var pending = GetPendingOptionChange(field);
|
||||||
|
var baseValue = descriptor.IsMultiValue
|
||||||
|
? (object?)(descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||||
|
: descriptor.GetValue();
|
||||||
|
var oldValue = pending?.OldValue ?? baseValue;
|
||||||
|
var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source;
|
||||||
|
_session.TrackCommit(new EffectiveConfigEditCommittedArgs(
|
||||||
|
field.SectionId, field.OptionName, oldValue, newValue, source?.FilePath));
|
||||||
|
RefreshCrossOptionIssues(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, IReadOnlyList<string> newValues)
|
||||||
|
{
|
||||||
|
EnsureEditMode();
|
||||||
|
var descriptor = ResolveDescriptor(status, field);
|
||||||
|
if (descriptor == null) return;
|
||||||
|
|
||||||
|
var pending = GetPendingOptionChange(field);
|
||||||
|
var baseValues = descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>();
|
||||||
|
var oldValues = pending?.OldValue is IReadOnlyList<string> list ? list.ToList() : baseValues;
|
||||||
|
var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source;
|
||||||
|
_session.TrackCommit(new EffectiveConfigEditCommittedArgs(
|
||||||
|
field.SectionId, field.OptionName, oldValues, newValues.ToList(), source?.FilePath));
|
||||||
|
RefreshCrossOptionIssues(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AppendMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string newValue)
|
||||||
|
{
|
||||||
|
var current = GetEffectiveMultiValues(status, field);
|
||||||
|
var next = current.Append(newValue).ToList();
|
||||||
|
ReplaceMultiValues(status, field, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string valueToRemove)
|
||||||
|
{
|
||||||
|
var current = GetEffectiveMultiValues(status, field);
|
||||||
|
var next = current.Where(v => !string.Equals(v, valueToRemove, StringComparison.Ordinal)).ToList();
|
||||||
|
ReplaceMultiValues(status, field, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Revert(EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
_session.RevertChange(field.SectionId, field.OptionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList<FieldIssue> issues)
|
||||||
|
{
|
||||||
|
_session.SetFieldIssues(field.FieldKey, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearFieldIssues(EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
_session.ClearFieldIssues(field.FieldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshCrossOptionIssues(DnsmasqServiceStatus status)
|
||||||
|
{
|
||||||
|
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(status, _session.PendingChanges.OfType<PendingOptionChange>().ToList());
|
||||||
|
_session.SetCrossOptionIssues(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Activate(EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
EnsureEditMode();
|
||||||
|
_session.ActivateField(field.FieldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingOptionChange? GetPendingOptionChange(EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
return _session.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
|
string.Equals(c.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(c.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveConfigFieldDescriptor? ResolveDescriptor(DnsmasqServiceStatus status, EffectiveConfigFieldRef field)
|
||||||
|
{
|
||||||
|
return _descriptorProvider.Resolve(status, field);
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-117
@@ -1,6 +1,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||||
@@ -15,6 +16,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
private readonly IDnsmasqConfigSetService _configSetService;
|
private readonly IDnsmasqConfigSetService _configSetService;
|
||||||
private readonly IDnsmasqConfigService _configService;
|
private readonly IDnsmasqConfigService _configService;
|
||||||
private readonly IConfigSetCache _configSetCache;
|
private readonly IConfigSetCache _configSetCache;
|
||||||
|
private readonly IHostsFileService _hostsFileService;
|
||||||
private readonly IConfigValidationService _validationService;
|
private readonly IConfigValidationService _validationService;
|
||||||
private readonly IEffectiveConfigSemanticValidationService _semanticValidationService;
|
private readonly IEffectiveConfigSemanticValidationService _semanticValidationService;
|
||||||
private readonly IReloadService _reloadService;
|
private readonly IReloadService _reloadService;
|
||||||
@@ -25,6 +27,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
IDnsmasqConfigSetService configSetService,
|
IDnsmasqConfigSetService configSetService,
|
||||||
IDnsmasqConfigService configService,
|
IDnsmasqConfigService configService,
|
||||||
IConfigSetCache configSetCache,
|
IConfigSetCache configSetCache,
|
||||||
|
IHostsFileService hostsFileService,
|
||||||
IConfigValidationService validationService,
|
IConfigValidationService validationService,
|
||||||
IEffectiveConfigSemanticValidationService semanticValidationService,
|
IEffectiveConfigSemanticValidationService semanticValidationService,
|
||||||
IReloadService reloadService,
|
IReloadService reloadService,
|
||||||
@@ -34,6 +37,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
_configSetService = configSetService;
|
_configSetService = configSetService;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_configSetCache = configSetCache;
|
_configSetCache = configSetCache;
|
||||||
|
_hostsFileService = hostsFileService;
|
||||||
_validationService = validationService;
|
_validationService = validationService;
|
||||||
_semanticValidationService = semanticValidationService;
|
_semanticValidationService = semanticValidationService;
|
||||||
_reloadService = reloadService;
|
_reloadService = reloadService;
|
||||||
@@ -43,7 +47,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<EffectiveConfigSaveResult> SaveAsync(
|
public async Task<EffectiveConfigSaveResult> SaveAsync(
|
||||||
IReadOnlyList<PendingEffectiveConfigChange> changes,
|
IReadOnlyList<PendingDnsmasqChange> changes,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (changes.Count == 0)
|
if (changes.Count == 0)
|
||||||
@@ -52,55 +56,32 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
var version = await _versionService.GetVersionInfoAsync(ct);
|
var version = await _versionService.GetVersionInfoAsync(ct);
|
||||||
if (!version.ProbeSucceeded)
|
if (!version.ProbeSucceeded)
|
||||||
{
|
{
|
||||||
return new EffectiveConfigSaveResult(
|
return Fail(Array.Empty<DnsmasqManagedBackup>(), EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed,
|
||||||
BackupCreated: false,
|
string.IsNullOrWhiteSpace(version.Error)
|
||||||
BackupPath: null,
|
|
||||||
Saved: false,
|
|
||||||
Validated: false,
|
|
||||||
ValidationExitCode: -1,
|
|
||||||
ValidationStdOut: null,
|
|
||||||
ValidationStdErr: null,
|
|
||||||
Restarted: false,
|
|
||||||
RestartExitCode: -1,
|
|
||||||
RestartStdOut: null,
|
|
||||||
RestartStdErr: null,
|
|
||||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed,
|
|
||||||
UserMessage: string.IsNullOrWhiteSpace(version.Error)
|
|
||||||
? "Cannot save: dnsmasq version could not be determined."
|
? "Cannot save: dnsmasq version could not be determined."
|
||||||
: $"Cannot save: dnsmasq version could not be determined. {version.Error}");
|
: $"Cannot save: dnsmasq version could not be determined. {version.Error}");
|
||||||
}
|
}
|
||||||
if (!version.IsSupported)
|
if (!version.IsSupported)
|
||||||
{
|
{
|
||||||
return new EffectiveConfigSaveResult(
|
return Fail(Array.Empty<DnsmasqManagedBackup>(), EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion,
|
||||||
BackupCreated: false,
|
$"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}.");
|
||||||
BackupPath: null,
|
|
||||||
Saved: false,
|
|
||||||
Validated: false,
|
|
||||||
ValidationExitCode: -1,
|
|
||||||
ValidationStdOut: null,
|
|
||||||
ValidationStdErr: null,
|
|
||||||
Restarted: false,
|
|
||||||
RestartExitCode: -1,
|
|
||||||
RestartStdOut: null,
|
|
||||||
RestartStdErr: null,
|
|
||||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion,
|
|
||||||
UserMessage: $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||||
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
|
var plan = BuildWritePlan(changes, set.ManagedFilePath ?? "");
|
||||||
|
|
||||||
|
if (plan.OptionChanges.Count > 0 && string.IsNullOrWhiteSpace(set.ManagedFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Save skipped: managed config path is not configured");
|
_logger.LogWarning("Save skipped: managed config path is not configured");
|
||||||
return new EffectiveConfigSaveResult(
|
return Fail(Array.Empty<DnsmasqManagedBackup>(), EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath,
|
||||||
false, null, false, false, -1, null, null, false, -1, null, null,
|
"Managed config path is not configured.");
|
||||||
EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedPath = set.ManagedFilePath!;
|
var optionWriteChanges = plan.OptionChanges
|
||||||
var backupPath = BuildBackupPath(managedPath);
|
.Select(c => new PendingEffectiveConfigChange(c.SectionId, c.OptionName, c.OldValue, c.NewValue, c.CurrentSourceFilePath))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Reject changes for options not supported by this dnsmasq build (e.g. DNSSEC when built without DNSSEC).
|
var unsupported = optionWriteChanges
|
||||||
var unsupported = changes
|
|
||||||
.Where(c => !EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(c.OptionName, version.Capabilities))
|
.Where(c => !EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(c.OptionName, version.Capabilities))
|
||||||
.Select(c => c.OptionName)
|
.Select(c => c.OptionName)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
@@ -115,23 +96,10 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
var userMessage = unsupported.Count == 1
|
var userMessage = unsupported.Count == 1
|
||||||
? firstReason
|
? firstReason
|
||||||
: $"Some of the options you changed aren't supported by your dnsmasq build. Options not saved: {optionList}.";
|
: $"Some of the options you changed aren't supported by your dnsmasq build. Options not saved: {optionList}.";
|
||||||
return new EffectiveConfigSaveResult(
|
return Fail(Array.Empty<DnsmasqManagedBackup>(), EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, userMessage);
|
||||||
BackupCreated: false,
|
|
||||||
BackupPath: null,
|
|
||||||
Saved: false,
|
|
||||||
Validated: false,
|
|
||||||
ValidationExitCode: -1,
|
|
||||||
ValidationStdOut: null,
|
|
||||||
ValidationStdErr: null,
|
|
||||||
Restarted: false,
|
|
||||||
RestartExitCode: -1,
|
|
||||||
RestartStdOut: null,
|
|
||||||
RestartStdErr: null,
|
|
||||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities,
|
|
||||||
UserMessage: userMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var semanticIssues = _semanticValidationService.Validate(changes);
|
var semanticIssues = _semanticValidationService.Validate(optionWriteChanges);
|
||||||
if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error))
|
if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error))
|
||||||
{
|
{
|
||||||
var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||||||
@@ -142,12 +110,41 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList();
|
var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList();
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}",
|
"Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}",
|
||||||
errorIssues.Count,
|
errorIssues.Count, failedFields, messages.FirstOrDefault());
|
||||||
failedFields,
|
return Fail(Array.Empty<DnsmasqManagedBackup>(), EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed, userMessage);
|
||||||
messages.FirstOrDefault());
|
}
|
||||||
|
|
||||||
|
var backups = CreateBackups(plan.Targets);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (plan.OptionChanges.Count > 0)
|
||||||
|
await _configService.ApplyEffectiveConfigChangesAsync(optionWriteChanges, ct);
|
||||||
|
if (plan.ManagedHostsChange != null)
|
||||||
|
{
|
||||||
|
await _hostsFileService.WriteAsync(plan.ManagedHostsChange.NewEntries, ct);
|
||||||
|
_logger.LogInformation("Managed hosts file written, {Count} entries", plan.ManagedHostsChange.NewEntries.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to write effective config or managed hosts");
|
||||||
|
// Roll back any partial write so we never leave one target overwritten and the other unchanged.
|
||||||
|
foreach (var backup in backups)
|
||||||
|
{
|
||||||
|
if (File.Exists(backup.BackupPath))
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(backup.TargetPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.Copy(backup.BackupPath, backup.TargetPath, overwrite: true);
|
||||||
|
_logger.LogInformation("Rolled back partial write: {BackupPath} -> {TargetPath}", backup.BackupPath, backup.TargetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backups.Count > 0)
|
||||||
|
_configSetCache.Invalidate();
|
||||||
return new EffectiveConfigSaveResult(
|
return new EffectiveConfigSaveResult(
|
||||||
BackupCreated: false,
|
backups,
|
||||||
BackupPath: null,
|
|
||||||
Saved: false,
|
Saved: false,
|
||||||
Validated: false,
|
Validated: false,
|
||||||
ValidationExitCode: -1,
|
ValidationExitCode: -1,
|
||||||
@@ -157,44 +154,16 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
RestartExitCode: -1,
|
RestartExitCode: -1,
|
||||||
RestartStdOut: null,
|
RestartStdOut: null,
|
||||||
RestartStdErr: null,
|
RestartStdErr: null,
|
||||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed,
|
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
|
||||||
UserMessage: userMessage);
|
UserMessage: ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
-1,
|
|
||||||
null,
|
|
||||||
ex.Message,
|
|
||||||
EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
|
|
||||||
"Failed to write config.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupCreated = File.Exists(backupPath);
|
|
||||||
var validateResult = await _validationService.ValidateAsync(ct);
|
var validateResult = await _validationService.ValidateAsync(ct);
|
||||||
|
|
||||||
if (!validateResult.Success)
|
if (!validateResult.Success)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Config saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr);
|
_logger.LogWarning("Changes saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr);
|
||||||
return new EffectiveConfigSaveResult(
|
return new EffectiveConfigSaveResult(
|
||||||
BackupCreated: backupCreated,
|
backups,
|
||||||
BackupPath: backupCreated ? backupPath : null,
|
|
||||||
Saved: true,
|
Saved: true,
|
||||||
Validated: false,
|
Validated: false,
|
||||||
ValidationExitCode: validateResult.ExitCode,
|
ValidationExitCode: validateResult.ExitCode,
|
||||||
@@ -209,13 +178,11 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var restartResult = await _reloadService.ReloadAsync(ct);
|
var restartResult = await _reloadService.ReloadAsync(ct);
|
||||||
|
|
||||||
if (!restartResult.Success)
|
if (!restartResult.Success)
|
||||||
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
||||||
|
|
||||||
return new EffectiveConfigSaveResult(
|
return new EffectiveConfigSaveResult(
|
||||||
BackupCreated: backupCreated,
|
backups,
|
||||||
BackupPath: backupCreated ? backupPath : null,
|
|
||||||
Saved: true,
|
Saved: true,
|
||||||
Validated: true,
|
Validated: true,
|
||||||
ValidationExitCode: 0,
|
ValidationExitCode: 0,
|
||||||
@@ -232,30 +199,39 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default)
|
public async Task<EffectiveConfigRestoreResult> RestoreAsync(
|
||||||
|
IReadOnlyList<DnsmasqManagedBackup> backups,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
if (backups == null || backups.Count == 0)
|
||||||
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Restore skipped: managed config path is not configured");
|
_logger.LogWarning("Restore skipped: no backups provided");
|
||||||
return new EffectiveConfigRestoreResult(
|
return new EffectiveConfigRestoreResult(
|
||||||
false, false, -1, null, "Managed config path is not configured.");
|
false, false, -1, null, "No backups available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(backupPath))
|
foreach (var backup in backups)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Restore skipped: backup file not found: {Path}", backupPath);
|
if (!File.Exists(backup.BackupPath))
|
||||||
return new EffectiveConfigRestoreResult(
|
{
|
||||||
false, false, -1, null, "Backup file not found.");
|
_logger.LogWarning("Restore skipped: backup file not found: {Path}", backup.BackupPath);
|
||||||
|
return new EffectiveConfigRestoreResult(
|
||||||
|
false, false, -1, null, $"Backup file not found: {backup.BackupPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var backup in backups)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(backup.TargetPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.Copy(backup.BackupPath, backup.TargetPath, overwrite: true);
|
||||||
|
_logger.LogInformation("Restored {Kind} from backup: {BackupPath} -> {TargetPath}", backup.Kind, backup.BackupPath, backup.TargetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedPath = set.ManagedFilePath!;
|
|
||||||
File.Copy(backupPath, managedPath, overwrite: true);
|
|
||||||
_configSetCache.Invalidate();
|
_configSetCache.Invalidate();
|
||||||
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
|
|
||||||
|
|
||||||
var restartResult = await _reloadService.ReloadAsync(ct);
|
var restartResult = await _reloadService.ReloadAsync(ct);
|
||||||
|
|
||||||
if (!restartResult.Success)
|
if (!restartResult.Success)
|
||||||
_logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
_logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
||||||
|
|
||||||
@@ -265,22 +241,45 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
RestartExitCode: restartResult.ExitCode,
|
RestartExitCode: restartResult.ExitCode,
|
||||||
RestartStdErr: restartResult.StdErr,
|
RestartStdErr: restartResult.StdErr,
|
||||||
UserMessage: restartResult.Success
|
UserMessage: restartResult.Success
|
||||||
? "Backup restored and dnsmasq restarted."
|
? "Backups restored and dnsmasq restarted."
|
||||||
: "Backup restored, but the restart command still failed.");
|
: "Backups restored, but the restart command still failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildBackupPath(string managedPath) =>
|
private static EffectiveConfigSaveResult Fail(
|
||||||
$"{managedPath}.bak.{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
IReadOnlyList<DnsmasqManagedBackup> backups,
|
||||||
|
string errorCode,
|
||||||
|
string userMessage) =>
|
||||||
|
new(backups, false, false, -1, null, null, false, -1, null, null, errorCode, userMessage);
|
||||||
|
|
||||||
private static void CreateBackupIfSourceExists(string managedPath, string backupPath)
|
private static DnsmasqSaveWritePlan BuildWritePlan(
|
||||||
|
IReadOnlyList<PendingDnsmasqChange> changes,
|
||||||
|
string managedConfigPath)
|
||||||
{
|
{
|
||||||
if (!File.Exists(managedPath))
|
var optionChanges = changes.OfType<PendingOptionChange>().ToList();
|
||||||
return;
|
var hostsChange = changes.OfType<PendingManagedHostsChange>().FirstOrDefault();
|
||||||
|
var targets = new List<DnsmasqManagedWriteTarget>();
|
||||||
|
if (optionChanges.Count > 0 && !string.IsNullOrWhiteSpace(managedConfigPath))
|
||||||
|
targets.Add(new DnsmasqManagedWriteTarget(DnsmasqManagedTargetKind.ManagedConfig, managedConfigPath));
|
||||||
|
if (hostsChange != null)
|
||||||
|
targets.Add(new DnsmasqManagedWriteTarget(DnsmasqManagedTargetKind.ManagedHosts, hostsChange.ManagedHostsFilePath));
|
||||||
|
return new DnsmasqSaveWritePlan(optionChanges, hostsChange, targets);
|
||||||
|
}
|
||||||
|
|
||||||
var dir = Path.GetDirectoryName(backupPath);
|
private static IReadOnlyList<DnsmasqManagedBackup> CreateBackups(IReadOnlyList<DnsmasqManagedWriteTarget> targets)
|
||||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
{
|
||||||
Directory.CreateDirectory(dir);
|
var list = new List<DnsmasqManagedBackup>();
|
||||||
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||||
File.Copy(managedPath, backupPath, overwrite: false);
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
if (!File.Exists(target.TargetPath))
|
||||||
|
continue;
|
||||||
|
var backupPath = $"{target.TargetPath}.bak.{timestamp}";
|
||||||
|
var dir = Path.GetDirectoryName(backupPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.Copy(target.TargetPath, backupPath, overwrite: false);
|
||||||
|
list.Add(new DnsmasqManagedBackup(target.Kind, target.TargetPath, backupPath));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -1,3 +1,4 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
@@ -6,9 +7,17 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds field descriptors for viewing and editing. Mapping is done once here via delegates passed to each descriptor.
|
/// Builds field descriptors for viewing and editing. Mapping is done once here via delegates passed to each descriptor.
|
||||||
|
/// Full graph is built via DI so the registry dependency remains explicit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EffectiveConfigFieldBuilder
|
public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
|
||||||
{
|
{
|
||||||
|
private readonly IEffectiveConfigRenderFragmentRegistry _registry;
|
||||||
|
|
||||||
|
public EffectiveConfigFieldBuilder(IEffectiveConfigRenderFragmentRegistry registry)
|
||||||
|
{
|
||||||
|
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||||
|
}
|
||||||
|
|
||||||
private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig;
|
private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig;
|
||||||
private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources;
|
private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources;
|
||||||
|
|
||||||
@@ -28,27 +37,18 @@ public static class EffectiveConfigFieldBuilder
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Section IDs for use by registry/views. Prefer EffectiveConfigSections for section list and option→section mapping.</summary>
|
/// <inheritdoc />
|
||||||
public static string SectionHosts => EffectiveConfigSections.SectionHosts;
|
public IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus status)
|
||||||
public static string SectionResolver => EffectiveConfigSections.SectionResolver;
|
|
||||||
public static string SectionDnsRecords => EffectiveConfigSections.SectionDnsRecords;
|
|
||||||
public static string SectionDhcp => EffectiveConfigSections.SectionDhcp;
|
|
||||||
public static string SectionTftpPxe => EffectiveConfigSections.SectionTftpPxe;
|
|
||||||
public static string SectionDnssec => EffectiveConfigSections.SectionDnssec;
|
|
||||||
public static string SectionCache => EffectiveConfigSections.SectionCache;
|
|
||||||
public static string SectionProcess => EffectiveConfigSections.SectionProcess;
|
|
||||||
|
|
||||||
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry = null)
|
|
||||||
{
|
{
|
||||||
if (status == null || status.EffectiveConfig == null)
|
if (status == null || status.EffectiveConfig == null)
|
||||||
return Array.Empty<EffectiveConfigFieldDescriptor>();
|
return Array.Empty<EffectiveConfigFieldDescriptor>();
|
||||||
|
|
||||||
var list = new List<EffectiveConfigFieldDescriptor>();
|
var list = new List<EffectiveConfigFieldDescriptor>();
|
||||||
AddAllDescriptors(list, status, registry);
|
AddAllDescriptors(list, status, _registry);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddAllDescriptors(List<EffectiveConfigFieldDescriptor> list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry)
|
private static void AddAllDescriptors(List<EffectiveConfigFieldDescriptor> list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry registry)
|
||||||
{
|
{
|
||||||
// Hosts
|
// Hosts
|
||||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
|
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
|
||||||
@@ -7,12 +8,14 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
|||||||
/// Single source of truth for options with special parse/write/validation semantics.
|
/// Single source of truth for options with special parse/write/validation semantics.
|
||||||
/// Prevents drift between parse, write, hint, and validation for UseStaleCache, AddMac, AddSubnet, Umbrella, Do0x20Encode.
|
/// Prevents drift between parse, write, hint, and validation for UseStaleCache, AddMac, AddSubnet, Umbrella, Do0x20Encode.
|
||||||
/// Inverse-pair key names are not stored here; see <see cref="EffectiveConfigSpecialOptionSemantics.GetInversePairKeys"/>.
|
/// Inverse-pair key names are not stored here; see <see cref="EffectiveConfigSpecialOptionSemantics.GetInversePairKeys"/>.
|
||||||
|
/// When set, <see cref="StructuredValueType"/> indicates the option has a typed structured value handler (e.g. dhcp-host → <see cref="DhcpHostEntry"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record OptionSemantics(
|
public sealed record OptionSemantics(
|
||||||
string OptionName,
|
string OptionName,
|
||||||
EffectiveConfigParserBehavior ParserBehavior,
|
EffectiveConfigParserBehavior ParserBehavior,
|
||||||
EffectiveConfigWriteBehavior WriteBehavior,
|
EffectiveConfigWriteBehavior WriteBehavior,
|
||||||
OptionValidationSemantics Validation
|
OptionValidationSemantics Validation,
|
||||||
|
Type? StructuredValueType = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -220,7 +223,8 @@ public static class EffectiveConfigSpecialOptionSemantics
|
|||||||
DnsmasqConfKeys.DhcpHost,
|
DnsmasqConfKeys.DhcpHost,
|
||||||
EffectiveConfigParserBehavior.Multi,
|
EffectiveConfigParserBehavior.Multi,
|
||||||
EffectiveConfigWriteBehavior.MultiValue,
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
ComplexMulti),
|
ComplexMulti,
|
||||||
|
typeof(DhcpHostEntry)),
|
||||||
[DnsmasqConfKeys.DhcpOption] = new OptionSemantics(
|
[DnsmasqConfKeys.DhcpOption] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.DhcpOption,
|
DnsmasqConfKeys.DhcpOption,
|
||||||
EffectiveConfigParserBehavior.Multi,
|
EffectiveConfigParserBehavior.Multi,
|
||||||
@@ -507,6 +511,10 @@ public static class EffectiveConfigSpecialOptionSemantics
|
|||||||
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
||||||
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
||||||
|
|
||||||
|
/// <summary>Returns the structured value type for an option that has a typed handler (e.g. dhcp-host → DhcpHostEntry); null otherwise.</summary>
|
||||||
|
public static Type? GetStructuredValueType(string optionName) =>
|
||||||
|
TryGetSemantics(optionName)?.StructuredValueType;
|
||||||
|
|
||||||
/// <summary>Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists.</summary>
|
/// <summary>Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists.</summary>
|
||||||
public static IReadOnlyCollection<string> GetAllOptionNames() => ByOptionName.Keys.ToList();
|
public static IReadOnlyCollection<string> GetAllOptionNames() => ByOptionName.Keys.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-33
@@ -1,12 +1,11 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines which sections and (optionally) which fields are visible per EffectiveConfigContext.
|
/// Defines which sections and (optionally) which fields are visible per EffectiveConfigContext.
|
||||||
/// Single place to add new contexts or change what shows on Hosts/DHCP/etc.
|
/// Visibility only; descriptor construction is done by <see cref="IEffectiveConfigDescriptorProvider"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EffectiveConfigViews
|
public static class EffectiveConfigViews
|
||||||
{
|
{
|
||||||
@@ -81,34 +80,4 @@ public static class EffectiveConfigViews
|
|||||||
DnsmasqConfKeys.NoRoundRobin,
|
DnsmasqConfKeys.NoRoundRobin,
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For each section in the view, returns the context-visible descriptors for that section.
|
|
||||||
/// Does not apply search; component applies search to each section's list and hides sections with no matches.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registry">When provided, used to create the correct descriptor type per field (e.g. integer options).</param>
|
|
||||||
public static IReadOnlyDictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>> GetDescriptorsBySection(
|
|
||||||
DnsmasqServiceStatus? status,
|
|
||||||
IReadOnlyList<EffectiveConfigSectionView> views,
|
|
||||||
IEffectiveConfigRenderFragmentRegistry? registry = null)
|
|
||||||
{
|
|
||||||
if (status == null || views.Count == 0)
|
|
||||||
return new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
|
|
||||||
|
|
||||||
var allDescriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(status, registry);
|
|
||||||
var result = new Dictionary<string, List<EffectiveConfigFieldDescriptor>>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var view in views)
|
|
||||||
{
|
|
||||||
var list = allDescriptors
|
|
||||||
.Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase))
|
|
||||||
.Where(d => view.AllowedOptionNames == null
|
|
||||||
|| view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
if (list.Count > 0)
|
|
||||||
result[view.SectionId] = list;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<EffectiveConfigFieldDescriptor>)kv.Value, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-50
@@ -44,36 +44,36 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
|
|
||||||
private void RegisterCustomSingles()
|
private void RegisterCustomSingles()
|
||||||
{
|
{
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
|
RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
RegisterComponent(EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterIntegerDescriptors()
|
private void RegisterIntegerDescriptors()
|
||||||
{
|
{
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.PortLimit, max: 65535);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.PortLimit, max: 65535);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MinPort, min: 1, max: 65535);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.MinPort, min: 1, max: 65535);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MaxPort, min: 1, max: 65535);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.MaxPort, min: 1, max: 65535);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsForwardMax, min: 1);
|
RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.DnsForwardMax, min: 1);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheSize, min: 0);
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.CacheSize, min: 0);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.LocalTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.LocalTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NegTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.NegTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MaxTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MaxTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MaxCacheTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MaxCacheTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
RegisterInteger(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
RegisterInteger(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterFlagDisplays()
|
private void RegisterFlagDisplays()
|
||||||
{
|
{
|
||||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers);
|
RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
DnsmasqConfKeys.ExpandHosts,
|
DnsmasqConfKeys.ExpandHosts,
|
||||||
DnsmasqConfKeys.BogusPriv,
|
DnsmasqConfKeys.BogusPriv,
|
||||||
DnsmasqConfKeys.StrictOrder,
|
DnsmasqConfKeys.StrictOrder,
|
||||||
@@ -90,7 +90,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
DnsmasqConfKeys.LocaliseQueries,
|
DnsmasqConfKeys.LocaliseQueries,
|
||||||
DnsmasqConfKeys.NoRoundRobin);
|
DnsmasqConfKeys.NoRoundRobin);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
DnsmasqConfKeys.DhcpAuthoritative,
|
DnsmasqConfKeys.DhcpAuthoritative,
|
||||||
DnsmasqConfKeys.DhcpRapidCommit,
|
DnsmasqConfKeys.DhcpRapidCommit,
|
||||||
DnsmasqConfKeys.LeasefileRo,
|
DnsmasqConfKeys.LeasefileRo,
|
||||||
@@ -101,25 +101,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
DnsmasqConfKeys.ScriptOnRenewal,
|
DnsmasqConfKeys.ScriptOnRenewal,
|
||||||
DnsmasqConfKeys.DhcpNoOverride);
|
DnsmasqConfKeys.DhcpNoOverride);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
EffectiveConfigSections.SectionTftpPxe,
|
||||||
DnsmasqConfKeys.EnableTftp,
|
DnsmasqConfKeys.EnableTftp,
|
||||||
DnsmasqConfKeys.TftpSecure,
|
DnsmasqConfKeys.TftpSecure,
|
||||||
DnsmasqConfKeys.TftpNoFail,
|
DnsmasqConfKeys.TftpNoFail,
|
||||||
DnsmasqConfKeys.TftpNoBlocksize);
|
DnsmasqConfKeys.TftpNoBlocksize);
|
||||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx);
|
RegisterFlags(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
EffectiveConfigSections.SectionDnssec,
|
||||||
DnsmasqConfKeys.Dnssec,
|
DnsmasqConfKeys.Dnssec,
|
||||||
DnsmasqConfKeys.ProxyDnssec,
|
DnsmasqConfKeys.ProxyDnssec,
|
||||||
DnsmasqConfKeys.DnssecNoTimecheck,
|
DnsmasqConfKeys.DnssecNoTimecheck,
|
||||||
DnsmasqConfKeys.DnssecDebug);
|
DnsmasqConfKeys.DnssecDebug);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionCache,
|
EffectiveConfigSections.SectionCache,
|
||||||
DnsmasqConfKeys.NoNegcache,
|
DnsmasqConfKeys.NoNegcache,
|
||||||
DnsmasqConfKeys.StripMac,
|
DnsmasqConfKeys.StripMac,
|
||||||
DnsmasqConfKeys.StripSubnet);
|
DnsmasqConfKeys.StripSubnet);
|
||||||
RegisterFlags(
|
RegisterFlags(
|
||||||
EffectiveConfigFieldBuilder.SectionProcess,
|
EffectiveConfigSections.SectionProcess,
|
||||||
DnsmasqConfKeys.NoPoll,
|
DnsmasqConfKeys.NoPoll,
|
||||||
DnsmasqConfKeys.BindInterfaces,
|
DnsmasqConfKeys.BindInterfaces,
|
||||||
DnsmasqConfKeys.BindDynamic,
|
DnsmasqConfKeys.BindDynamic,
|
||||||
@@ -138,22 +138,22 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
private void RegisterKeyOnlyOrValueDisplays()
|
private void RegisterKeyOnlyOrValueDisplays()
|
||||||
{
|
{
|
||||||
RegisterSemanticSingleComponent(
|
RegisterSemanticSingleComponent(
|
||||||
EffectiveConfigFieldBuilder.SectionCache,
|
EffectiveConfigSections.SectionCache,
|
||||||
typeof(KeyOnlyOrValueDisplay),
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
DnsmasqConfKeys.UseStaleCache,
|
DnsmasqConfKeys.UseStaleCache,
|
||||||
DnsmasqConfKeys.AddMac,
|
DnsmasqConfKeys.AddMac,
|
||||||
DnsmasqConfKeys.AddSubnet,
|
DnsmasqConfKeys.AddSubnet,
|
||||||
DnsmasqConfKeys.Umbrella);
|
DnsmasqConfKeys.Umbrella);
|
||||||
RegisterSemanticSingleComponent(
|
RegisterSemanticSingleComponent(
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
typeof(KeyOnlyOrValueDisplay),
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||||
RegisterSemanticSingleComponent(
|
RegisterSemanticSingleComponent(
|
||||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
EffectiveConfigSections.SectionDnssec,
|
||||||
typeof(KeyOnlyOrValueDisplay),
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
DnsmasqConfKeys.DnssecCheckUnsigned);
|
DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||||
RegisterComponents(
|
RegisterComponents(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
typeof(KeyOnlyOrValueDisplay),
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
DnsmasqConfKeys.DhcpGenerateNames,
|
DnsmasqConfKeys.DhcpGenerateNames,
|
||||||
DnsmasqConfKeys.DhcpBroadcast,
|
DnsmasqConfKeys.DhcpBroadcast,
|
||||||
@@ -163,38 +163,38 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
private void RegisterPathValidatedSingles()
|
private void RegisterPathValidatedSingles()
|
||||||
{
|
{
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionHosts,
|
EffectiveConfigSections.SectionHosts,
|
||||||
DnsmasqConfKeys.Hostsdir);
|
DnsmasqConfKeys.Hostsdir);
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
DnsmasqConfKeys.DhcpLeasefile,
|
DnsmasqConfKeys.DhcpLeasefile,
|
||||||
DnsmasqConfKeys.DhcpScript,
|
DnsmasqConfKeys.DhcpScript,
|
||||||
DnsmasqConfKeys.DhcpLuascript);
|
DnsmasqConfKeys.DhcpLuascript);
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
EffectiveConfigSections.SectionTftpPxe,
|
||||||
DnsmasqConfKeys.TftpRoot);
|
DnsmasqConfKeys.TftpRoot);
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
EffectiveConfigSections.SectionDnssec,
|
||||||
DnsmasqConfKeys.DnssecTimestamp);
|
DnsmasqConfKeys.DnssecTimestamp);
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionCache,
|
EffectiveConfigSections.SectionCache,
|
||||||
DnsmasqConfKeys.Dumpfile);
|
DnsmasqConfKeys.Dumpfile);
|
||||||
RegisterSemanticSingles(
|
RegisterSemanticSingles(
|
||||||
EffectiveConfigFieldBuilder.SectionProcess,
|
EffectiveConfigSections.SectionProcess,
|
||||||
DnsmasqConfKeys.PidFile);
|
DnsmasqConfKeys.PidFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterMultiValueDescriptors()
|
private void RegisterMultiValueDescriptors()
|
||||||
{
|
{
|
||||||
RegisterSemanticMultiDescriptor(
|
RegisterSemanticMultiDescriptor(
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
DnsmasqConfKeys.Server,
|
DnsmasqConfKeys.Server,
|
||||||
behavior: new DistinctMultiValueEditBehavior());
|
behavior: new DistinctMultiValueEditBehavior());
|
||||||
RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ListenAddress);
|
RegisterSemanticMultiDescriptor(EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.ListenAddress);
|
||||||
|
|
||||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
RegisterSemanticMultis(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
DnsmasqConfKeys.Local,
|
DnsmasqConfKeys.Local,
|
||||||
DnsmasqConfKeys.ResolvFile,
|
DnsmasqConfKeys.ResolvFile,
|
||||||
DnsmasqConfKeys.RevServer,
|
DnsmasqConfKeys.RevServer,
|
||||||
@@ -207,7 +207,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
DnsmasqConfKeys.Nftset,
|
DnsmasqConfKeys.Nftset,
|
||||||
DnsmasqConfKeys.ConnmarkAllowlist);
|
DnsmasqConfKeys.ConnmarkAllowlist);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
DnsmasqConfKeys.DhcpRange,
|
DnsmasqConfKeys.DhcpRange,
|
||||||
DnsmasqConfKeys.DhcpHost,
|
DnsmasqConfKeys.DhcpHost,
|
||||||
DnsmasqConfKeys.DhcpOption,
|
DnsmasqConfKeys.DhcpOption,
|
||||||
@@ -234,10 +234,10 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
DnsmasqConfKeys.Slaac);
|
DnsmasqConfKeys.Slaac);
|
||||||
|
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
DnsmasqConfKeys.FilterRr);
|
DnsmasqConfKeys.FilterRr);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionDnsRecords,
|
EffectiveConfigSections.SectionDnsRecords,
|
||||||
DnsmasqConfKeys.Domain,
|
DnsmasqConfKeys.Domain,
|
||||||
DnsmasqConfKeys.Cname,
|
DnsmasqConfKeys.Cname,
|
||||||
DnsmasqConfKeys.MxHost,
|
DnsmasqConfKeys.MxHost,
|
||||||
@@ -256,20 +256,20 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
DnsmasqConfKeys.AuthSecServers,
|
DnsmasqConfKeys.AuthSecServers,
|
||||||
DnsmasqConfKeys.AuthPeer);
|
DnsmasqConfKeys.AuthPeer);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
EffectiveConfigSections.SectionDhcp,
|
||||||
DnsmasqConfKeys.DhcpCircuitid,
|
DnsmasqConfKeys.DhcpCircuitid,
|
||||||
DnsmasqConfKeys.DhcpRemoteid,
|
DnsmasqConfKeys.DhcpRemoteid,
|
||||||
DnsmasqConfKeys.DhcpSubscrid);
|
DnsmasqConfKeys.DhcpSubscrid);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
EffectiveConfigSections.SectionTftpPxe,
|
||||||
DnsmasqConfKeys.PxeService);
|
DnsmasqConfKeys.PxeService);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
EffectiveConfigSections.SectionTftpPxe,
|
||||||
DnsmasqConfKeys.DhcpOptionPxe);
|
DnsmasqConfKeys.DhcpOptionPxe);
|
||||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
|
RegisterSemanticMultis(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
|
||||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
|
RegisterSemanticMultis(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.CacheRr);
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionProcess,
|
EffectiveConfigSections.SectionProcess,
|
||||||
DnsmasqConfKeys.Interface,
|
DnsmasqConfKeys.Interface,
|
||||||
DnsmasqConfKeys.ExceptInterface,
|
DnsmasqConfKeys.ExceptInterface,
|
||||||
DnsmasqConfKeys.AuthServer,
|
DnsmasqConfKeys.AuthServer,
|
||||||
|
|||||||
+4
-4
@@ -15,7 +15,7 @@ public static class EffectiveConfigCrossOptionEvaluator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<FieldIssue> Evaluate(
|
public static IReadOnlyList<FieldIssue> Evaluate(
|
||||||
DnsmasqServiceStatus? status,
|
DnsmasqServiceStatus? status,
|
||||||
IReadOnlyList<PendingEffectiveConfigChange> pending)
|
IReadOnlyList<PendingOptionChange> pending)
|
||||||
{
|
{
|
||||||
var issues = new List<FieldIssue>();
|
var issues = new List<FieldIssue>();
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public static class EffectiveConfigCrossOptionEvaluator
|
|||||||
|
|
||||||
private static bool GetEffectiveBool(
|
private static bool GetEffectiveBool(
|
||||||
DnsmasqServiceStatus? status,
|
DnsmasqServiceStatus? status,
|
||||||
IReadOnlyList<PendingEffectiveConfigChange>? pending,
|
IReadOnlyList<PendingOptionChange>? pending,
|
||||||
string optionName,
|
string optionName,
|
||||||
Func<DnsmasqServiceStatus?, bool> fromConfig)
|
Func<DnsmasqServiceStatus?, bool> fromConfig)
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,7 @@ public static class EffectiveConfigCrossOptionEvaluator
|
|||||||
|
|
||||||
private static int? GetEffectiveInt(
|
private static int? GetEffectiveInt(
|
||||||
DnsmasqServiceStatus? status,
|
DnsmasqServiceStatus? status,
|
||||||
IReadOnlyList<PendingEffectiveConfigChange>? pending,
|
IReadOnlyList<PendingOptionChange>? pending,
|
||||||
string optionName,
|
string optionName,
|
||||||
Func<DnsmasqServiceStatus?, int?> fromConfig)
|
Func<DnsmasqServiceStatus?, int?> fromConfig)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ public static class EffectiveConfigCrossOptionEvaluator
|
|||||||
return fromConfig(status);
|
return fromConfig(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<string>? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList<PendingEffectiveConfigChange> pending)
|
private static IReadOnlyList<string>? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList<PendingOptionChange> pending)
|
||||||
{
|
{
|
||||||
var fromConfig = status?.EffectiveConfig?.ServerValues;
|
var fromConfig = status?.EffectiveConfig?.ServerValues;
|
||||||
var pendingChange = pending?.FirstOrDefault(c =>
|
var pendingChange = pending?.FirstOrDefault(c =>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>Kind of managed file written by the save flow.</summary>
|
||||||
|
public enum DnsmasqManagedTargetKind
|
||||||
|
{
|
||||||
|
ManagedConfig,
|
||||||
|
ManagedHosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One file that may be written during save (config or managed hosts).</summary>
|
||||||
|
public sealed record DnsmasqManagedWriteTarget(
|
||||||
|
DnsmasqManagedTargetKind Kind,
|
||||||
|
string TargetPath);
|
||||||
|
|
||||||
|
/// <summary>One backup created before write (target path + backup path).</summary>
|
||||||
|
public sealed record DnsmasqManagedBackup(
|
||||||
|
DnsmasqManagedTargetKind Kind,
|
||||||
|
string TargetPath,
|
||||||
|
string BackupPath);
|
||||||
|
|
||||||
|
/// <summary>Plan built from pending changes: which option changes, hosts change, and which targets to back up and write.</summary>
|
||||||
|
public sealed record DnsmasqSaveWritePlan(
|
||||||
|
IReadOnlyList<PendingOptionChange> OptionChanges,
|
||||||
|
PendingManagedHostsChange? ManagedHostsChange,
|
||||||
|
IReadOnlyList<DnsmasqManagedWriteTarget> Targets);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed identity for an effective-config field (section + option).
|
||||||
|
/// Use for activation, revert, and field issues instead of loose string pairs.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct EffectiveConfigFieldRef(string SectionId, string OptionName)
|
||||||
|
{
|
||||||
|
/// <summary>Key used by session (ActivateField, SetFieldIssues, etc.).</summary>
|
||||||
|
public string FieldKey => $"{SectionId}:{OptionName}";
|
||||||
|
|
||||||
|
public static EffectiveConfigFieldRef For(string sectionId, string optionName)
|
||||||
|
=> new(sectionId, optionName);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
/// <summary>Result of the effective-config save flow (backup, write, validate, restart). Used by UI to drive state and show restore/continue options.</summary>
|
/// <summary>Result of the effective-config save flow (backup, write, validate, restart). 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="Backups">Backups created before write (one per target file touched). Empty when no write occurred or no backups were created.</param>
|
||||||
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
|
/// <param name="Saved">True when all managed files were written successfully.</param>
|
||||||
/// <param name="Saved">True when the managed config was written successfully.</param>
|
|
||||||
/// <param name="Validated">True when validation was run and succeeded (or was skipped). When validation ran and failed, restart is not attempted.</param>
|
/// <param name="Validated">True when validation was run and succeeded (or was skipped). When validation ran and failed, restart is not attempted.</param>
|
||||||
/// <param name="ValidationExitCode">Validation command exit code; -1 when not run or failed to start.</param>
|
/// <param name="ValidationExitCode">Validation command exit code; -1 when not run or failed to start.</param>
|
||||||
/// <param name="ValidationStdOut">Standard output from the validation command.</param>
|
/// <param name="ValidationStdOut">Standard output from the validation command.</param>
|
||||||
@@ -15,8 +14,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|||||||
/// <param name="ErrorCode">Machine-readable code: see <see cref="ErrorCodes"/>; null when success.</param>
|
/// <param name="ErrorCode">Machine-readable code: see <see cref="ErrorCodes"/>; null when success.</param>
|
||||||
/// <param name="UserMessage">Short message for the user.</param>
|
/// <param name="UserMessage">Short message for the user.</param>
|
||||||
public record EffectiveConfigSaveResult(
|
public record EffectiveConfigSaveResult(
|
||||||
bool BackupCreated,
|
IReadOnlyList<DnsmasqManagedBackup> Backups,
|
||||||
string? BackupPath,
|
|
||||||
bool Saved,
|
bool Saved,
|
||||||
bool Validated,
|
bool Validated,
|
||||||
int ValidationExitCode,
|
int ValidationExitCode,
|
||||||
@@ -29,6 +27,8 @@ public record EffectiveConfigSaveResult(
|
|||||||
string? ErrorCode,
|
string? ErrorCode,
|
||||||
string? UserMessage)
|
string? UserMessage)
|
||||||
{
|
{
|
||||||
|
/// <summary>True when at least one backup was created (for restore UI).</summary>
|
||||||
|
public bool BackupCreated => Backups.Count > 0;
|
||||||
/// <summary>Machine-readable error codes for <see cref="ErrorCode"/>.</summary>
|
/// <summary>Machine-readable error codes for <see cref="ErrorCode"/>.</summary>
|
||||||
public static class ErrorCodes
|
public static class ErrorCodes
|
||||||
{
|
{
|
||||||
@@ -51,5 +51,5 @@ public record EffectiveConfigSaveResult(
|
|||||||
|
|
||||||
/// <summary>Result when there are no pending changes to apply.</summary>
|
/// <summary>Result when there are no pending changes to apply.</summary>
|
||||||
public static EffectiveConfigSaveResult NoChanges() =>
|
public static EffectiveConfigSaveResult NoChanges() =>
|
||||||
new(false, null, false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes.");
|
new(Array.Empty<DnsmasqManagedBackup>(), false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>Base type for a pending change (config option or managed hosts). Used by the edit session and save flow.</summary>
|
||||||
|
public abstract record PendingDnsmasqChange(string ChangeKey);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>A pending change for the managed hosts file. Written via IHostsFileService on save.</summary>
|
||||||
|
public sealed record PendingManagedHostsChange(
|
||||||
|
IReadOnlyList<HostEntry> OldEntries,
|
||||||
|
IReadOnlyList<HostEntry> NewEntries,
|
||||||
|
string ManagedHostsFilePath)
|
||||||
|
: PendingDnsmasqChange("hosts:managed")
|
||||||
|
{
|
||||||
|
/// <summary>True when NewEntries differs from OldEntries.</summary>
|
||||||
|
public bool HasChanges => !EntriesEqual(OldEntries, NewEntries);
|
||||||
|
|
||||||
|
private static bool EntriesEqual(IReadOnlyList<HostEntry> left, IReadOnlyList<HostEntry> right)
|
||||||
|
{
|
||||||
|
if (left.Count != right.Count) return false;
|
||||||
|
for (var i = 0; i < left.Count; i++)
|
||||||
|
{
|
||||||
|
var a = left[i];
|
||||||
|
var b = right[i];
|
||||||
|
if (a.Id != b.Id ||
|
||||||
|
a.Address != b.Address ||
|
||||||
|
!(a.Names ?? new List<string>()).SequenceEqual(b.Names ?? new List<string>(), StringComparer.Ordinal) ||
|
||||||
|
a.RawLine != b.RawLine ||
|
||||||
|
a.IsComment != b.IsComment ||
|
||||||
|
a.IsPassthrough != b.IsPassthrough)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>A pending change for one config option. Written to the managed config file on save.</summary>
|
||||||
|
public sealed record PendingOptionChange(
|
||||||
|
string SectionId,
|
||||||
|
string OptionName,
|
||||||
|
object? OldValue,
|
||||||
|
object? NewValue,
|
||||||
|
string? CurrentSourceFilePath = null)
|
||||||
|
: PendingDnsmasqChange($"{SectionId}:{OptionName}");
|
||||||
Reference in New Issue
Block a user