mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 01:42:08 +10:00
Effective config: do-0x20-encode revert fix, validation UI, custom displays, option help
- Fix do-0x20-encode: clearing pending when toggling back to original (Disabled/Default) - Do0x20EncodeDisplay: uncheck uses pending.OldValue when present - EffectiveConfigFieldDisplay: use pending.OldValue as _valueAtActivation when re-entering with pending - Effective config: validation errors/warnings in toolbar and save modal, revert from modal - Custom displays: Do0x20EncodeDisplay (tri-state), KeyOnlyOrValueDisplay - ExplicitToggleState, config/apply support for do-0x20-encode, option help and tests
This commit is contained in:
@@ -24,7 +24,21 @@
|
||||
{
|
||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
@if (ReloadCommandConfigured && OnReloadRequested.HasDelegate)
|
||||
{
|
||||
<button class="btn btn-primary btn-sm" @onclick="InvokeReloadAsync" disabled="@IsReloading" title="Reload dnsmasq config (e.g. SIGHUP)">
|
||||
@if (IsReloading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
|
||||
}
|
||||
Reload config
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
[Parameter] public bool IsReloading { get; set; }
|
||||
|
||||
/// <summary>When set and reload is configured, show a "Reload config" button next to Refresh.</summary>
|
||||
[Parameter] public EventCallback OnReloadRequested { get; set; }
|
||||
|
||||
[Parameter] public bool ReloadCommandConfigured { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
@@ -55,6 +60,8 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
||||
|
||||
private Task InvokeReloadAsync() => OnReloadRequested.InvokeAsync();
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
|
||||
+8
-5
@@ -18,7 +18,7 @@
|
||||
}
|
||||
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
ValidationErrorCount="@_validationErrorCount" ValidationWarningCount="@_validationWarningCount"
|
||||
ValidationSummary="@Session.GetValidationSummary()"
|
||||
AllSectionsExpanded="@_allSectionsExpanded"
|
||||
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||
<CascadingValue Value="@Session">
|
||||
@@ -28,7 +28,7 @@
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@
|
||||
private HashSet<string> _openPanels = new();
|
||||
private bool _showSaveModal;
|
||||
|
||||
private int _validationErrorCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Error);
|
||||
private int _validationWarningCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Warning);
|
||||
|
||||
private string? _optionHelpKey;
|
||||
private string? _optionHelpAnchorId;
|
||||
private string _optionHelpTitle = "Option help";
|
||||
@@ -199,6 +196,12 @@
|
||||
Session.SetCrossOptionIssues(issues);
|
||||
}
|
||||
|
||||
private void OnRevertedFromSaveModal()
|
||||
{
|
||||
RunCrossOptionEvaluator();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OpenSaveModal()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
|
||||
+29
-2
@@ -187,11 +187,23 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Edit wrapper (checkbox/input) shrinks to content so the editable badge stays on the same line */
|
||||
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
|
||||
::deep .ec-field-value-when-editing {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Edit wrapper (checkbox/input) shrinks to content */
|
||||
::deep .ec-field-edit-wrapper {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Input/select in value area can shrink so the editable badge stays on the same line */
|
||||
@@ -253,6 +265,21 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Only when field is in edit mode: push ok/cancel/revert to end and allow them to wrap first on narrow widths */
|
||||
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
::deep .ec-section-header {
|
||||
padding-left: 0.75rem;
|
||||
|
||||
+25
-6
@@ -35,12 +35,24 @@
|
||||
<span class="small ec-toolbar-validation-count" aria-live="polite">
|
||||
@if (ValidationErrorCount > 0)
|
||||
{
|
||||
<span class="text-danger fw-medium">errors: @ValidationErrorCount</span>
|
||||
<span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0"
|
||||
title="@ValidationErrorTooltip"
|
||||
aria-label="Open save dialog"
|
||||
@onclick="OnOpenSaveModal"
|
||||
@onkeydown="OnValidationLinkKeyDown">
|
||||
@ValidationErrorCount error(s)
|
||||
</span>
|
||||
}
|
||||
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
|
||||
@if (ValidationWarningCount > 0)
|
||||
{
|
||||
<span class="text-warning">warnings: @ValidationWarningCount</span>
|
||||
<span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0"
|
||||
title="@ValidationWarningTooltip"
|
||||
aria-label="Open save dialog"
|
||||
@onclick="OnOpenSaveModal"
|
||||
@onkeydown="OnValidationLinkKeyDown">
|
||||
@ValidationWarningCount warning(s)
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@@ -60,8 +72,11 @@
|
||||
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public int PendingChangesCount { get; set; }
|
||||
[Parameter] public int ValidationErrorCount { get; set; }
|
||||
[Parameter] public int ValidationWarningCount { get; set; }
|
||||
[Parameter] public IReadOnlyList<FieldIssue>? ValidationSummary { get; set; }
|
||||
private int ValidationErrorCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Error) ?? 0;
|
||||
private int ValidationWarningCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Warning) ?? 0;
|
||||
private string ValidationErrorTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Error).Select(i => i.Message));
|
||||
private string ValidationWarningTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Warning).Select(i => i.Message));
|
||||
[Parameter] public bool AllSectionsExpanded { get; set; }
|
||||
[Parameter] public EventCallback OnExpandAll { get; set; }
|
||||
[Parameter] public EventCallback OnCollapseAll { get; set; }
|
||||
@@ -85,8 +100,12 @@
|
||||
private async Task OnChangesLinkKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" || e.Key == " ")
|
||||
{
|
||||
await OnOpenSaveModal.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidationLinkKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" || e.Key == " ")
|
||||
await OnOpenSaveModal.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* do-0x20-encode / no-0x20-encode: tri-state. Checkbox shows Enabled vs not-Enabled; uncheck maps back to Default when that was the value at activation, else Disabled. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isOn = GetState() == ExplicitToggleState.Enabled;
|
||||
<input type="checkbox" class="form-check-input" checked="@isOn" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled")
|
||||
}
|
||||
|
||||
@code {
|
||||
private ExplicitToggleState _stateAtActivation;
|
||||
private bool _wasActiveEditor;
|
||||
|
||||
private ExplicitToggleState GetState()
|
||||
{
|
||||
var v = GetEffectiveDisplayValue();
|
||||
if (v is ExplicitToggleState s) return s;
|
||||
return ExplicitToggleState.Default;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!IsActiveEditor)
|
||||
_stateAtActivation = GetState();
|
||||
else if (!_wasActiveEditor)
|
||||
_stateAtActivation = GetState();
|
||||
_wasActiveEditor = IsActiveEditor;
|
||||
}
|
||||
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
var isChecked = e.Value is true;
|
||||
if (isChecked)
|
||||
{
|
||||
await NotifyValueChanged(ExplicitToggleState.Enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
if (pending?.OldValue is ExplicitToggleState previousState)
|
||||
{
|
||||
await NotifyValueChanged(previousState);
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing pending edit for this field: map unchecked based on the state at activation.
|
||||
// Default and Disabled both display as "Disabled" in view mode, so we preserve true intent here.
|
||||
await NotifyValueChanged(_stateAtActivation == ExplicitToggleState.Default ? ExplicitToggleState.Default : ExplicitToggleState.Disabled);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Options that can be: not set, key-only (e.g. use-stale-cache), or key=value (e.g. use-stale-cache=60). Checkbox to enable; when enabled, optional value input. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isEnabled = _editorValue != null;
|
||||
var valuePart = _editorValue is { Length: > 0 } ? _editorValue : "";
|
||||
<div class="ec-key-only-or-value-editor">
|
||||
<div class="form-check form-switch mb-0 ec-key-only-or-value-switch">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
checked="@isEnabled"
|
||||
aria-label="Enable @Descriptor.OptionName"
|
||||
title="Set @Descriptor.OptionName"
|
||||
@onchange="OnCheckedChange" />
|
||||
</div>
|
||||
@if (isEnabled)
|
||||
{
|
||||
<input type="text"
|
||||
class="form-control form-control-sm ec-key-only-or-value-input"
|
||||
placeholder="Optional value"
|
||||
value="@valuePart"
|
||||
@oninput="OnValueInput" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (v is null)
|
||||
{
|
||||
@("(not set)")
|
||||
}
|
||||
else if (v.Length == 0)
|
||||
{
|
||||
@("Enabled")
|
||||
}
|
||||
else
|
||||
{
|
||||
@v
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? _editorValue;
|
||||
private bool _wasActiveEditor;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var effective = GetEffectiveDisplayValue() as string;
|
||||
if (!IsActiveEditor)
|
||||
{
|
||||
_editorValue = effective;
|
||||
}
|
||||
else if (!_wasActiveEditor)
|
||||
{
|
||||
_editorValue = effective;
|
||||
}
|
||||
|
||||
_wasActiveEditor = IsActiveEditor;
|
||||
}
|
||||
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
var isChecked = e.Value switch
|
||||
{
|
||||
bool b => b,
|
||||
string s when bool.TryParse(s, out var parsed) => parsed,
|
||||
_ => false
|
||||
};
|
||||
|
||||
_editorValue = isChecked ? "" : null;
|
||||
await NotifyValueChanged(_editorValue);
|
||||
}
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString()?.Trim();
|
||||
_editorValue = string.IsNullOrEmpty(raw) ? "" : raw;
|
||||
await NotifyValueChanged(_editorValue);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
.ec-key-only-or-value-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
min-height: 1.25rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-switch .form-check-input {
|
||||
float: none;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-input {
|
||||
flex: 1 1 7rem;
|
||||
min-width: 0;
|
||||
width: 7rem;
|
||||
max-width: 9rem;
|
||||
}
|
||||
+25
-10
@@ -22,12 +22,15 @@
|
||||
<span class="ec-field-value">
|
||||
@if (isActiveEditor)
|
||||
{
|
||||
<div class="ec-field-edit-wrapper" @onfocusout="OnBlur" @onclick:stopPropagation="true">
|
||||
<CascadingValue Value="@true">
|
||||
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
||||
@((fragmentWithCallback ?? fragment)(Descriptor))
|
||||
<div class="ec-field-value-when-editing" @onclick:stopPropagation="true">
|
||||
<div class="ec-field-edit-wrapper">
|
||||
<CascadingValue Value="@true">
|
||||
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
||||
@((fragmentWithCallback ?? fragment)(Descriptor))
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</div>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -35,8 +38,8 @@
|
||||
<CascadingValue Value="@false">
|
||||
@fragment(Descriptor)
|
||||
</CascadingValue>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
@@ -111,6 +114,7 @@ else
|
||||
private bool _hasPendingChange;
|
||||
private object? _valueAtActivation;
|
||||
private object? _draftValue;
|
||||
private bool _hasDraftValue;
|
||||
private bool _wasActive;
|
||||
private ExitAction _exitAction = ExitAction.None;
|
||||
|
||||
@@ -140,7 +144,12 @@ else
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = descriptorValue;
|
||||
{
|
||||
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
||||
_valueAtActivation = pending != null ? pending.OldValue : descriptorValue;
|
||||
_hasDraftValue = false;
|
||||
_draftValue = null;
|
||||
}
|
||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
if (_wasActive && !IsActive)
|
||||
@@ -190,8 +199,7 @@ else
|
||||
private void HandleValueChanged(object? value)
|
||||
{
|
||||
_draftValue = value;
|
||||
if (_hasPendingChange && ValuesEqual(_valueAtActivation, value))
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
_hasDraftValue = true;
|
||||
}
|
||||
|
||||
private async Task OnBlur(FocusEventArgs _)
|
||||
@@ -202,10 +210,11 @@ else
|
||||
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? _effectiveValue;
|
||||
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
||||
if (ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
if (_hasPendingChange && Ui?.RevertFieldAsync != null)
|
||||
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
|
||||
return true;
|
||||
@@ -228,6 +237,7 @@ else
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -236,12 +246,14 @@ else
|
||||
private void HandleDefaultValueChanged(string value)
|
||||
{
|
||||
_draftValue = value;
|
||||
_hasDraftValue = true;
|
||||
}
|
||||
|
||||
private async Task HandleDefaultBlur(string? value)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = value;
|
||||
_hasDraftValue = true;
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
@@ -249,6 +261,7 @@ else
|
||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
_hasDraftValue = true;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
StateHasChanged();
|
||||
@@ -266,6 +279,7 @@ else
|
||||
private async Task HandleCancelEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
_exitAction = ExitAction.Cancel;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
@@ -274,6 +288,7 @@ else
|
||||
private async Task HandleRevertEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_exitAction = ExitAction.Revert;
|
||||
if (Ui != null)
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
|
||||
[Parameter] public EventCallback OnRevertedNotify { get; set; }
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public EventCallback OnContinueEditingAfterSavedFailure { get; set; }
|
||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||
@@ -60,6 +62,8 @@
|
||||
|
||||
private async Task HandleReverted()
|
||||
{
|
||||
if (OnRevertedNotify.HasDelegate)
|
||||
await OnRevertedNotify.InvokeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="ec-save-modal-title" aria-modal="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable" role="document">
|
||||
@@ -85,6 +86,35 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
|
||||
{
|
||||
<div class="ec-save-modal-validation mb-3">
|
||||
@if (ValidationErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger py-2 mb-2">
|
||||
<strong>Validation errors</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
@foreach (var issue in ValidationErrors)
|
||||
{
|
||||
<li>@issue.Message</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (ValidationWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning py-2 mb-2">
|
||||
<strong>Validation warnings</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
@foreach (var issue in ValidationWarnings)
|
||||
{
|
||||
<li>@issue.Message</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
||||
@if (ManagedFilePath != null)
|
||||
{
|
||||
@@ -114,9 +144,9 @@
|
||||
@if (!isMultiValue)
|
||||
{
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
<span>@FormatValue(change.NewValue, change.OptionName)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -180,7 +210,7 @@
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0 || HasBlockingValidationErrors)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,6 +236,10 @@
|
||||
|
||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||
|
||||
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 static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
|
||||
{
|
||||
var err = (result.ValidationStdErr ?? "").Trim();
|
||||
@@ -248,8 +282,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v)
|
||||
private static string FormatValue(object? v, string? optionName = null)
|
||||
{
|
||||
if (optionName != null && EffectiveConfigWriteBehaviorMap.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
|
||||
return FormatKeyOnlyOrValue(v);
|
||||
if (v == null) return "(not set)";
|
||||
if (v is bool b) return b ? "on" : "off";
|
||||
var list = AsStringList(v);
|
||||
@@ -257,6 +293,14 @@
|
||||
return v.ToString() ?? "(not set)";
|
||||
}
|
||||
|
||||
private static string FormatKeyOnlyOrValue(object? v)
|
||||
{
|
||||
if (v == null) return "(not set)";
|
||||
var s = v.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return "(set)";
|
||||
return s;
|
||||
}
|
||||
|
||||
private async Task Revert(PendingEffectiveConfigChange change)
|
||||
{
|
||||
Session.RevertChange(change.SectionId, change.OptionName);
|
||||
|
||||
@@ -76,7 +76,7 @@ else if (_status != null)
|
||||
</div>
|
||||
}
|
||||
|
||||
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnReloadRequested="ReloadDnsmasq" ReloadCommandConfigured="_status.ReloadCommandConfigured" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection Status="_status" RefreshIntervalSeconds="@ClientSettingsFields.RecentLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.RecentLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.RecentLogsAutoScroll.Value" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
<AppLogsSection RefreshIntervalSeconds="@ClientSettingsFields.AppLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.AppLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.AppLogsAutoScroll.Value" OnOpenSettings="OpenAppLogsSettings" />
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user