Files
dnsmasq-webui/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ManagedHostsPendingChangeDiff.cs
T
alex f0b4ea97a4 Hosts page UX, draft/UI split, and managed hosts save summary
- Split effective-config session into Draft + Ui (IEffectiveConfigDraft, IEffectiveConfigUiState)
- Hosts: managed overlay, edit mode from table, validation deferred to blur/Escape/Enter
- Managed hosts save modal: line-level diff (add/remove/modify/order)
- Hosts group headers: editable/read-only badges, empty-group row, Effective names help
- hosts-ui.js for touch vs hover help; table inline bind oninput for live preview
- HostsEntryEditValidator and ManagedHostsPendingChangeDiff with tests
2026-04-01 11:27:55 +10:00

127 lines
4.7 KiB
C#

using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
public enum ManagedHostsLineDiffKind
{
Added,
Removed,
Modified,
/// <summary>Same entries (by id) but a different order in the file.</summary>
OrderOnly
}
/// <summary>One line-level change for display in the save modal (before → after).</summary>
public readonly record struct ManagedHostsLineDiff(
ManagedHostsLineDiffKind Kind,
string? BeforeLine,
string? AfterLine);
/// <summary>Builds a line-level diff between baseline and draft managed-hosts entries.</summary>
public static class ManagedHostsPendingChangeDiff
{
public static IReadOnlyList<ManagedHostsLineDiff> Build(PendingManagedHostsChange change) =>
Build(change.OldEntries, change.NewEntries);
public static IReadOnlyList<ManagedHostsLineDiff> Build(
IReadOnlyList<HostEntry> oldEntries,
IReadOnlyList<HostEntry> newEntries)
{
var oldMap = ToUniqueMap(oldEntries);
var newMap = ToUniqueMap(newEntries);
var keys = oldMap.Keys.Union(newMap.Keys, StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal);
var list = new List<ManagedHostsLineDiff>();
foreach (var key in keys)
{
oldMap.TryGetValue(key, out var o);
newMap.TryGetValue(key, out var n);
if (o == null && n != null)
list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Added, null, FormatEntry(n)));
else if (o != null && n == null)
list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Removed, FormatEntry(o), null));
else if (o != null && n != null && !ContentEquals(o, n))
list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.Modified, FormatEntry(o), FormatEntry(n)));
}
if (list.Count == 0 && DetectOrderOnly(oldEntries, newEntries))
list.Add(new ManagedHostsLineDiff(ManagedHostsLineDiffKind.OrderOnly, null, null));
return list
.OrderBy(d => SortOrder(d))
.ToList();
}
private static int SortOrder(ManagedHostsLineDiff d) => d.Kind switch
{
ManagedHostsLineDiffKind.Removed => 0,
ManagedHostsLineDiffKind.Modified => 1,
ManagedHostsLineDiffKind.OrderOnly => 2,
ManagedHostsLineDiffKind.Added => 3,
_ => 4
};
private static bool DetectOrderOnly(IReadOnlyList<HostEntry> oldList, IReadOnlyList<HostEntry> newList)
{
if (oldList.Count != newList.Count || oldList.Count == 0)
return false;
var oldKeys = oldList.Select(e => StableKey(e)).OrderBy(k => k, StringComparer.Ordinal).ToList();
var newKeys = newList.Select(e => StableKey(e)).OrderBy(k => k, StringComparer.Ordinal).ToList();
if (!oldKeys.SequenceEqual(newKeys, StringComparer.Ordinal))
return false;
for (var i = 0; i < oldList.Count; i++)
{
if (!string.Equals(StableKey(oldList[i]), StableKey(newList[i]), StringComparison.Ordinal))
return true;
}
return false;
}
private static Dictionary<string, HostEntry> ToUniqueMap(IReadOnlyList<HostEntry> entries)
{
var map = new Dictionary<string, HostEntry>(StringComparer.Ordinal);
foreach (var e in entries)
{
var k = StableKey(e);
if (!map.ContainsKey(k))
map[k] = e;
}
return map;
}
/// <summary>Matches <see cref="PendingManagedHostsChange"/> identity: Id, or managed:line when Id is empty.</summary>
public static string StableKey(HostEntry e) =>
!string.IsNullOrEmpty(e.Id) ? e.Id : $"managed:{e.LineNumber}";
public static string FormatEntry(HostEntry e)
{
if (e.IsPassthrough)
return string.IsNullOrEmpty(e.RawLine) ? "(unparsed line)" : e.RawLine.TrimEnd();
if (e.IsComment)
return string.IsNullOrEmpty(e.RawLine) ? "#" : e.RawLine.TrimEnd();
var names = e.Names ?? new List<string>();
if (string.IsNullOrWhiteSpace(e.Address) && names.Count == 0)
return "(empty)";
return $"{e.Address} {string.Join(' ', names)}".Trim();
}
private static bool ContentEquals(HostEntry a, HostEntry b)
{
if (string.Equals(a.Address ?? "", b.Address ?? "", StringComparison.Ordinal))
{
var an = a.Names ?? new List<string>();
var bn = b.Names ?? new List<string>();
if (an.SequenceEqual(bn, StringComparer.Ordinal)
&& a.IsComment == b.IsComment
&& a.IsPassthrough == b.IsPassthrough
&& string.Equals(a.RawLine ?? "", b.RawLine ?? "", StringComparison.Ordinal))
return true;
}
return false;
}
}