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
@@ -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>