mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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>()));
|
||||
}
|
||||
}
|
||||
+25
-13
@@ -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
|
||||
|
||||
+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>
|
||||
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();
|
||||
|
||||
+25
-21
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
+10
-7
@@ -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; }
|
||||
}
|
||||
|
||||
+48
-6
@@ -25,22 +25,64 @@
|
||||
else
|
||||
{
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@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>
|
||||
}
|
||||
@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>
|
||||
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -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)
|
||||
else
|
||||
{
|
||||
@if (TotalVisibleDataRows == 0 && !string.IsNullOrWhiteSpace(_search))
|
||||
{
|
||||
<p class="text-muted">No rows match the current search.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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,22 +113,53 @@ 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>
|
||||
@if (group.Rows.Count == 0)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
@if (IsEditingRow(row))
|
||||
@@ -126,10 +169,23 @@ else
|
||||
@ref="_editingRowRef"
|
||||
@onfocusout="OnEditingRowFocusOut">
|
||||
<td>
|
||||
<input class="form-control form-control-sm" @bind="_editAddress" autofocus />
|
||||
<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" placeholder="Comma-separated names" @bind="_editNames" />
|
||||
<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>
|
||||
@@ -138,8 +194,8 @@ else
|
||||
{
|
||||
<tr class="@RowClass(row)"
|
||||
@key="@row.Id"
|
||||
title="@(row.IsEditable ? "Double-click to edit" : string.Empty)"
|
||||
@ondblclick="@(() => OnRowDoubleClick(row))">
|
||||
title="@RowTitle(row)"
|
||||
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
|
||||
<td><code>@row.Address</code></td>
|
||||
<td>@string.Join(", ", row.Names)</td>
|
||||
<td>
|
||||
@@ -155,6 +211,7 @@ else
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
}
|
||||
</table>
|
||||
@@ -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)
|
||||
{
|
||||
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;
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+37
@@ -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();
|
||||
}
|
||||
+4
-40
@@ -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; }
|
||||
}
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
-157
@@ -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();
|
||||
}
|
||||
|
||||
+13
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user