mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Effective config: multi-value editor, config service fixes, save result improvements
- Add EffectiveConfigMultiValueListEditor with badge-style actions, draft merge, and display-mode editable badges; fix row callback capture and bounds checks - EffectiveConfigFieldDisplay: compute multi-value state in OnParametersSet + HandleMultiValueChanged; BuildItemsWithSource preserves readonly/editable; dedupe list on commit; defensive copy for multi-value commit - DnsmasqConfigService: generic MatchesOption via key parsing; FilterManagedOnly + ReadReadonlyMultiValuesByOptionAsync (single-pass readonly); EnsureManagedConfigInvariants + EnsureUniqueDirective; fix DHCP write cache notification (re-parse written output) - EffectiveConfigSaveResult: ErrorCodes constants, IsValidateFailed; set Validated=false when validation fails; EffectiveConfigSaveFlow use IsValidateFailed; save service use ErrorCodes - OptionStateBadge: OnEditableClickRequested for list-editor per-item edit - Validation service and config save flow updates
This commit is contained in:
+63
-7
@@ -44,13 +44,10 @@
|
|||||||
}
|
}
|
||||||
else if (Descriptor.IsMultiValue)
|
else if (Descriptor.IsMultiValue)
|
||||||
{
|
{
|
||||||
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList
|
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||||
? pendingList.Select(s => new ValueWithSource(s, null)).ToList()
|
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||||
: Descriptor.GetItems();
|
|
||||||
var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true);
|
|
||||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
|
||||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||||
Source="@(allReadonly ? items?.FirstOrDefault()?.Source : null)" ShowEditableBadge="@(!allReadonly)" HasPendingChange="@_hasPendingChange" FieldKey="@FieldKey"
|
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -98,6 +95,15 @@ else
|
|||||||
private bool _wasActive;
|
private bool _wasActive;
|
||||||
private ExitAction _exitAction = ExitAction.None;
|
private ExitAction _exitAction = ExitAction.None;
|
||||||
|
|
||||||
|
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
|
||||||
|
private IReadOnlyList<ValueWithSource>? _multiValueItems;
|
||||||
|
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
|
||||||
|
private bool _multiValueHasDraft;
|
||||||
|
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
|
||||||
|
private ConfigValueSource? _multiValueRowSource;
|
||||||
|
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
|
||||||
|
private bool _multiValueHasPendingOrDraft;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||||
@@ -112,6 +118,8 @@ else
|
|||||||
: Descriptor.GetValue();
|
: Descriptor.GetValue();
|
||||||
_hasPendingChange = pending != null;
|
_hasPendingChange = pending != null;
|
||||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||||
|
if (Descriptor.IsMultiValue)
|
||||||
|
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||||
if (!_wasActive && IsActive)
|
if (!_wasActive && IsActive)
|
||||||
_valueAtActivation = descriptorValue;
|
_valueAtActivation = descriptorValue;
|
||||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||||
@@ -121,6 +129,45 @@ else
|
|||||||
_wasActive = IsActive;
|
_wasActive = IsActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
|
||||||
|
{
|
||||||
|
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
|
||||||
|
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
|
||||||
|
if (_multiValueHasDraft && draftList != null)
|
||||||
|
{
|
||||||
|
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
|
||||||
|
}
|
||||||
|
else if (_hasPendingChange && pendingList != null)
|
||||||
|
{
|
||||||
|
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_multiValueItems = descriptorItems;
|
||||||
|
}
|
||||||
|
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
|
||||||
|
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
|
||||||
|
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
|
||||||
|
var list = new List<ValueWithSource>(values.Count);
|
||||||
|
foreach (var s in values)
|
||||||
|
{
|
||||||
|
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
list.Add(pool[idx]);
|
||||||
|
pool.RemoveAt(idx);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
list.Add(new ValueWithSource(s, null));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleValueChanged(object? value)
|
private void HandleValueChanged(object? value)
|
||||||
{
|
{
|
||||||
_draftValue = value;
|
_draftValue = value;
|
||||||
@@ -145,7 +192,13 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
var source = Descriptor.GetSource();
|
var source = Descriptor.GetSource();
|
||||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
|
object? valueToCommit = newValue;
|
||||||
|
if (newValue is IReadOnlyList<string> list)
|
||||||
|
{
|
||||||
|
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
valueToCommit = deduped;
|
||||||
|
}
|
||||||
|
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
|
||||||
if (Ui != null)
|
if (Ui != null)
|
||||||
await Ui.CommitFieldAsync(args);
|
await Ui.CommitFieldAsync(args);
|
||||||
}
|
}
|
||||||
@@ -171,6 +224,9 @@ else
|
|||||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
_draftValue = values.ToList();
|
_draftValue = values.ToList();
|
||||||
|
if (Descriptor.IsMultiValue)
|
||||||
|
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleConfirmEdit()
|
private async Task HandleConfirmEdit()
|
||||||
|
|||||||
+8
-2
@@ -2,9 +2,12 @@
|
|||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
|
||||||
@Value
|
@Value
|
||||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="false" EditableAsButton="false" />
|
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
|
||||||
|
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
|
||||||
|
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="@IsEditMode"
|
||||||
|
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -13,4 +16,7 @@
|
|||||||
[Parameter] public string OptionName { get; set; } = "";
|
[Parameter] public string OptionName { get; set; } = "";
|
||||||
[Parameter] public bool IsEditMode { get; set; }
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
[Parameter] public string? ReadonlyBadgeAnchorId { 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
|
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||||
|
<ol class="ec-field-value-list">
|
||||||
|
@if (SourceItems?.Count > 0)
|
||||||
|
{
|
||||||
|
@for (var i = 0; i < SourceItems.Count; i++)
|
||||||
|
{
|
||||||
|
var item = SourceItems[i];
|
||||||
|
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
|
||||||
|
var itemReadonly = item.Source?.IsReadOnly == true;
|
||||||
|
var rowIndex = i;
|
||||||
|
var rowValue = item.Value;
|
||||||
|
|
||||||
|
<li class="ec-multi-list-row" @key="anchorId">
|
||||||
|
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm ec-multi-list-input"
|
||||||
|
@bind="_draftValue" @bind:event="oninput" />
|
||||||
|
<span class="status-badge-inline">
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||||
|
Title="Apply change" AriaLabel="Apply change"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||||
|
Title="Discard edit" AriaLabel="Cancel edit"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="ec-field-value-item">@rowValue</span>
|
||||||
|
|
||||||
|
<span class="status-badge-inline">
|
||||||
|
<OptionStateBadge
|
||||||
|
AnchorId="@anchorId"
|
||||||
|
Source="@item.Source"
|
||||||
|
OptionName="@OptionName"
|
||||||
|
Value="@rowValue"
|
||||||
|
IsInteractive="@IsEditMode"
|
||||||
|
ShowEditableBadge="@(!itemReadonly)"
|
||||||
|
EditableAsButton="@(!itemReadonly)"
|
||||||
|
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
|
||||||
|
@if (!itemReadonly)
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||||
|
Title="Remove this value" AriaLabel="Remove"
|
||||||
|
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-row ec-multi-list-empty-hint">
|
||||||
|
<span class="text-muted">(none)</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (AllowAddManagedValue)
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
|
||||||
|
@if (_addingNew)
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm ec-multi-list-input"
|
||||||
|
placeholder="New value"
|
||||||
|
@bind="_draftValue" @bind:event="oninput" />
|
||||||
|
<span class="status-badge-inline">
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||||
|
Title="Add this value" AriaLabel="Add"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||||
|
Title="Discard" AriaLabel="Cancel"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
|
||||||
|
Title="Add a new value" AriaLabel="Add item"
|
||||||
|
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||||
|
[Parameter] public string OptionName { get; set; } = "";
|
||||||
|
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
|
||||||
|
|
||||||
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
|
||||||
|
|
||||||
|
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||||
|
|
||||||
|
private const string AddItemKey = "add-item";
|
||||||
|
private int? _editingIndex;
|
||||||
|
private bool _addingNew;
|
||||||
|
private string _draftValue = "";
|
||||||
|
|
||||||
|
private void BeginEdit(int index, string current)
|
||||||
|
{
|
||||||
|
_addingNew = false;
|
||||||
|
_editingIndex = index;
|
||||||
|
_draftValue = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginAdd()
|
||||||
|
{
|
||||||
|
_editingIndex = null;
|
||||||
|
_addingNew = true;
|
||||||
|
_draftValue = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelInline()
|
||||||
|
{
|
||||||
|
_editingIndex = null;
|
||||||
|
_addingNew = false;
|
||||||
|
_draftValue = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit(int index)
|
||||||
|
{
|
||||||
|
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||||
|
.Select(x => x.Value)
|
||||||
|
.ToList();
|
||||||
|
if (index < 0 || index >= next.Count) return;
|
||||||
|
|
||||||
|
next[index] = (_draftValue ?? "").Trim();
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
CancelInline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveNew()
|
||||||
|
{
|
||||||
|
var value = (_draftValue ?? "").Trim();
|
||||||
|
if (value.Length == 0) return;
|
||||||
|
|
||||||
|
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||||
|
.Select(x => x.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
next.Add(value);
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
CancelInline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAt(int index)
|
||||||
|
{
|
||||||
|
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
|
||||||
|
var item = SourceItems[index];
|
||||||
|
if (item?.Source?.IsReadOnly == true) return;
|
||||||
|
|
||||||
|
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||||
|
.Select(x => x.Value)
|
||||||
|
.ToList();
|
||||||
|
if (index < 0 || index >= next.Count) return;
|
||||||
|
|
||||||
|
next.RemoveAt(index);
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/* Editor block: distinct from display mode (border + background) */
|
||||||
|
.ec-multi-list-editor {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-editor .ec-field-value-list {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-row .ec-field-value-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-row .status-badge-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||||
|
max-width: 28rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-empty-hint {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-add-item {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed var(--bs-border-color, #dee2e6);
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-add-item .form-control.ec-multi-list-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-add-item .status-badge-inline {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-multi-list-add-btn {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
+12
-30
@@ -6,13 +6,17 @@
|
|||||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
||||||
@if (IsActiveEditor)
|
@if (IsActiveEditor)
|
||||||
{
|
{
|
||||||
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
|
<EffectiveConfigMultiValueListEditor
|
||||||
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
|
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
|
||||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
OptionName="@Label"
|
||||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
IsEditMode="@IsEditMode"
|
||||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
AllowAddManagedValue="@IsEditMode"
|
||||||
</span>
|
OnValuesChanged="@ItemsChanged" />
|
||||||
|
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||||
|
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||||
|
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||||
|
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -23,7 +27,7 @@
|
|||||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
||||||
{
|
{
|
||||||
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
|
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
|
||||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" /></li>
|
<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>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
}
|
}
|
||||||
@@ -31,10 +35,6 @@
|
|||||||
{
|
{
|
||||||
<span class="text-muted">(none)</span>
|
<span class="text-muted">(none)</span>
|
||||||
}
|
}
|
||||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
|
||||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
|
||||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
|
||||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
@@ -56,22 +56,4 @@
|
|||||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||||
|
|
||||||
private string _editLines = "";
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
if (Items != null && Items.Count > 0)
|
|
||||||
_editLines = string.Join("\n", Items.Select(i => i.Value));
|
|
||||||
else
|
|
||||||
_editLines = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnLinesInput(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
var text = e.Value?.ToString() ?? "";
|
|
||||||
_editLines = text;
|
|
||||||
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
|
||||||
await ItemsChanged.InvokeAsync(lines);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
Review,
|
Review,
|
||||||
Saving,
|
Saving,
|
||||||
WriteFailed,
|
WriteFailed,
|
||||||
|
ValidateFailed,
|
||||||
ReloadFailed,
|
ReloadFailed,
|
||||||
RestoreInProgress,
|
RestoreInProgress,
|
||||||
RestoreFailed
|
RestoreFailed
|
||||||
@@ -83,7 +84,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Saved && !result.Restarted)
|
if (result.Saved && result.IsValidateFailed)
|
||||||
|
_saveState = SaveUiState.ValidateFailed;
|
||||||
|
else if (result.Saved && !result.Restarted)
|
||||||
_saveState = SaveUiState.ReloadFailed;
|
_saveState = SaveUiState.ReloadFailed;
|
||||||
else if (!result.Saved)
|
else if (!result.Saved)
|
||||||
_saveState = SaveUiState.WriteFailed;
|
_saveState = SaveUiState.WriteFailed;
|
||||||
|
|||||||
@@ -15,7 +15,28 @@
|
|||||||
{
|
{
|
||||||
<div class="ec-save-modal-loading text-center py-4">
|
<div class="ec-save-modal-loading text-center py-4">
|
||||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")</p>
|
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null)
|
||||||
|
{
|
||||||
|
<div class="ec-save-modal-reload-failed">
|
||||||
|
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||||
|
<div class="ec-save-modal-alert-text">
|
||||||
|
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (SaveResult.BackupPath != null)
|
||||||
|
{
|
||||||
|
<p class="ec-save-modal-backup-path mb-2">
|
||||||
|
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="ec-save-modal-stderr mb-3">
|
||||||
|
<p class="text-muted small mb-1"><strong>Validation output</strong></p>
|
||||||
|
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetValidationFailedOutput(SaveResult)</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||||
@@ -129,7 +150,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer ec-save-modal-footer">
|
<div class="modal-footer ec-save-modal-footer">
|
||||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
|
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
||||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||||
@@ -172,6 +193,18 @@
|
|||||||
|
|
||||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||||
|
|
||||||
|
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
|
||||||
|
{
|
||||||
|
var err = (result.ValidationStdErr ?? "").Trim();
|
||||||
|
var outp = (result.ValidationStdOut ?? "").Trim();
|
||||||
|
if (err.Length > 0 && outp.Length > 0)
|
||||||
|
return err + "\n\n" + outp;
|
||||||
|
if (err.Length > 0) return err;
|
||||||
|
if (outp.Length > 0) return outp;
|
||||||
|
var exitInfo = result.ValidationExitCode >= 0 ? $"Exit code: {result.ValidationExitCode}." : "";
|
||||||
|
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
|
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
|
||||||
{
|
{
|
||||||
var err = (result.RestartStdErr ?? "").Trim();
|
var err = (result.RestartStdErr ?? "").Trim();
|
||||||
|
|||||||
@@ -4,17 +4,25 @@
|
|||||||
|
|
||||||
@if (Source?.IsReadOnly == true)
|
@if (Source?.IsReadOnly == true)
|
||||||
{
|
{
|
||||||
@if (IsInteractive && Ui != null)
|
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
|
||||||
|
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||||
{
|
{
|
||||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||||
Id="@AnchorId" Title="@(Source?.GetReadOnlyTooltip())"
|
Id="@AnchorId" Title="@readonlyTitle"
|
||||||
|
OnClick="OnEditableClick"
|
||||||
|
Class="status-badge-inline" />
|
||||||
|
}
|
||||||
|
else if (IsInteractive && Ui != null)
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||||
|
Id="@AnchorId" Title="@readonlyTitle"
|
||||||
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
|
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
|
||||||
Class="status-badge-inline" />
|
Class="status-badge-inline" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
|
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
|
||||||
Title="@(Source?.GetReadOnlyTooltip())"
|
Title="@readonlyTitle"
|
||||||
Class="status-badge-inline" />
|
Class="status-badge-inline" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,12 @@ else if (ShowEditableBadge)
|
|||||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||||
[Parameter] public string? FieldKey { get; set; }
|
[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; }
|
||||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||||
|
|
||||||
private async Task OnConfirmClick()
|
private async Task OnConfirmClick()
|
||||||
@@ -93,10 +107,15 @@ else if (ShowEditableBadge)
|
|||||||
await OnRevertRequested.InvokeAsync();
|
await OnRevertRequested.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task OnEditableClick()
|
private async Task OnEditableClick()
|
||||||
{
|
{
|
||||||
if (Ui == null || string.IsNullOrEmpty(FieldKey)) return Task.CompletedTask;
|
if (OnEditableClickRequested.HasDelegate)
|
||||||
return Ui.ActivateFieldAsync(FieldKey);
|
{
|
||||||
|
await OnEditableClickRequested.InvokeAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||||
|
await Ui.ActivateFieldAsync(FieldKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task OnReadonlyClick()
|
private Task OnReadonlyClick()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Layout
|
@namespace DnsmasqWebUI.Components.Layout
|
||||||
@using System.Reflection
|
@using System.Reflection
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
@inject IOptions<ApplicationOptions> AppOptions
|
@inject IOptions<ApplicationOptions> AppOptions
|
||||||
@inject ISettingsModalService SettingsModalService
|
@inject ISettingsModalService SettingsModalService
|
||||||
@inject IUpdateCheckService UpdateCheckService
|
@inject IUpdateCheckService UpdateCheckService
|
||||||
|
@inject INotificationService NotificationService
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
@@ -110,6 +112,12 @@
|
|||||||
private async Task ForceCheckAsync()
|
private async Task ForceCheckAsync()
|
||||||
{
|
{
|
||||||
await UpdateCheckService.CheckNowAsync();
|
await UpdateCheckService.CheckNowAsync();
|
||||||
|
if (UpdateCheckService.LastCheckFailed)
|
||||||
|
NotificationService.ShowError("Update check failed: " + (UpdateCheckService.LastCheckErrorMessage ?? "Unknown error."));
|
||||||
|
else if (UpdateCheckService.NewerVersionAvailable)
|
||||||
|
NotificationService.ShowSuccess($"New version {UpdateCheckService.NewerVersionTag} is available.");
|
||||||
|
else
|
||||||
|
NotificationService.ShowInfo("You're up to date.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
|
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
|
||||||
|
|||||||
@@ -64,18 +64,54 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
return "line:" + e.LineNumber;
|
return "line:" + e.LineNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>When managedHostsPath is set, ensures the managed config has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line or inserts at start). So dnsmasq loads our managed hosts file last.</summary>
|
/// <summary>
|
||||||
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string? managedHostsPath)
|
/// Applies managed-config invariants (system-owned directives) that are not user option edits.
|
||||||
|
/// This runs at write-time and is intentionally outside the pending-changes model.
|
||||||
|
/// </summary>
|
||||||
|
private static void EnsureManagedConfigInvariants(List<DnsmasqConfLine> configLines, string? managedHostsPath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(managedHostsPath))
|
if (!string.IsNullOrEmpty(managedHostsPath))
|
||||||
|
{
|
||||||
|
EnsureUniqueDirective(
|
||||||
|
configLines,
|
||||||
|
DnsmasqConfKeys.AddnHosts,
|
||||||
|
managedHostsPath,
|
||||||
|
lineNumber => new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = managedHostsPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures exactly one directive with the given key/value exists in the managed file.
|
||||||
|
/// If multiple exist, keeps the first and removes the rest.
|
||||||
|
/// </summary>
|
||||||
|
private static void EnsureUniqueDirective(
|
||||||
|
List<DnsmasqConfLine> configLines,
|
||||||
|
string key,
|
||||||
|
string value,
|
||||||
|
Func<int, DnsmasqConfLine>? lineFactory = null)
|
||||||
|
{
|
||||||
|
var matchingIndices = new List<int>();
|
||||||
|
for (var i = 0; i < configLines.Count; i++)
|
||||||
|
{
|
||||||
|
var raw = DnsmasqConfFileLineParser.ToLine(configLines[i]);
|
||||||
|
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
|
||||||
|
if (kv != null &&
|
||||||
|
string.Equals(kv.Value.key, key, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(kv.Value.value.Trim(), value, StringComparison.Ordinal))
|
||||||
|
matchingIndices.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingIndices.Count == 0)
|
||||||
|
{
|
||||||
|
configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine { LineNumber = 1, RawLine = $"{key}={value}" });
|
||||||
return;
|
return;
|
||||||
var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts);
|
}
|
||||||
var lineNumber = idx >= 0 ? configLines[idx].LineNumber : 1;
|
|
||||||
var line = new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = managedHostsPath };
|
var keepIdx = matchingIndices[0];
|
||||||
if (idx >= 0)
|
var keepLineNumber = configLines[keepIdx].LineNumber;
|
||||||
configLines[idx] = line;
|
configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine { LineNumber = keepLineNumber, RawLine = $"{key}={value}" };
|
||||||
else
|
for (var i = matchingIndices.Count - 1; i >= 1; i--)
|
||||||
configLines.Insert(0, line);
|
configLines.RemoveAt(matchingIndices[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the managed hosts file empty if it does not exist, so dnsmasq does not error when we add addn-hosts=<path> to the managed config.</summary>
|
/// <summary>Creates the managed hosts file empty if it does not exist, so dnsmasq does not error when we add addn-hosts=<path> to the managed config.</summary>
|
||||||
@@ -124,7 +160,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
rawLines = Array.Empty<string>();
|
rawLines = Array.Empty<string>();
|
||||||
|
|
||||||
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
|
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
|
||||||
EnsureOneAddnHostsLine(configLines, set.ManagedHostsFilePath);
|
EnsureManagedConfigInvariants(configLines, set.ManagedHostsFilePath);
|
||||||
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
|
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
|
||||||
AssignStableIds(fileEntries);
|
AssignStableIds(fileEntries);
|
||||||
|
|
||||||
@@ -148,8 +184,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct);
|
await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct);
|
||||||
File.Move(tmpPath, path, overwrite: true);
|
File.Move(tmpPath, path, overwrite: true);
|
||||||
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
|
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
|
||||||
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
|
|
||||||
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
|
var writtenLines = DnsmasqConfFileLineParser.ParseFile(output).ToList();
|
||||||
|
var effectiveHostsPathDhcp = writtenLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
|
||||||
|
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(writtenLines, effectiveHostsPathDhcp));
|
||||||
_logger.LogInformation("Wrote managed config file: {Path}", path);
|
_logger.LogInformation("Wrote managed config file: {Path}", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +225,9 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
public async Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default)
|
public async Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (changes.Count == 0) return;
|
if (changes.Count == 0) return;
|
||||||
var content = await ReadManagedConfigAsync(ct);
|
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
|
||||||
var list = content.Lines.ToList();
|
var readonlyByOption = await ReadReadonlyMultiValuesByOptionAsync(snapshot.Set, ct);
|
||||||
|
var list = snapshot.ManagedContent.Lines.ToList();
|
||||||
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
|
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
|
||||||
foreach (var c in changes)
|
foreach (var c in changes)
|
||||||
{
|
{
|
||||||
@@ -198,13 +237,15 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
|
|
||||||
bool MatchesOption(DnsmasqConfLine line)
|
bool MatchesOption(DnsmasqConfLine line)
|
||||||
{
|
{
|
||||||
if (line is not OtherLine o) return false;
|
var raw = DnsmasqConfFileLineParser.ToLine(line);
|
||||||
var raw = o.RawLine.Trim();
|
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
|
||||||
return raw == confKey || raw.StartsWith(confKey + "=", StringComparison.Ordinal);
|
return kv != null && string.Equals(kv.Value.key, confKey, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
|
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
|
||||||
{
|
{
|
||||||
|
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
|
||||||
|
var valuesToWrite = FilterManagedOnly(values, readonlyValues);
|
||||||
var matchingIndices = new List<int>();
|
var matchingIndices = new List<int>();
|
||||||
for (var i = 0; i < list.Count; i++)
|
for (var i = 0; i < list.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -213,10 +254,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
for (var i = matchingIndices.Count - 1; i >= 0; i--)
|
for (var i = matchingIndices.Count - 1; i >= 0; i--)
|
||||||
list.RemoveAt(matchingIndices[i]);
|
list.RemoveAt(matchingIndices[i]);
|
||||||
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
|
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
|
||||||
for (var i = 0; i < values.Count; i++)
|
for (var i = 0; i < valuesToWrite.Count; i++)
|
||||||
{
|
{
|
||||||
var lineKey = confKey;
|
var lineKey = confKey;
|
||||||
var lineText = string.IsNullOrEmpty(values[i]) ? lineKey : lineKey + "=" + values[i];
|
var lineText = string.IsNullOrEmpty(valuesToWrite[i]) ? lineKey : lineKey + "=" + valuesToWrite[i];
|
||||||
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
|
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
|
||||||
maxLineNumber++;
|
maxLineNumber++;
|
||||||
list.Insert(insertIdx + i, lineObj);
|
list.Insert(insertIdx + i, lineObj);
|
||||||
@@ -248,6 +289,61 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
await WriteManagedConfigAsync(list, ct);
|
await WriteManagedConfigAsync(list, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps only values that should be written to managed config by subtracting values provided by
|
||||||
|
/// non-managed files (multiset subtraction, order preserved).
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> FilterManagedOnly(
|
||||||
|
IReadOnlyList<string> requestedValues,
|
||||||
|
IReadOnlyList<string> readonlyValues)
|
||||||
|
{
|
||||||
|
if (readonlyValues.Count == 0)
|
||||||
|
return requestedValues;
|
||||||
|
|
||||||
|
var readonlyCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
foreach (var value in readonlyValues)
|
||||||
|
readonlyCounts[value] = readonlyCounts.TryGetValue(value, out var count) ? count + 1 : 1;
|
||||||
|
|
||||||
|
var result = new List<string>(requestedValues.Count);
|
||||||
|
foreach (var value in requestedValues)
|
||||||
|
{
|
||||||
|
if (readonlyCounts.TryGetValue(value, out var count) && count > 0)
|
||||||
|
{
|
||||||
|
readonlyCounts[value] = count - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.Add(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads all multi-value directive values from non-managed config files, grouped by option name. Single pass per save.</summary>
|
||||||
|
private static async Task<Dictionary<string, List<string>>> ReadReadonlyMultiValuesByOptionAsync(DnsmasqConfigSet set, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||||
|
foreach (var file in set.Files.Where(f => !f.IsManaged))
|
||||||
|
{
|
||||||
|
if (!File.Exists(file.Path))
|
||||||
|
continue;
|
||||||
|
var lines = await File.ReadAllLinesAsync(file.Path, DnsmasqFileEncoding.Utf8NoBom, ct);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||||
|
if (kv == null)
|
||||||
|
continue;
|
||||||
|
var key = kv.Value.key;
|
||||||
|
var value = kv.Value.value.Trim();
|
||||||
|
if (!result.TryGetValue(key, out var list))
|
||||||
|
{
|
||||||
|
list = new List<string>();
|
||||||
|
result[key] = list;
|
||||||
|
}
|
||||||
|
list.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryGetMultiValues(object? value, out IReadOnlyList<string> values)
|
private static bool TryGetMultiValues(object? value, out IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
values = null!;
|
values = null!;
|
||||||
@@ -291,7 +387,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
|||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
var list = lines.ToList();
|
var list = lines.ToList();
|
||||||
EnsureOneAddnHostsLine(list, set.ManagedHostsFilePath);
|
EnsureManagedConfigInvariants(list, set.ManagedHostsFilePath);
|
||||||
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
|
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
|
||||||
|
|
||||||
var tmpPath = path + ".tmp";
|
var tmpPath = path + ".tmp";
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Runs the configured config validation command (e.g. dnsmasq --test --conf-file=...). Used before restart in the save flow.</summary>
|
||||||
|
public interface IConfigValidationService : IApplicationScopedService
|
||||||
|
{
|
||||||
|
/// <summary>Runs the validation command. When not configured, returns success without running.</summary>
|
||||||
|
Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of running the validation command.</summary>
|
||||||
|
public record ConfigValidationResult(
|
||||||
|
bool Success,
|
||||||
|
bool Attempted,
|
||||||
|
int ExitCode,
|
||||||
|
string? StdOut,
|
||||||
|
string? StdErr,
|
||||||
|
string? UserMessage);
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
|
using DnsmasqWebUI.Models.Config;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation;
|
||||||
|
|
||||||
|
public sealed class ConfigValidationService : IConfigValidationService
|
||||||
|
{
|
||||||
|
private readonly DnsmasqOptions _options;
|
||||||
|
private readonly IProcessRunner _processRunner;
|
||||||
|
private readonly ILogger<ConfigValidationService> _logger;
|
||||||
|
|
||||||
|
public ConfigValidationService(
|
||||||
|
IOptions<DnsmasqOptions> options,
|
||||||
|
IProcessRunner processRunner,
|
||||||
|
ILogger<ConfigValidationService> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_processRunner = processRunner;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.ValidateCommand))
|
||||||
|
{
|
||||||
|
return new ConfigValidationResult(
|
||||||
|
Success: true,
|
||||||
|
Attempted: false,
|
||||||
|
ExitCode: 0,
|
||||||
|
StdOut: null,
|
||||||
|
StdErr: null,
|
||||||
|
UserMessage: "Validation command not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = _options.ValidateCommand!
|
||||||
|
.Replace("{{MainConfigPath}}", _options.MainConfigPath, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var run = await _processRunner.RunAsync(command, _options.ValidateTimeout, ct);
|
||||||
|
|
||||||
|
var stderr = run.Stderr;
|
||||||
|
if (run.TimedOut)
|
||||||
|
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") +
|
||||||
|
$"Validation timed out after {_options.ValidateTimeoutSeconds} seconds.";
|
||||||
|
if (!string.IsNullOrWhiteSpace(run.ExceptionMessage))
|
||||||
|
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + run.ExceptionMessage;
|
||||||
|
|
||||||
|
var success = run.ExitCode == 0;
|
||||||
|
if (!success)
|
||||||
|
_logger.LogWarning("Config validation failed: exit {ExitCode}, stderr: {Stderr}", run.ExitCode, stderr);
|
||||||
|
|
||||||
|
return new ConfigValidationResult(
|
||||||
|
Success: success,
|
||||||
|
Attempted: true,
|
||||||
|
ExitCode: run.ExitCode ?? -1,
|
||||||
|
StdOut: run.Stdout,
|
||||||
|
StdErr: stderr,
|
||||||
|
UserMessage: success
|
||||||
|
? "Configuration validated."
|
||||||
|
: "Configuration validation failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-6
@@ -1,5 +1,6 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -10,17 +11,20 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
{
|
{
|
||||||
private readonly IDnsmasqConfigSetService _configSetService;
|
private readonly IDnsmasqConfigSetService _configSetService;
|
||||||
private readonly IDnsmasqConfigService _configService;
|
private readonly IDnsmasqConfigService _configService;
|
||||||
|
private readonly IConfigValidationService _validationService;
|
||||||
private readonly IReloadService _reloadService;
|
private readonly IReloadService _reloadService;
|
||||||
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
||||||
|
|
||||||
public EffectiveConfigSaveService(
|
public EffectiveConfigSaveService(
|
||||||
IDnsmasqConfigSetService configSetService,
|
IDnsmasqConfigSetService configSetService,
|
||||||
IDnsmasqConfigService configService,
|
IDnsmasqConfigService configService,
|
||||||
|
IConfigValidationService validationService,
|
||||||
IReloadService reloadService,
|
IReloadService reloadService,
|
||||||
ILogger<EffectiveConfigSaveService> logger)
|
ILogger<EffectiveConfigSaveService> logger)
|
||||||
{
|
{
|
||||||
_configSetService = configSetService;
|
_configSetService = configSetService;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_validationService = validationService;
|
||||||
_reloadService = reloadService;
|
_reloadService = reloadService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -38,8 +42,8 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
{
|
{
|
||||||
_logger.LogWarning("Save skipped: managed config path is not configured");
|
_logger.LogWarning("Save skipped: managed config path is not configured");
|
||||||
return new EffectiveConfigSaveResult(
|
return new EffectiveConfigSaveResult(
|
||||||
false, null, false, false, -1, null, null,
|
false, null, false, false, -1, null, null, false, -1, null, null,
|
||||||
"missing_managed_path", "Managed config path is not configured.");
|
EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedPath = set.ManagedFilePath!;
|
var managedPath = set.ManagedFilePath!;
|
||||||
@@ -61,13 +65,38 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
false,
|
false,
|
||||||
-1,
|
-1,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
-1,
|
||||||
|
null,
|
||||||
ex.Message,
|
ex.Message,
|
||||||
"write_failed",
|
EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
|
||||||
"Failed to write config.");
|
"Failed to write config.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var restartResult = await _reloadService.ReloadAsync(ct);
|
|
||||||
var backupCreated = File.Exists(backupPath);
|
var backupCreated = File.Exists(backupPath);
|
||||||
|
var validateResult = await _validationService.ValidateAsync(ct);
|
||||||
|
|
||||||
|
if (!validateResult.Success)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Config saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr);
|
||||||
|
return new EffectiveConfigSaveResult(
|
||||||
|
BackupCreated: backupCreated,
|
||||||
|
BackupPath: backupCreated ? backupPath : null,
|
||||||
|
Saved: true,
|
||||||
|
Validated: false,
|
||||||
|
ValidationExitCode: validateResult.ExitCode,
|
||||||
|
ValidationStdOut: validateResult.StdOut,
|
||||||
|
ValidationStdErr: validateResult.StdErr,
|
||||||
|
Restarted: false,
|
||||||
|
RestartExitCode: -1,
|
||||||
|
RestartStdOut: null,
|
||||||
|
RestartStdErr: null,
|
||||||
|
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.ValidateFailed,
|
||||||
|
UserMessage: "Saved, but validation failed. Restart was not attempted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var restartResult = await _reloadService.ReloadAsync(ct);
|
||||||
|
|
||||||
if (!restartResult.Success)
|
if (!restartResult.Success)
|
||||||
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
|
||||||
@@ -76,12 +105,18 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
BackupCreated: backupCreated,
|
BackupCreated: backupCreated,
|
||||||
BackupPath: backupCreated ? backupPath : null,
|
BackupPath: backupCreated ? backupPath : null,
|
||||||
Saved: true,
|
Saved: true,
|
||||||
|
Validated: true,
|
||||||
|
ValidationExitCode: 0,
|
||||||
|
ValidationStdOut: validateResult.StdOut,
|
||||||
|
ValidationStdErr: validateResult.StdErr,
|
||||||
Restarted: restartResult.Success,
|
Restarted: restartResult.Success,
|
||||||
RestartExitCode: restartResult.ExitCode,
|
RestartExitCode: restartResult.ExitCode,
|
||||||
RestartStdOut: restartResult.StdOut,
|
RestartStdOut: restartResult.StdOut,
|
||||||
RestartStdErr: restartResult.StdErr,
|
RestartStdErr: restartResult.StdErr,
|
||||||
ErrorCode: restartResult.Success ? null : "restart_failed",
|
ErrorCode: restartResult.Success ? null : EffectiveConfigSaveResult.ErrorCodes.RestartFailed,
|
||||||
UserMessage: restartResult.Success ? "Saved and dnsmasq restarted." : "Saved, but the restart command failed.");
|
UserMessage: restartResult.Success
|
||||||
|
? "Saved, validated, and dnsmasq restarted."
|
||||||
|
: "Saved and validated, but the restart command failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public interface IUpdateCheckService : IApplicationSingleton
|
|||||||
/// <summary>When the last check completed (success or failure), or null if never run.</summary>
|
/// <summary>When the last check completed (success or failure), or null if never run.</summary>
|
||||||
DateTime? LastCheckTime { get; }
|
DateTime? LastCheckTime { get; }
|
||||||
|
|
||||||
|
/// <summary>True if the last check threw an error (e.g. network failure).</summary>
|
||||||
|
bool LastCheckFailed { get; }
|
||||||
|
|
||||||
|
/// <summary>Error message from the last failed check, or null if the last check succeeded.</summary>
|
||||||
|
string? LastCheckErrorMessage { get; }
|
||||||
|
|
||||||
/// <summary>True while a check is in progress.</summary>
|
/// <summary>True while a check is in progress.</summary>
|
||||||
bool CheckInProgress { get; }
|
bool CheckInProgress { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
|
|||||||
private string? _newerVersionUrl;
|
private string? _newerVersionUrl;
|
||||||
private DateTime? _lastCheckTime;
|
private DateTime? _lastCheckTime;
|
||||||
private bool _checkInProgress;
|
private bool _checkInProgress;
|
||||||
|
private bool _lastCheckFailed;
|
||||||
|
private string? _lastCheckErrorMessage;
|
||||||
|
|
||||||
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
|
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
|
||||||
{
|
{
|
||||||
@@ -34,6 +36,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
|
|||||||
public string? NewerVersionTag => _newerVersionTag;
|
public string? NewerVersionTag => _newerVersionTag;
|
||||||
public string? NewerVersionUrl => _newerVersionUrl;
|
public string? NewerVersionUrl => _newerVersionUrl;
|
||||||
public DateTime? LastCheckTime => _lastCheckTime;
|
public DateTime? LastCheckTime => _lastCheckTime;
|
||||||
|
public bool LastCheckFailed => _lastCheckFailed;
|
||||||
|
public string? LastCheckErrorMessage => _lastCheckErrorMessage;
|
||||||
public bool CheckInProgress => _checkInProgress;
|
public bool CheckInProgress => _checkInProgress;
|
||||||
|
|
||||||
public async Task CheckNowAsync()
|
public async Task CheckNowAsync()
|
||||||
@@ -46,6 +50,12 @@ public sealed class UpdateCheckService : IUpdateCheckService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_lastCheckFailed = false;
|
||||||
|
_lastCheckErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
|
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
|
||||||
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
|
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
|
||||||
string? newTag = null;
|
string? newTag = null;
|
||||||
@@ -79,12 +89,14 @@ public sealed class UpdateCheckService : IUpdateCheckService
|
|||||||
|
|
||||||
ResultChanged?.Invoke(this, EventArgs.Empty);
|
ResultChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_lastCheckTime = DateTime.UtcNow;
|
_lastCheckTime = DateTime.UtcNow;
|
||||||
_checkInProgress = false;
|
_checkInProgress = false;
|
||||||
|
_lastCheckFailed = true;
|
||||||
|
_lastCheckErrorMessage = ex.Message;
|
||||||
}
|
}
|
||||||
ResultChanged?.Invoke(this, EventArgs.Empty);
|
ResultChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public class DnsmasqOptions
|
|||||||
/// <summary>Command to run after config changes when <see cref="RestartCommand"/> is not set (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). SIGHUP only re-reads hosts/addn-hosts etc., not .conf files. Runs in the same environment as the app.</summary>
|
/// <summary>Command to run after config changes when <see cref="RestartCommand"/> is not set (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). SIGHUP only re-reads hosts/addn-hosts etc., not .conf files. Runs in the same environment as the app.</summary>
|
||||||
public string? ReloadCommand { get; set; }
|
public string? ReloadCommand { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional command to validate config before restart (e.g. "dnsmasq --test --conf-file=\"{{MainConfigPath}}\""). Token {{MainConfigPath}} is replaced with <see cref="MainConfigPath"/>. When set, run after write and before restart; if it fails, restart is not attempted. When unset, validation is skipped.</summary>
|
||||||
|
public string? ValidateCommand { get; set; } = "dnsmasq --test --conf-file=\"{{MainConfigPath}}\"";
|
||||||
|
|
||||||
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
|
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
|
||||||
public string? StatusCommand { get; set; }
|
public string? StatusCommand { get; set; }
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ public class DnsmasqOptions
|
|||||||
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
|
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
|
||||||
public int LogsTimeoutSeconds { get; set; } = 10;
|
public int LogsTimeoutSeconds { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>Timeout in seconds for <see cref="ValidateCommand"/>. Default 10.</summary>
|
||||||
|
public int ValidateTimeoutSeconds { get; set; } = 10;
|
||||||
|
|
||||||
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||||
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
|
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
|
||||||
|
|
||||||
@@ -69,4 +75,7 @@ public class DnsmasqOptions
|
|||||||
|
|
||||||
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||||
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds);
|
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds);
|
||||||
|
|
||||||
|
/// <summary><see cref="ValidateTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||||
|
public TimeSpan ValidateTimeout => TimeSpan.FromSeconds(ValidateTimeoutSeconds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
|
|||||||
failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}.");
|
failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}.");
|
||||||
if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds)
|
if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds)
|
||||||
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}.");
|
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}.");
|
||||||
|
if (options.ValidateTimeoutSeconds < minTimeoutSeconds || options.ValidateTimeoutSeconds > maxTimeoutSeconds)
|
||||||
|
failures.Add($"Dnsmasq:ValidateTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.ValidateTimeoutSeconds}.");
|
||||||
|
|
||||||
if (failures.Count == 0)
|
if (failures.Count == 0)
|
||||||
return ValidateOptionsResult.Success;
|
return ValidateOptionsResult.Success;
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
/// <summary>Result of the effective-config save flow (backup, write, restart). Used by UI to drive state and show restore/continue options.</summary>
|
/// <summary>Result of the effective-config save flow (backup, write, validate, restart). Used by UI to drive state and show restore/continue options.</summary>
|
||||||
/// <param name="BackupCreated">True when a backup file was created before write.</param>
|
/// <param name="BackupCreated">True when a backup file was created before write.</param>
|
||||||
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
|
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
|
||||||
/// <param name="Saved">True when the managed config was written successfully.</param>
|
/// <param name="Saved">True when the managed config was written successfully.</param>
|
||||||
/// <param name="Restarted">True when the restart command succeeded after write (config changes require restart, not SIGHUP).</param>
|
/// <param name="Validated">True when validation was run and succeeded (or was skipped). When validation ran and failed, restart is not attempted.</param>
|
||||||
|
/// <param name="ValidationExitCode">Validation command exit code; -1 when not run or failed to start.</param>
|
||||||
|
/// <param name="ValidationStdOut">Standard output from the validation command.</param>
|
||||||
|
/// <param name="ValidationStdErr">Standard error from the validation command.</param>
|
||||||
|
/// <param name="Restarted">True when the restart command succeeded after write (and after validation if run).</param>
|
||||||
/// <param name="RestartExitCode">Restart command process exit code; -1 when not run or failed to start.</param>
|
/// <param name="RestartExitCode">Restart command process exit code; -1 when not run or failed to start.</param>
|
||||||
/// <param name="RestartStdOut">Standard output from the restart command.</param>
|
/// <param name="RestartStdOut">Standard output from the restart command.</param>
|
||||||
/// <param name="RestartStdErr">Standard error from the restart command.</param>
|
/// <param name="RestartStdErr">Standard error from the restart command.</param>
|
||||||
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, restart_failed, or null when success.</param>
|
/// <param name="ErrorCode">Machine-readable code: see <see cref="ErrorCodes"/>; null when success.</param>
|
||||||
/// <param name="UserMessage">Short message for the user.</param>
|
/// <param name="UserMessage">Short message for the user.</param>
|
||||||
public record EffectiveConfigSaveResult(
|
public record EffectiveConfigSaveResult(
|
||||||
bool BackupCreated,
|
bool BackupCreated,
|
||||||
string? BackupPath,
|
string? BackupPath,
|
||||||
bool Saved,
|
bool Saved,
|
||||||
|
bool Validated,
|
||||||
|
int ValidationExitCode,
|
||||||
|
string? ValidationStdOut,
|
||||||
|
string? ValidationStdErr,
|
||||||
bool Restarted,
|
bool Restarted,
|
||||||
int RestartExitCode,
|
int RestartExitCode,
|
||||||
string? RestartStdOut,
|
string? RestartStdOut,
|
||||||
@@ -21,7 +29,20 @@ public record EffectiveConfigSaveResult(
|
|||||||
string? ErrorCode,
|
string? ErrorCode,
|
||||||
string? UserMessage)
|
string? UserMessage)
|
||||||
{
|
{
|
||||||
|
/// <summary>Machine-readable error codes for <see cref="ErrorCode"/>.</summary>
|
||||||
|
public static class ErrorCodes
|
||||||
|
{
|
||||||
|
public const string NoChanges = "no_changes";
|
||||||
|
public const string MissingManagedPath = "missing_managed_path";
|
||||||
|
public const string WriteFailed = "write_failed";
|
||||||
|
public const string ValidateFailed = "validate_failed";
|
||||||
|
public const string RestartFailed = "restart_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when config was written but validation failed (restart not attempted).</summary>
|
||||||
|
public bool IsValidateFailed => ErrorCode == ErrorCodes.ValidateFailed;
|
||||||
|
|
||||||
/// <summary>Result when there are no pending changes to apply.</summary>
|
/// <summary>Result when there are no pending changes to apply.</summary>
|
||||||
public static EffectiveConfigSaveResult NoChanges() =>
|
public static EffectiveConfigSaveResult NoChanges() =>
|
||||||
new(false, null, false, false, -1, null, null, "no_changes", "No pending changes.");
|
new(false, null, false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
"SystemHostsPath": "/etc/hosts",
|
"SystemHostsPath": "/etc/hosts",
|
||||||
"RestartCommand": "systemctl restart dnsmasq",
|
"RestartCommand": "systemctl restart dnsmasq",
|
||||||
"ReloadCommand": "systemctl reload dnsmasq",
|
"ReloadCommand": "systemctl reload dnsmasq",
|
||||||
|
"ValidateCommand": "dnsmasq --test --conf-file=\"{{MainConfigPath}}\"",
|
||||||
|
"ValidateTimeoutSeconds": 10,
|
||||||
"StatusCommand": "systemctl is-active dnsmasq",
|
"StatusCommand": "systemctl is-active dnsmasq",
|
||||||
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
|
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
|
||||||
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"
|
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"
|
||||||
|
|||||||
Reference in New Issue
Block a user