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
This commit is contained in:
2026-04-01 11:27:55 +10:00
parent 1bbab1c8c3
commit f0b4ea97a4
28 changed files with 1267 additions and 384 deletions
@@ -0,0 +1,90 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class ManagedHostsPendingChangeDiffTests
{
[Fact]
public void Build_same_entries_returns_empty()
{
var e = new HostEntry { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } };
var oldL = new List<HostEntry> { e };
var newL = new List<HostEntry>
{
new()
{
Id = e.Id,
LineNumber = e.LineNumber,
Address = e.Address,
Names = new List<string>(e.Names)
}
};
Assert.Empty(ManagedHostsPendingChangeDiff.Build(oldL, newL));
}
[Fact]
public void Build_added_entry()
{
var oldL = new List<HostEntry>();
var newL = new List<HostEntry>
{
new() { Id = "new:x", LineNumber = 1, Address = "192.168.0.1", Names = new List<string> { "router" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var a = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Added, a.Kind);
Assert.Contains("192.168.0.1", a.AfterLine, StringComparison.Ordinal);
Assert.Contains("router", a.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_removed_entry()
{
var oldL = new List<HostEntry>
{
new() { Id = "1", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "x" } }
};
var newL = new List<HostEntry>();
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var r = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Removed, r.Kind);
Assert.Contains("10.0.0.2", r.BeforeLine, StringComparison.Ordinal);
}
[Fact]
public void Build_modified_address()
{
var oldL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } }
};
var newL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.9", Names = new List<string> { "h" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var m = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Modified, m.Kind);
Assert.Contains("10.0.0.1", m.BeforeLine, StringComparison.Ordinal);
Assert.Contains("10.0.0.9", m.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_order_only()
{
var oldL = new List<HostEntry>
{
new() { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "a" } },
new() { Id = "b", LineNumber = 2, Address = "10.0.0.2", Names = new List<string> { "b" } }
};
var newL = new List<HostEntry>
{
new() { Id = "b", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "b" } },
new() { Id = "a", LineNumber = 2, Address = "10.0.0.1", Names = new List<string> { "a" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var o = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.OrderOnly, o.Kind);
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEntryEditValidatorTests
{
[Fact]
public void Empty_is_invalid()
{
var e = HostsEntryEditValidator.GetErrors("", Array.Empty<string>());
Assert.NotEmpty(e);
}
[Fact]
public void Valid_ipv4_and_name_ok()
{
Assert.True(HostsEntryEditValidator.IsValid("192.168.1.1", new[] { "router" }));
}
[Fact]
public void Missing_names_invalid()
{
var e = HostsEntryEditValidator.GetErrors("10.0.0.1", Array.Empty<string>());
Assert.Contains(e, x => x.Contains("hostname", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Field_errors_split_address_and_names()
{
Assert.NotEmpty(HostsEntryEditValidator.GetAddressFieldErrors(""));
Assert.Empty(HostsEntryEditValidator.GetHostnameFieldErrors(new[] { "h" }));
Assert.NotEmpty(HostsEntryEditValidator.GetHostnameFieldErrors(Array.Empty<string>()));
}
}
@@ -14,10 +14,10 @@ public class EffectiveConfigEditSessionChangedEventTests
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.EnterEditMode();
session.Ui.EnterEditMode();
var raised = false;
session.Changed += () => raised = true;
session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
Assert.True(raised);
}
@@ -28,7 +28,7 @@ public class EffectiveConfigEditSessionChangedEventTests
var session = new EffectiveConfigEditSession(saveService);
var raised = false;
session.Changed += () => raised = true;
session.EnterEditMode();
session.Ui.EnterEditMode();
Assert.True(raised);
}
@@ -37,27 +37,39 @@ public class EffectiveConfigEditSessionChangedEventTests
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.EnterEditMode();
session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
session.Ui.EnterEditMode();
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
var raised = false;
session.Changed += () => raised = true;
session.RevertChange("resolver", "port");
session.Draft.RevertChange("resolver", "port");
Assert.True(raised);
}
[Fact]
public void TrackManagedHostsChange_StoresChange_AndRevertManagedHostsChange_RemovesIt()
public void Ui_EnterEditMode_DoesNotClearDraft()
{
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> { "b" } } };
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/tmp/managed.hosts");
Assert.Single(session.Draft.PendingChanges);
session.Ui.EnterEditMode();
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
[Fact]
public void SetManagedHostsDraft_StoresChange_AndRevertManagedHostsDraft_RemovesIt()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.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>());
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/etc/hosts");
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
session.Draft.RevertManagedHostsDraft();
Assert.Empty(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
private sealed class StubSaveService : IEffectiveConfigSaveService
@@ -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>
protected object? GetEffectiveDisplayValue()
{
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
return pending != null ? pending.NewValue : Descriptor.GetValue();
@@ -18,17 +18,17 @@
var _allSectionsExpanded = _sectionViews.Count > 0 && _sectionViews.All(v => _openPanels.Contains(v.SectionId));
}
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
ValidationSummary="@Session.GetValidationSummary()"
IsEditMode="@Session.Ui.IsEditMode" PendingChangesCount="@Session.Draft.PendingChanges.Count"
ValidationSummary="@Session.Draft.GetValidationSummary()"
AllSectionsExpanded="@_allSectionsExpanded"
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
<CascadingValue Value="@Session">
<CascadingValue Value="@_uiContext">
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey" />
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.Ui.IsEditMode" ActiveFieldKey="@Session.Ui.ActiveFieldKey" />
</CascadingValue>
</CascadingValue>
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.Draft.PendingChanges" Status="@Status" Session="@Session"
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
</div>
}
@@ -71,9 +71,9 @@
return Task.CompletedTask;
},
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
DeactivateFieldAsync = () => { Session.Ui.DeactivateField(); return InvokeAsync(StateHasChanged); },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
RevertFieldAsync = (sectionId, optionName) => { Session.Draft.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
};
}
@@ -154,14 +154,14 @@
private void EnterEditMode()
{
Session.EnterEditMode();
Session.Ui.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged();
}
private async Task ExitEditModeWithConfirmAsync()
{
if (Session.PendingChanges.Count > 0)
if (Session.Draft.PendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
try
@@ -174,41 +174,43 @@
await module.DisposeAsync();
}
}
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
StateHasChanged();
}
private void DeactivateActiveFieldIfClicked()
{
if (Session.IsEditMode && Session.ActiveFieldKey != null)
if (Session.Ui.IsEditMode && Session.Ui.ActiveFieldKey != null)
{
Session.DeactivateField();
Session.Ui.DeactivateField();
StateHasChanged();
}
}
private void ActivateField(string fieldKey)
{
Session.ActivateField(fieldKey);
Session.Ui.ActivateField(fieldKey);
StateHasChanged();
}
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
Session.TrackCommit(args);
Session.Draft.TrackCommit(args);
Session.Ui.DeactivateField();
RunCrossOptionEvaluator();
StateHasChanged();
}
private void RunCrossOptionEvaluator()
{
var optionChanges = Session.PendingChanges.OfType<PendingOptionChange>().ToList();
var optionChanges = Session.Draft.PendingChanges.OfType<PendingOptionChange>().ToList();
var issues = CrossOptionValidation.Validate(Status, optionChanges);
Session.SetCrossOptionIssues(issues);
Session.Draft.SetCrossOptionIssues(issues);
}
/// <summary>
/// <see cref="IEffectiveConfigEditSession.EnterEditMode"/> clears cross-option issues; repopulate from disk + pending.
/// After entering edit mode, repopulate cross-option issues from disk + pending.
/// </summary>
private void RefreshCrossOptionAfterEnteringEditMode()
{
@@ -224,8 +226,8 @@
private void OpenSaveModal()
{
var hasPending = Session.PendingChanges.Count > 0;
var hasValidation = Session.GetValidationSummary().Count > 0;
var hasPending = Session.Draft.PendingChanges.Count > 0;
var hasValidation = Session.Draft.GetValidationSummary().Count > 0;
if (!hasPending && !hasValidation)
return;
_showSaveModal = true;
@@ -241,9 +243,10 @@
private async Task ContinueEditingAfterSavedFailureAsync()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
await OnSaveCompleted.InvokeAsync();
Session.EnterEditMode();
Session.Ui.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged();
}
@@ -251,7 +254,8 @@
private void CloseSaveModalAndEndEdit()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
StateHasChanged();
}
}
@@ -262,6 +262,18 @@
margin-bottom: 0;
}
/* Multivalue display mode: editable entry row (no list number; align with list content) */
::deep .ec-field-value-list li.ec-multi-list-entry-point {
list-style: none;
margin-left: -1.25rem;
margin-top: 0.15rem;
}
::deep .ec-field-value-list li.ec-multi-list-empty-hint {
list-style: none;
margin-left: -1.25rem;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
@@ -52,7 +52,7 @@ else
return;
}
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
if (pending?.OldValue is ExplicitToggleState previousState)
@@ -58,7 +58,7 @@ else if (Descriptor.IsMultiValue)
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
@@ -68,7 +68,7 @@ else if (Descriptor.IsMultiValue)
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
Source="@_multiValueRowSource" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
@@ -137,7 +137,7 @@ else
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = Descriptor.OptionName;
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
var descriptorValue = Descriptor.IsMultiValue
@@ -305,7 +305,7 @@ else
{
_draftValue = null;
_hasDraftValue = false;
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
Session?.Draft.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
_exitAction = ExitAction.Revert;
if (Ui != null)
await Ui.DeactivateFieldAsync();
@@ -2,12 +2,17 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
@Value
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="true"
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
<OptionStateBadge
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
Source="@Source"
OptionName="@OptionName"
Value="@Value"
IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
ShowEditableBadge="@(Source?.IsReadOnly != true)"
EditableAsButton="true"
FieldKey="@FieldKey" />
</span>
@code {
@@ -17,6 +22,4 @@
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
[Parameter] public string? ReadonlyTitleOverride { get; set; }
}
@@ -25,22 +25,64 @@
else
{
<div class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0)
{
<ol class="ec-field-value-list">
<ol class="ec-field-value-list">
@if (Items?.Count > 0)
{
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" FieldKey="@FieldKey" ReadonlyClickActivatesField="true" ReadonlyTitleOverride="New values can still be added. Click to edit." /></li>
var anchorId = ReadonlyBadgeAnchorIdBase != null
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
: "";
<li>
<EffectiveConfigMultiValueItem
Value="@item.Value"
Source="@item.Source"
OptionName="@Label"
IsEditMode="@IsEditMode"
ReadonlyBadgeAnchorId="@anchorId"
FieldKey="@FieldKey" />
</li>
}
</ol>
}
else
{
<span class="text-muted">(none)</span>
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
}
}
@if (ShowEditableBadge && (Items?.Count ?? 0) > 0)
{
<li class="ec-multi-list-entry-point">
<OptionStateBadge
OptionName="@Label"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@HasPendingChange"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
</li>
}
else if ((Items?.Count ?? 0) == 0)
{
<li class="ec-multi-list-empty-hint">
@if (ShowEditableBadge)
{
<span class="text-muted">(none)</span>
<OptionStateBadge
OptionName="@Label"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@HasPendingChange"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
}
else
{
<span class="text-muted">(none)</span>
}
</li>
}
</ol>
</div>
}
</li>
@@ -69,8 +69,8 @@
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
if (Session.HasBlockingValidationErrors())
if (Session.Draft.PendingChanges.Count == 0) return;
if (Session.Draft.HasBlockingValidationErrors())
{
NotificationService.ShowError("Fix validation errors before saving.");
return;
@@ -81,14 +81,16 @@
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await Session.ApplyAsync();
var result = await Session.Draft.ApplyAsync();
_saveResult = result;
if (result.Saved)
{
// Changes were written to disk, so clear them from session state.
// Whether to stay in edit mode depends on whether restart succeeded.
Session.AcceptAppliedChanges(stayInEditMode: result.IsValidateFailed || !result.Restarted);
Session.Draft.AcceptAppliedChanges();
if (!result.IsValidateFailed && result.Restarted)
Session.Ui.ExitEditMode();
else
Session.Ui.EnterEditMode();
}
if (result.Saved && result.Restarted)
@@ -127,8 +129,8 @@
_restoreResult = result;
if (result.Restored)
{
// Restore wrote to disk, so clear any pending changes from session.
Session.AcceptAppliedChanges(stayInEditMode: false);
Session.Draft.AcceptAppliedChanges();
Session.Ui.ExitEditMode();
}
if (result.Restored && result.Restarted)
@@ -228,13 +228,39 @@
}
else if (c is PendingManagedHostsChange hostChange)
{
var hostLineDiffs = ManagedHostsPendingChangeDiff.Build(hostChange);
<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">
<div class="d-flex align-items-center flex-wrap 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>
<span class="ec-save-modal-diff text-muted">@hostChange.OldEntries.Count → @hostChange.NewEntries.Count entries</span>
</div>
<div class="small text-muted mt-1">@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)</div>
@if (hostLineDiffs.Count > 0)
{
<div class="ec-save-modal-managed-hosts-diff small mt-2 ps-2 border-start border-secondary border-opacity-25">
@foreach (var d in hostLineDiffs)
{
@if (d.Kind == ManagedHostsLineDiffKind.Added)
{
<div class="text-success font-monospace text-break">+ @d.AfterLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.Removed)
{
<div class="text-muted text-decoration-line-through font-monospace text-break">− @d.BeforeLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.Modified)
{
<div class="text-muted text-decoration-line-through font-monospace text-break">~ @d.BeforeLine</div>
<div class="text-success font-monospace text-break ms-1">→ @d.AfterLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.OrderOnly)
{
<div class="text-muted fst-italic">Line order changed (same entries).</div>
}
}
</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
@@ -304,9 +330,9 @@
return list;
}
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 bool HasBlockingValidationErrors => Session.HasBlockingValidationErrors();
private IReadOnlyList<FieldIssue> ValidationErrors => Session.Draft.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.Draft.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
private bool HasBlockingValidationErrors => Session.Draft.HasBlockingValidationErrors();
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
{
@@ -371,13 +397,13 @@
private async Task RevertOption(PendingOptionChange change)
{
Session.RevertChange(change.SectionId, change.OptionName);
Session.Draft.RevertChange(change.SectionId, change.OptionName);
await OnReverted.InvokeAsync();
}
private async Task RevertManagedHosts()
{
Session.RevertManagedHostsChange();
Session.Draft.RevertManagedHostsDraft();
await OnReverted.InvokeAsync();
}
@@ -39,6 +39,15 @@
font-size: 0.9375rem;
}
.ec-save-modal-managed-hosts-diff {
font-size: 0.8125rem;
line-height: 1.45;
}
.ec-save-modal-managed-hosts-diff > div + div {
margin-top: 0.2rem;
}
.ec-save-modal-override {
font-size: 0.8125rem;
}
@@ -11,15 +11,8 @@
}
else if (Source?.IsReadOnly == true)
{
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
OnClick="OnEditableClick"
Class="status-badge-inline" />
}
else if (IsInteractive && Ui != null)
var readonlyTitle = Source?.GetReadOnlyTooltip();
if (IsInteractive && Ui != null)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
@@ -88,10 +81,6 @@ else if (ShowEditableBadge)
[Parameter] public string? FieldKey { get; set; }
/// <summary>Optional: when set, editable-badge click invokes this instead of activating the field by key (e.g. list editor per-item edit).</summary>
[Parameter] public EventCallback OnEditableClickRequested { get; set; }
/// <summary>When true, readonly badge click activates the field for editing instead of showing the readonly popover (e.g. for multi-value where new items can still be added).</summary>
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
[Parameter] public string? ReadonlyTitleOverride { get; set; }
/// <summary>When true, option is disabled because dnsmasq build does not support it (e.g. no DNSSEC).</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip shown when <see cref="IsCapabilityDisabled"/> is true (e.g. "This dnsmasq build does not support DNSSEC; option not written.").</summary>
+467 -83
View File
@@ -8,6 +8,7 @@
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
@using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@implements IDisposable
@@ -16,6 +17,7 @@
@inject IEffectiveConfigEditSession Session
@inject IHostsPageProjectionService HostsProjection
@inject IJSRuntime JSRuntime
@inject INotificationService NotificationService
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Hosts | @AppTitle</PageTitle>
@@ -72,16 +74,16 @@ else
</div>
</div>
@if (_unifiedRows == null || _unifiedRows.Count == 0)
@if (VisibleGroups.Count == 0)
{
<p class="text-muted">No hosts entries found.</p>
}
else if (VisibleGroups.Count == 0)
{
<p class="text-muted">No rows match the current search.</p>
}
else
{
@if (TotalVisibleDataRows == 0 && !string.IsNullOrWhiteSpace(_search))
{
<p class="text-muted">No rows match the current search.</p>
}
<div class="app-table-scroll-shell" data-table-scroll-cue>
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0">
<table class="table table-bordered table-hover app-data-table hosts-table mb-0">
@@ -90,7 +92,17 @@ else
<tr>
<th scope="col">Address</th>
<th scope="col">Names</th>
<th scope="col">Effective names</th>
<th scope="col" class="hosts-effective-names-th">
<span class="align-middle">Effective names</span>
<button type="button"
class="btn btn-link btn-sm text-muted hosts-effective-names-help p-0 ms-1 align-middle border-0"
title="@EffectiveNamesHelpText"
aria-label="@EffectiveNamesHelpText"
@onclick="OnEffectiveNamesHelpClickAsync"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
</tr>
</thead>
@foreach (var group in VisibleGroups)
@@ -101,58 +113,103 @@ else
<div class="hosts-group-header__content">
<div class="hosts-group-header__title">
<span>@group.Title</span>
@if (group.GroupContainsEditableRows)
{
<span class="badge bg-primary">Editable</span>
}
@if (!group.IsActive)
{
<span class="badge bg-warning text-dark">Inactive</span>
}
</div>
<div class="hosts-group-header__path">
@if (!string.IsNullOrWhiteSpace(group.Subtitle))
{
<span class="hosts-group-header__subtitle text-muted small">@group.Subtitle</span>
}
@if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge"
Clickable="true"
Disabled="@IsInlineEditing"
Title="@(IsInlineEditing ? "Finish editing the current row before adding another." : "Add a host entry")"
AriaLabel="@(IsInlineEditing ? "Finish the current row edit first" : "Add a host entry")"
OnClick="AddManagedHostFromBadge" />
}
else if (group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="read-only"
Class="hosts-group-header__badge" />
}
</div>
</div>
</th>
</tr>
@foreach (var row in group.Rows)
@if (group.Rows.Count == 0)
{
@if (IsEditingRow(row))
<tr class="hosts-row hosts-row--empty" @key="@("empty:" + group.Key)">
<td colspan="3" class="text-muted small text-center hosts-empty-group-message py-3">
@EmptyGroupMessage
</td>
</tr>
}
else
{
@foreach (var row in group.Rows)
{
<tr class="hosts-row hosts-row--editing table-info"
@key="@("edit:" + row.Id)"
@ref="_editingRowRef"
@onfocusout="OnEditingRowFocusOut">
<td>
<input class="form-control form-control-sm" @bind="_editAddress" autofocus />
</td>
<td>
<input class="form-control form-control-sm" placeholder="Comma-separated names" @bind="_editNames" />
</td>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
</tr>
}
else
{
<tr class="@RowClass(row)"
@key="@row.Id"
title="@(row.IsEditable ? "Double-click to edit" : string.Empty)"
@ondblclick="@(() => OnRowDoubleClick(row))">
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
<td>
@if (row.EffectiveNames.Count != row.Names.Count)
{
<span class="text-primary">@string.Join(", ", row.EffectiveNames)</span>
}
else
{
@string.Join(", ", row.EffectiveNames)
}
</td>
</tr>
@if (IsEditingRow(row))
{
<tr class="hosts-row hosts-row--editing table-info"
@key="@("edit:" + row.Id)"
@ref="_editingRowRef"
@onfocusout="OnEditingRowFocusOut">
<td>
<input @ref="_editAddressInputRef"
class="form-control form-control-sm @(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "is-invalid" : "")"
title="@HostEditAddressTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "true" : null)"
@bind="_editAddress"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown"
autofocus />
</td>
<td>
<input class="form-control form-control-sm @(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "is-invalid" : "")"
placeholder="Comma-separated names"
title="@HostEditNamesTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)"
@bind="_editNames"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</td>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
</tr>
}
else
{
<tr class="@RowClass(row)"
@key="@row.Id"
title="@RowTitle(row)"
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
<td>
@if (row.EffectiveNames.Count != row.Names.Count)
{
<span class="text-primary">@string.Join(", ", row.EffectiveNames)</span>
}
else
{
@string.Join(", ", row.EffectiveNames)
}
</td>
</tr>
}
}
}
</tbody>
@@ -165,6 +222,36 @@ else
</section>
}
@if (_showInvalidHostEditModal)
{
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="hosts-invalid-edit-title" aria-modal="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="hosts-invalid-edit-title">Host entry incomplete</h5>
</div>
<div class="modal-body">
<p class="mb-2">This row is not valid yet. You can keep editing, or discard @(_editingRowIsNew ? "the new row" : "your changes on this row").</p>
@if (CurrentEditValidationErrors.Count > 0)
{
<ul class="small text-danger mb-0 ps-3">
@foreach (var err in CurrentEditValidationErrors)
{
<li>@err</li>
}
</ul>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @onclick="OnInvalidEditContinue">Keep editing</button>
<button type="button" class="btn btn-outline-secondary" @onclick="OnInvalidEditDiscardAsync">Discard</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
}
<div class="hosts-page-notes mt-3 d-flex flex-column gap-2">
@if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason))
{
@@ -185,11 +272,14 @@ else
</div>
@code {
private const string EffectiveNamesHelpText =
"Effective names show how dnsmasq resolves this row from your Address and Names columns. If expand-hosts and a domain are enabled, short hostnames get that suffix. This preview updates as you type.";
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
private IReadOnlyList<HostsPageRow> _unifiedRows = Array.Empty<HostsPageRow>();
private List<HostEntry> _managedEntries = new();
private List<HostEntry> _originalManagedEntries = new();
private List<HostsPageRow> _restUnifiedRows = new();
private List<HostEntry> _baselineManagedEntries = new();
private string? _error;
private string _search = "";
@@ -198,31 +288,100 @@ else
private string _editAddress = "";
private string _editNames = "";
private ElementReference _editingRowRef;
private ElementReference _editAddressInputRef;
private IJSObjectReference? _hostsTableScrollCueModule;
private IJSObjectReference? _hostsUiModule;
private bool _showInvalidHostEditModal;
private bool _refocusEditAfterRender;
/// <summary>When false, invalid state is hidden until the first blur, Escape, or Enter.</summary>
private bool _showHostFieldValidation;
private IReadOnlyList<HostEntry> CurrentManagedEntries =>
Session.Draft.ManagedHostsDraft?.NewEntries ?? _baselineManagedEntries;
// Memoize: markup reads VisibleGroups.Count then foreach — avoid building twice per render.
private IReadOnlyList<HostsPageGroup>? _visibleGroupsCache;
private object? _visibleGroupsCacheRowsRef;
private string _visibleGroupsCacheSearch = "\0";
private string _visibleGroupsCacheManagedPath = "\0";
private IReadOnlyList<HostsPageGroup> VisibleGroups
{
get
{
var managedPath = _status?.ManagedHostsFilePath ?? "";
if (_visibleGroupsCache != null
&& ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows)
&& _visibleGroupsCacheSearch == _search)
&& _visibleGroupsCacheSearch == _search
&& _visibleGroupsCacheManagedPath == managedPath)
{
return _visibleGroupsCache;
}
_visibleGroupsCache = HostsProjection.BuildGroups(_unifiedRows, BuildQuery());
var built = HostsProjection.BuildGroups(_unifiedRows, BuildQuery());
_visibleGroupsCache = EnsureManagedHostsHeaderGroup(built);
_visibleGroupsCacheRowsRef = _unifiedRows;
_visibleGroupsCacheSearch = _search;
_visibleGroupsCacheManagedPath = managedPath;
return _visibleGroupsCache;
}
}
private int TotalVisibleDataRows => VisibleGroups.Sum(g => g.Rows.Count);
private bool IsInlineEditing => !string.IsNullOrEmpty(_editingRowId);
private bool _editingRowIsNew =>
!string.IsNullOrEmpty(_editingRowId) && _editingRowId.StartsWith("new:", StringComparison.Ordinal);
private IReadOnlyList<string> CurrentEditValidationErrors =>
string.IsNullOrEmpty(_editingRowId)
? Array.Empty<string>()
: HostsEntryEditValidator.GetErrors(_editAddress.Trim(), ParseNamesList(_editNames));
private bool IsCurrentEditValid => CurrentEditValidationErrors.Count == 0;
private IReadOnlyList<string> CurrentAddressFieldErrors =>
string.IsNullOrEmpty(_editingRowId)
? Array.Empty<string>()
: HostsEntryEditValidator.GetAddressFieldErrors(_editAddress.Trim());
private IReadOnlyList<string> CurrentHostnameFieldErrors =>
string.IsNullOrEmpty(_editingRowId)
? Array.Empty<string>()
: HostsEntryEditValidator.GetHostnameFieldErrors(ParseNamesList(_editNames));
private string? HostEditAddressTooltip =>
!_showHostFieldValidation ? null : JoinHostFieldTooltip(CurrentAddressFieldErrors);
private string? HostEditNamesTooltip =>
!_showHostFieldValidation ? null : JoinHostFieldTooltip(CurrentHostnameFieldErrors);
private static string? JoinHostFieldTooltip(IReadOnlyList<string> errors) =>
errors.Count == 0 ? null : string.Join(" · ", errors);
private IReadOnlyList<HostsPageGroup> EnsureManagedHostsHeaderGroup(IReadOnlyList<HostsPageGroup> groups)
{
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
return groups;
var path = _status.ManagedHostsFilePath;
if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed))
return groups;
var placeholder = new HostsPageGroup(
Key: $"{HostsRowSourceKind.Managed}:{path}",
Title: "Managed hosts",
Subtitle: path,
SourceKind: HostsRowSourceKind.Managed,
GroupContainsEditableRows: true,
IsActive: true,
InactiveReason: null,
VisibleRowCount: 0,
Rows: Array.Empty<HostsPageRow>(),
IsSourceEditable: true);
return new[] { placeholder }.Concat(groups).ToList();
}
private HostsPageQueryState BuildQuery()
{
return new HostsPageQueryState(
@@ -235,6 +394,24 @@ else
ActiveFilter: null);
}
private void InvalidateVisibleGroupsCache()
{
_visibleGroupsCache = null;
}
private async Task OnEffectiveNamesHelpClickAsync()
{
try
{
_hostsUiModule ??= await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/modules/hosts-ui.js");
if (await _hostsUiModule.InvokeAsync<bool>("preferEffectiveNamesHelpToast"))
NotificationService.ShowInfo(EffectiveNamesHelpText);
}
catch (JSDisconnectedException)
{
}
}
private static string RowClass(HostsPageRow row)
{
var parts = new List<string> { "hosts-row" };
@@ -245,21 +422,87 @@ else
return string.Join(" ", parts);
}
private void OnRowDoubleClick(HostsPageRow row) => BeginEdit(row);
private string RowTitle(HostsPageRow row) =>
row.IsEditable ? "Double-click to edit" : string.Empty;
private string EmptyGroupMessage =>
string.IsNullOrWhiteSpace(_search) ? "No entries." : "No matching entries.";
private bool IsEditingRow(HostsPageRow row) =>
!string.IsNullOrEmpty(_editingRowId) && string.Equals(_editingRowId, row.Id, StringComparison.Ordinal);
private void BeginEdit(HostsPageRow row)
private async Task OnRowDoubleClickAsync(HostsPageRow row)
{
if (!row.IsEditable || _status == null)
return;
if (!string.IsNullOrEmpty(_editingRowId))
{
if (string.Equals(_editingRowId, row.Id, StringComparison.Ordinal))
return;
if (!IsCurrentEditValid)
{
RevealHostFieldValidation();
_showInvalidHostEditModal = true;
await InvokeAsync(StateHasChanged);
return;
}
await CommitAndEndInlineEditAsync();
}
if (!Session.Ui.IsEditMode)
Session.Ui.EnterEditMode();
var entry = FindManagedEntry(row);
if (entry == null)
return;
_showHostFieldValidation = false;
_editingRowId = row.Id;
_editAddress = entry.Address;
_editNames = string.Join(", ", entry.Names);
await InvokeAsync(StateHasChanged);
}
private void AddManagedHostFromBadge()
{
if (IsInlineEditing)
return;
AddManagedHost();
}
private void AddManagedHost()
{
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing)
return;
if (!Session.Ui.IsEditMode)
Session.Ui.EnterEditMode();
var list = CloneEntries(CurrentManagedEntries).ToList();
var nextLine = list.Count == 0 ? 1 : list.Max(e => e.LineNumber) + 1;
var id = "new:" + Guid.NewGuid().ToString("N");
list.Add(new HostEntry
{
LineNumber = nextLine,
Id = id,
Address = "",
Names = new List<string>(),
IsComment = false,
IsPassthrough = false
});
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
_showHostFieldValidation = false;
_editingRowId = id;
_editAddress = "";
_editNames = "";
StateHasChanged();
}
private void CancelEdit()
@@ -267,14 +510,90 @@ else
_editingRowId = null;
_editAddress = "";
_editNames = "";
_showHostFieldValidation = false;
}
private void RevealHostFieldValidation() => _showHostFieldValidation = true;
private void OnInvalidEditContinue()
{
_showInvalidHostEditModal = false;
_refocusEditAfterRender = true;
StateHasChanged();
}
private async Task OnInvalidEditDiscardAsync()
{
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
{
_showInvalidHostEditModal = false;
CancelEdit();
await InvokeAsync(StateHasChanged);
return;
}
var list = CloneEntries(CurrentManagedEntries).ToList();
var entry = list.FirstOrDefault(MatchesEditingId);
if (entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal))
{
list.Remove(entry);
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
}
_showInvalidHostEditModal = false;
CancelEdit();
await InvokeAsync(StateHasChanged);
}
private async Task OnEditFieldKeyDown(KeyboardEventArgs e)
{
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
return;
if (e.Key == "Enter")
{
await TryFinishHostEditFromKeyboardAsync();
return;
}
if (e.Key != "Escape")
return;
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
{
RevealHostFieldValidation();
_showInvalidHostEditModal = true;
await InvokeAsync(StateHasChanged);
}
}
private async Task TryFinishHostEditFromKeyboardAsync()
{
if (string.IsNullOrEmpty(_editingRowId))
return;
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
{
RevealHostFieldValidation();
_showInvalidHostEditModal = true;
await InvokeAsync(StateHasChanged);
}
}
private async Task OnEditingRowFocusOut(FocusEventArgs _)
{
if (string.IsNullOrEmpty(_editingRowId))
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
return;
await Task.Delay(10);
if (string.IsNullOrEmpty(_editingRowId))
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
return;
var outside = true;
@@ -289,24 +608,43 @@ else
}
if (outside)
await CommitAndEndInlineEditAsync();
{
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
{
RevealHostFieldValidation();
_showInvalidHostEditModal = true;
await InvokeAsync(StateHasChanged);
}
}
}
private async Task CommitAndEndInlineEditAsync()
{
if (string.IsNullOrEmpty(_editingRowId) || _status == null)
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
return;
var entry = _managedEntries.FirstOrDefault(MatchesEditingId);
var list = CloneEntries(CurrentManagedEntries).ToList();
var entry = list.FirstOrDefault(MatchesEditingId);
if (entry == null)
{
CancelEdit();
return;
}
entry.Address = _editAddress.Trim();
entry.Names = ParseNamesList(_editNames);
ApplyManagedOverlay();
SyncManagedHostsDraft();
var trimmedAddr = _editAddress.Trim();
var newNames = ParseNamesList(_editNames);
if (!HostsEntryEditValidator.IsValid(trimmedAddr, newNames))
return;
entry.Address = trimmedAddr;
entry.Names = newNames;
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath!);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
CancelEdit();
await InvokeAsync(StateHasChanged);
}
@@ -318,7 +656,7 @@ else
}
private HostEntry? FindManagedEntry(HostsPageRow row) =>
_managedEntries.FirstOrDefault(e =>
CurrentManagedEntries.FirstOrDefault(e =>
string.Equals(e.Id, row.Id, StringComparison.Ordinal) ||
string.Equals($"managed:{e.LineNumber}", row.Id, StringComparison.Ordinal));
@@ -339,19 +677,6 @@ else
IsPassthrough = e.IsPassthrough
}).ToList();
private void SyncManagedHostsDraft()
{
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
return;
var draft = CloneEntries(_managedEntries);
var original = CloneEntries(_originalManagedEntries);
var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath);
if (pending.HasChanges)
Session.TrackManagedHostsChange(pending);
else
Session.RevertManagedHostsChange();
}
private MarkupString RenderAddnHostsList()
{
if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0)
@@ -372,9 +697,20 @@ else
protected override async Task OnInitializedAsync()
{
Session.Changed += OnSessionChanged;
await Load();
}
private void OnSessionChanged()
{
if (_status != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
}
_ = InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -394,6 +730,21 @@ else
{
}
}
if (_refocusEditAfterRender)
{
_refocusEditAfterRender = false;
if (!string.IsNullOrEmpty(_editingRowId))
{
try
{
await _editAddressInputRef.FocusAsync();
}
catch (JSDisconnectedException)
{
}
}
}
}
private async Task RefreshStatusAsync()
@@ -406,14 +757,28 @@ else
catch { /* ignore */ }
}
private void ReconcileManagedHostsDraftAfterLoad()
{
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
return;
var draft = Session.Draft.ManagedHostsDraft;
if (draft == null)
return;
if (!string.Equals(draft.ManagedHostsFilePath, _status.ManagedHostsFilePath, StringComparison.Ordinal))
return;
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
CloneEntries(draft.NewEntries),
_status.ManagedHostsFilePath);
}
private async Task Load()
{
_error = null;
try
{
_status = await StatusClient.GetStatusAsync();
_managedEntries = (await HostsClient.GetHostsAsync()).ToList();
_originalManagedEntries = CloneEntries(_managedEntries);
_baselineManagedEntries = (await HostsClient.GetHostsAsync()).ToList();
if (_status?.EffectiveConfig != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
@@ -421,15 +786,17 @@ else
var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault();
var noHosts = _status.NoHosts;
var fromApi = await HostsClient.GetUnifiedRowsAsync(expandHosts, domain, noHosts, _status.ManagedHostsFilePath);
_unifiedRows = fromApi.ToList();
ApplyManagedOverlay();
_restUnifiedRows = fromApi.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList();
ReconcileManagedHostsDraftAfterLoad();
RebuildManagedOverlay();
}
else
{
_restUnifiedRows = new List<HostsPageRow>();
_unifiedRows = Array.Empty<HostsPageRow>();
}
SyncManagedHostsDraft();
InvalidateVisibleGroupsCache();
}
catch (Exception ex)
{
@@ -437,7 +804,7 @@ else
}
}
private void ApplyManagedOverlay()
private void RebuildManagedOverlay()
{
if (_status?.EffectiveConfig == null || string.IsNullOrEmpty(_status.ManagedHostsFilePath))
return;
@@ -447,7 +814,7 @@ else
var path = _status.ManagedHostsFilePath;
var managedRows = new List<HostsPageRow>();
foreach (var e in _managedEntries.Where(x => !x.IsPassthrough))
foreach (var e in CurrentManagedEntries.Where(x => !x.IsPassthrough))
{
var names = e.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
var effective = HostsEffectiveNames.Expand(names, expandHosts, domain);
@@ -466,13 +833,14 @@ else
LineNumber: e.LineNumber));
}
var rest = _unifiedRows.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList();
_unifiedRows = managedRows.Concat(rest).ToList();
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
}
public void Dispose()
{
Session.Changed -= OnSessionChanged;
_ = CleanupHostsTableScrollCueAsync();
_ = CleanupHostsUiModuleAsync();
}
private async Task CleanupHostsTableScrollCueAsync()
@@ -491,4 +859,20 @@ else
_hostsTableScrollCueModule = null;
}
private async Task CleanupHostsUiModuleAsync()
{
if (_hostsUiModule == null)
return;
try
{
await _hostsUiModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
_hostsUiModule = null;
}
}
@@ -101,6 +101,21 @@
}
/* Group header row (colspan, not rowspan) */
::deep .hosts-table .hosts-effective-names-th {
white-space: nowrap;
}
::deep .hosts-table .hosts-effective-names-help {
vertical-align: middle;
text-decoration: none;
line-height: 1;
}
::deep .hosts-table .hosts-effective-names-help:hover,
::deep .hosts-table .hosts-effective-names-help:focus-visible {
color: var(--bs-primary) !important;
}
::deep .hosts-table .hosts-group-header-cell {
background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%);
border-top: 1px solid #dbe3ec;
@@ -112,6 +127,10 @@
}
.hosts-group-header__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
}
@@ -121,13 +140,26 @@
align-items: center;
gap: 0.5rem;
color: #0f172a;
min-width: 0;
}
.hosts-group-header__path {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 0;
width: 100%;
font-weight: 500;
}
.hosts-group-header__badge {
flex-shrink: 0;
}
.hosts-group-header__subtitle {
display: inline-flex;
align-items: center;
display: inline-block;
line-height: 1.2;
margin-left: 0.25rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -147,6 +179,11 @@
background-color: rgba(13, 110, 253, 0.06);
}
::deep .hosts-table .hosts-row--empty td.hosts-empty-group-message {
background: #fafbfc;
font-style: italic;
}
::deep .hosts-row code {
font-size: 0.93em;
color: #0f172a;
@@ -63,6 +63,9 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
? first.InactiveReason
: null;
var isSourceEditable = first.SourceKind == HostsRowSourceKind.Managed
|| sortedRows.Any(r => r.IsEditable);
result.Add(new HostsPageGroup(
Key: $"{first.SourceKind}:{first.SourcePath}",
Title: title,
@@ -72,7 +75,8 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
IsActive: sortedRows.Any(r => r.IsActive),
InactiveReason: inactiveReason,
VisibleRowCount: sortedRows.Count,
Rows: sortedRows));
Rows: sortedRows,
IsSourceEditable: isSourceEditable));
}
return result;
@@ -0,0 +1,37 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>
/// Pending effective-config changes, validation issues, and apply/save. Independent of edit-mode UI state.
/// </summary>
public interface IEffectiveConfigDraft
{
event Action? Changed;
IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; }
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues { get; }
PendingManagedHostsChange? ManagedHostsDraft { get; }
void TrackCommit(EffectiveConfigEditCommittedArgs args);
void RevertChange(string sectionId, string optionName);
void SetManagedHostsDraft(
IReadOnlyList<HostEntry> baseline,
IReadOnlyList<HostEntry> draft,
string managedHostsFilePath);
void RevertManagedHostsDraft();
void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues);
void ClearFieldIssues(string fieldKey);
void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues);
bool HasBlockingValidationErrors();
IReadOnlyList<FieldIssue> GetValidationSummary();
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
void AcceptAppliedChanges();
void DiscardAllDraft();
}
@@ -1,51 +1,15 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>
/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, validation, and apply.
/// Components read state from here and call methods; the owning section re-renders after state changes.
/// Scoped root for effective-config editing: <see cref="Draft"/> holds pending changes and validation;
/// <see cref="Ui"/> holds edit mode and active field. Subscribe to <see cref="Changed"/> for any update.
/// </summary>
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; }
string? ActiveFieldKey { get; }
IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; }
/// <summary>Per-field validation issues (errors block save; warnings can be confirmed).</summary>
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues { get; }
void EnterEditMode();
void ExitEditModeDiscard();
void ActivateField(string fieldKey);
void DeactivateField();
void TrackCommit(EffectiveConfigEditCommittedArgs args);
void RevertChange(string sectionId, string optionName);
void TrackManagedHostsChange(PendingManagedHostsChange change);
void RevertManagedHostsChange();
void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues);
void ClearFieldIssues(string fieldKey);
/// <summary>Replaces all cross-option validation issues (e.g. no-resolv vs server). Merged with per-field issues for display and save guard.</summary>
void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues);
/// <summary>True if any field has one or more issues with <see cref="FieldIssueSeverity.Error"/>.</summary>
bool HasBlockingValidationErrors();
/// <summary>All issues across fields for summary display (e.g. toolbar count or save guard message).</summary>
IReadOnlyList<FieldIssue> GetValidationSummary();
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
/// <summary>
/// Accepts that changes were applied to disk. Clears pending changes and optionally exits edit mode.
/// Call this after a successful write (even if restart failed), so the session state matches disk state.
/// </summary>
/// <param name="stayInEditMode">If true, keep edit mode active; if false, exit edit mode.</param>
void AcceptAppliedChanges(bool stayInEditMode);
IEffectiveConfigDraft Draft { get; }
IEffectiveConfigUiState Ui { get; }
}
@@ -0,0 +1,18 @@
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>
/// Edit affordance state only: whether the effective-config UI is in edit mode and which field is active.
/// Does not own pending changes or validation.
/// </summary>
public interface IEffectiveConfigUiState
{
event Action? Changed;
bool IsEditMode { get; }
string? ActiveFieldKey { get; }
void EnterEditMode();
void ExitEditMode();
void ActivateField(string fieldKey);
void DeactivateField();
}
@@ -0,0 +1,149 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigDraft : IEffectiveConfigDraft
{
private readonly IEffectiveConfigSaveService _saveService;
private readonly List<PendingDnsmasqChange> _pending = new();
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
private readonly List<FieldIssue> _crossOptionIssues = new();
public EffectiveConfigDraft(IEffectiveConfigSaveService saveService)
{
_saveService = saveService;
}
public event Action? Changed;
private void NotifyChanged() => Changed?.Invoke();
public IReadOnlyList<PendingDnsmasqChange> PendingChanges => _pending;
public PendingManagedHostsChange? ManagedHostsDraft =>
_pending.OfType<PendingManagedHostsChange>().FirstOrDefault();
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
{
get
{
var copy = new Dictionary<string, List<FieldIssue>>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in _fieldIssues)
copy[kv.Key] = new List<FieldIssue>(kv.Value);
foreach (var issue in _crossOptionIssues)
{
if (!copy.TryGetValue(issue.FieldKey, out var list))
copy[issue.FieldKey] = list = new List<FieldIssue>();
list.Add(issue);
}
return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<FieldIssue>)kv.Value, StringComparer.OrdinalIgnoreCase);
}
}
public void DiscardAllDraft()
{
_pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
NotifyChanged();
}
public void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues)
{
if (string.IsNullOrEmpty(fieldKey)) return;
_fieldIssues[fieldKey] = issues.ToList();
NotifyChanged();
}
public void ClearFieldIssues(string fieldKey)
{
if (string.IsNullOrEmpty(fieldKey)) return;
_fieldIssues.Remove(fieldKey);
NotifyChanged();
}
public void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues)
{
_crossOptionIssues.Clear();
if (issues != null)
_crossOptionIssues.AddRange(issues);
NotifyChanged();
}
public bool HasBlockingValidationErrors()
{
if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error)))
return true;
return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error);
}
public IReadOnlyList<FieldIssue> GetValidationSummary()
{
var list = new List<FieldIssue>(_fieldIssues.Values.SelectMany(x => x));
list.AddRange(_crossOptionIssues);
return list;
}
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
{
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName);
if (!ValuesEqual(args.OldValue, args.NewValue))
_pending.Add(new PendingOptionChange(
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
NotifyChanged();
}
public void RevertChange(string sectionId, string optionName)
{
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == optionName);
ClearFieldIssues($"{sectionId}:{optionName}");
NotifyChanged();
}
public void SetManagedHostsDraft(
IReadOnlyList<HostEntry> baseline,
IReadOnlyList<HostEntry> draft,
string managedHostsFilePath)
{
_pending.RemoveAll(c => c is PendingManagedHostsChange);
var pending = new PendingManagedHostsChange(baseline, draft, managedHostsFilePath);
if (pending.HasChanges)
_pending.Add(pending);
NotifyChanged();
}
public void RevertManagedHostsDraft()
{
_pending.RemoveAll(c => c is PendingManagedHostsChange);
NotifyChanged();
}
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
{
if (_pending.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
return await _saveService.SaveAsync(_pending.ToList(), ct);
}
public void AcceptAppliedChanges()
{
_pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
NotifyChanged();
}
private static IReadOnlyList<string>? AsStringList(object? value) => value as IReadOnlyList<string>;
private static bool ValuesEqual(object? oldValue, object? newValue)
{
var oldList = AsStringList(oldValue);
var newList = AsStringList(newValue);
if (oldList != null || newList != null)
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
return Equals(oldValue, newValue);
}
}
@@ -1,172 +1,24 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
{
private readonly IEffectiveConfigSaveService _saveService;
private readonly List<PendingDnsmasqChange> _pending = new();
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
private readonly List<FieldIssue> _crossOptionIssues = new();
private readonly EffectiveConfigDraft _draft;
private readonly EffectiveConfigUiState _ui;
public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService)
{
_saveService = saveService;
_draft = new EffectiveConfigDraft(saveService);
_ui = new EffectiveConfigUiState();
_draft.Changed += OnChildChanged;
_ui.Changed += OnChildChanged;
}
public event Action? Changed;
private void NotifyChanged() => Changed?.Invoke();
public IEffectiveConfigDraft Draft => _draft;
public IEffectiveConfigUiState Ui => _ui;
public bool IsEditMode { get; private set; }
public string? ActiveFieldKey { get; private set; }
public IReadOnlyList<PendingDnsmasqChange> PendingChanges => _pending;
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
{
get
{
var copy = new Dictionary<string, List<FieldIssue>>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in _fieldIssues)
copy[kv.Key] = new List<FieldIssue>(kv.Value);
foreach (var issue in _crossOptionIssues)
{
if (!copy.TryGetValue(issue.FieldKey, out var list))
copy[issue.FieldKey] = list = new List<FieldIssue>();
list.Add(issue);
}
return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<FieldIssue>)kv.Value, StringComparer.OrdinalIgnoreCase);
}
}
public void EnterEditMode()
{
IsEditMode = true;
ActiveFieldKey = null;
_pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
NotifyChanged();
}
public void ExitEditModeDiscard()
{
_pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
ActiveFieldKey = null;
IsEditMode = false;
NotifyChanged();
}
public void ActivateField(string fieldKey)
{
IsEditMode = true;
ActiveFieldKey = fieldKey;
NotifyChanged();
}
public void DeactivateField()
{
ActiveFieldKey = null;
NotifyChanged();
}
public void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues)
{
if (string.IsNullOrEmpty(fieldKey)) return;
_fieldIssues[fieldKey] = issues.ToList();
NotifyChanged();
}
public void ClearFieldIssues(string fieldKey)
{
if (string.IsNullOrEmpty(fieldKey)) return;
_fieldIssues.Remove(fieldKey);
NotifyChanged();
}
public void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues)
{
_crossOptionIssues.Clear();
if (issues != null)
_crossOptionIssues.AddRange(issues);
NotifyChanged();
}
public bool HasBlockingValidationErrors()
{
if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error)))
return true;
return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error);
}
public IReadOnlyList<FieldIssue> GetValidationSummary()
{
var list = new List<FieldIssue>(_fieldIssues.Values.SelectMany(x => x));
list.AddRange(_crossOptionIssues);
return list;
}
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
{
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName);
if (!ValuesEqual(args.OldValue, args.NewValue))
_pending.Add(new PendingOptionChange(
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
ActiveFieldKey = null;
NotifyChanged();
}
public void RevertChange(string sectionId, string optionName)
{
_pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == 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)
{
if (_pending.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
return await _saveService.SaveAsync(_pending.ToList(), ct);
}
public void AcceptAppliedChanges(bool stayInEditMode)
{
_pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
ActiveFieldKey = null;
IsEditMode = stayInEditMode;
NotifyChanged();
}
private static IReadOnlyList<string>? AsStringList(object? value)
{
return value as IReadOnlyList<string>;
}
private static bool ValuesEqual(object? oldValue, object? newValue)
{
var oldList = AsStringList(oldValue);
var newList = AsStringList(newValue);
if (oldList != null || newList != null)
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
return Equals(oldValue, newValue);
}
private void OnChildChanged() => Changed?.Invoke();
}
@@ -27,8 +27,8 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor
public void EnsureEditMode()
{
if (!_session.IsEditMode)
_session.EnterEditMode();
if (!_session.Ui.IsEditMode)
_session.Ui.EnterEditMode();
}
public EffectiveConfigFieldRef Field(string sectionId, string optionName)
@@ -69,8 +69,9 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor
: descriptor.GetValue();
var oldValue = pending?.OldValue ?? baseValue;
var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source;
_session.TrackCommit(new EffectiveConfigEditCommittedArgs(
_session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs(
field.SectionId, field.OptionName, oldValue, newValue, source?.FilePath));
_session.Ui.DeactivateField();
RefreshCrossOptionIssues(status);
}
@@ -84,8 +85,9 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor
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(
_session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs(
field.SectionId, field.OptionName, oldValues, newValues.ToList(), source?.FilePath));
_session.Ui.DeactivateField();
RefreshCrossOptionIssues(status);
}
@@ -105,36 +107,36 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor
public void Revert(EffectiveConfigFieldRef field)
{
_session.RevertChange(field.SectionId, field.OptionName);
_session.Draft.RevertChange(field.SectionId, field.OptionName);
}
public void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList<FieldIssue> issues)
{
_session.SetFieldIssues(field.FieldKey, issues);
_session.Draft.SetFieldIssues(field.FieldKey, issues);
}
public void ClearFieldIssues(EffectiveConfigFieldRef field)
{
_session.ClearFieldIssues(field.FieldKey);
_session.Draft.ClearFieldIssues(field.FieldKey);
}
public void RefreshCrossOptionIssues(DnsmasqServiceStatus status)
{
var issues = _crossOptionValidation.Validate(
status,
_session.PendingChanges.OfType<PendingOptionChange>().ToList());
_session.SetCrossOptionIssues(issues);
_session.Draft.PendingChanges.OfType<PendingOptionChange>().ToList());
_session.Draft.SetCrossOptionIssues(issues);
}
public void Activate(EffectiveConfigFieldRef field)
{
EnsureEditMode();
_session.ActivateField(field.FieldKey);
_session.Ui.ActivateField(field.FieldKey);
}
private PendingOptionChange? GetPendingOptionChange(EffectiveConfigFieldRef field)
{
return _session.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
return _session.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase));
}
@@ -0,0 +1,38 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigUiState : IEffectiveConfigUiState
{
public event Action? Changed;
public bool IsEditMode { get; private set; }
public string? ActiveFieldKey { get; private set; }
public void EnterEditMode()
{
IsEditMode = true;
ActiveFieldKey = null;
Changed?.Invoke();
}
public void ExitEditMode()
{
IsEditMode = false;
ActiveFieldKey = null;
Changed?.Invoke();
}
public void ActivateField(string fieldKey)
{
IsEditMode = true;
ActiveFieldKey = fieldKey;
Changed?.Invoke();
}
public void DeactivateField()
{
ActiveFieldKey = null;
Changed?.Invoke();
}
}
@@ -0,0 +1,126 @@
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;
}
}
@@ -0,0 +1,35 @@
using System.Net;
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Client-side checks for hosts table inline editing (IP + at least one name).</summary>
public static class HostsEntryEditValidator
{
public static IReadOnlyList<string> GetAddressFieldErrors(string addressTrimmed)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(addressTrimmed))
errors.Add("Address is required.");
else if (!IPAddress.TryParse(addressTrimmed, out _))
errors.Add("Address must be a valid IPv4 or IPv6 address.");
return errors;
}
public static IReadOnlyList<string> GetHostnameFieldErrors(IReadOnlyList<string> names)
{
if (names == null || names.Count == 0)
return new[] { "At least one hostname is required." };
return Array.Empty<string>();
}
public static IReadOnlyList<string> GetErrors(string addressTrimmed, IReadOnlyList<string> names)
{
var list = new List<string>();
list.AddRange(GetAddressFieldErrors(addressTrimmed));
list.AddRange(GetHostnameFieldErrors(names));
return list;
}
public static bool IsValid(string addressTrimmed, IReadOnlyList<string> names) =>
GetErrors(addressTrimmed, names).Count == 0;
}
@@ -12,4 +12,6 @@ public sealed record HostsPageGroup(
bool IsActive,
string? InactiveReason,
int VisibleRowCount,
IReadOnlyList<HostsPageRow> Rows);
IReadOnlyList<HostsPageRow> Rows,
/// <summary>True when entries in this source can be edited in the UI (e.g. managed hosts file).</summary>
bool IsSourceEditable);
@@ -0,0 +1,12 @@
/**
* Touch / coarse-pointer devices: show Effective names help as a toast instead of relying on hover.
*/
export function preferEffectiveNamesHelpToast() {
try {
if (window.matchMedia("(pointer: coarse)").matches) return true;
if (window.matchMedia("(hover: none)").matches) return true;
return false;
} catch {
return false;
}
}