Effective config: readonly edit workflow and save staging

Add readonly badge/popover guidance, pending change tracking, and save confirmation flow for effective config editing. Update field renderers and config services to commit staged changes, and remove bundled Bootstrap dist assets from wwwroot.
This commit is contained in:
2026-02-27 20:49:31 +10:00
parent 906e222ec1
commit f22b039e74
77 changed files with 1165 additions and 59660 deletions
+5 -6
View File
@@ -19,15 +19,14 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS app
FROM base AS final
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=publish /app/publish .
FROM app AS final
# Slow, rarely-changing steps first (stay cached when only .NET code changes)
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/entrypoint.sh .
COPY scripts/dnsmasq-status.sh .
COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh .
RUN chmod +x entrypoint.sh dnsmasq-status.sh
# .NET publish last - only this layer invalidates when code changes
COPY --from=publish /app/publish .
ENTRYPOINT ["./entrypoint.sh"]
+1
View File
@@ -23,6 +23,7 @@
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/copy-to-clipboard.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -1,11 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* Reusable value-only display for boolean/flag options. Edit mode: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var isSet = Descriptor.GetValue() is bool b && b;
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" />
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
}
else
{
@@ -14,11 +14,12 @@ else
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnCheckedChange(ChangeEventArgs e)
{
await CheckedChanged.InvokeAsync(e.Value is true);
await ValueChanged.InvokeAsync(e.Value is true);
}
}
@@ -1,12 +1,12 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* Value-only display for int? options. Edit: number input (unless readonly); View: value + optional suffix/unit. Metadata from EffectiveIntegerConfigFieldDescriptor. *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
@* Value-only display for int? options. When active editor: number input (unless readonly); View: value + optional suffix/unit. Metadata from EffectiveIntegerConfigFieldDescriptor. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var current = Descriptor.GetValue() is int i ? i : DefaultValue;
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" />
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
}
else
{
@@ -25,9 +25,9 @@ else
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<int?> ValueChanged { get; set; }
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
@@ -1,10 +1,10 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* local-service: net | host. View: (not set) / net / host. Edit: dropdown. *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
@* local-service: net | host. View: (not set) / net / host. When active editor: dropdown. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(Descriptor.GetValue() as string ?? "")" @onchange="OnChange">
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(Descriptor.GetValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
<option value="">(not set)</option>
<option value="net">net</option>
<option value="host">host</option>
@@ -18,9 +18,9 @@ else
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnChange(ChangeEventArgs e)
{
@@ -1,10 +1,10 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). Edit: dropdown. *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
<select class="form-select form-select-sm" style="max-width: 12rem;" value="@GetEditValue()" @onchange="OnChange">
<select class="form-select form-select-sm" style="max-width: 12rem;" value="@GetEditValue()" @onchange="OnChange" @onfocusout="OnCommitRequested">
<option value="">Off</option>
<option value="on">On</option>
<option value="extra">On (extra)</option>
@@ -19,9 +19,9 @@ else
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private string GetEditValue()
{
@@ -1,11 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* Value-only display for the port option. Edit mode: number input (unless readonly); View: text with "(default DNS port)". *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
@* Value-only display for the port option. IsActiveEditor is cascaded from FieldDisplay (true only for the single active field in edit mode). When active editor and not readonly: number input; else: view text. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var port = Descriptor.GetValue() is int p ? p : 53;
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" />
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" @onfocusout="OnCommitRequested" />
}
else
{
@@ -14,14 +14,15 @@ else
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<int?> PortChanged { get; set; }
[CascadingParameter] public bool IsActiveEditor { get; set; }
[CascadingParameter] public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter] public EventCallback<object?> ValueChanged { get; set; }
private async Task OnPortInput(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
await PortChanged.InvokeAsync(port);
await ValueChanged.InvokeAsync(port);
else
await PortChanged.InvokeAsync(null);
await ValueChanged.InvokeAsync(null);
}
}
@@ -10,13 +10,44 @@
@if (_customFragment is { } fragment)
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showEditableBadge = !isReadonly;
var isActiveEditor = IsEditMode && IsActive;
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">
@fragment(Descriptor)
@if (Descriptor.GetSource()?.IsReadOnly == true)
@* IsActiveEditor: cascaded from here (true only for this field when it's the active editor). Section cascades IsEditMode + ActiveFieldKey; Panel passes IsActive=(ActiveFieldKey==fieldKey); we derive isActiveEditor = IsEditMode && IsActive and cascade that bool so fragment children (e.g. PortValueDisplay) know whether to show the input. *@
@if (isActiveEditor)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
<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))
</CascadingValue>
</CascadingValue>
</div>
}
else
{
<CascadingValue Value="@false">
@fragment(Descriptor)
</CascadingValue>
}
@if (isReadonly)
{
@if (IsEditMode)
{
<button type="button" id="@_readonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseReadonlyBadgeClicked" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">readonly</span>
}
}
@if (showEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
</li>
@@ -24,30 +55,105 @@
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showReadonlyHint = IsEditMode && isReadonly;
var isActiveEditor = IsEditMode && IsActive;
@if (showReadonlyHint)
{
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" SuppressOuterLi="true"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" />
</li>
}
else
{
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested"
ShowEditableBadge="true" OnActivateEdit="@OnActivateEdit" IsActive="@IsActive"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" OnReadonlyBadgeClick="@RaiseReadonlyBadgeClicked" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" />
}
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool IsActive { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
[Parameter] public EventCallback OnActivateEdit { get; set; }
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnEditCommitted { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
[CascadingParameter] public string? ManagedFilePath { get; set; }
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
private string? _labelTooltip;
private string? _optionHelpKey;
private object? _valueAtActivation;
private object? _draftValue;
private bool _wasActive;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
if (!_wasActive && IsActive)
_valueAtActivation = Descriptor.GetValue();
if (_wasActive && !IsActive)
_ = InvokeAsync(CommitIfChangedAsync);
_wasActive = IsActive;
}
private void HandleValueChanged(object? value)
{
_draftValue = value;
}
private async Task OnBlur(FocusEventArgs _)
{
if (!IsActive) return;
await CommitIfChangedAsync();
}
private async Task CommitIfChangedAsync()
{
var newValue = _draftValue ?? Descriptor.GetValue();
if (!Equals(_valueAtActivation, newValue))
{
var source = Descriptor.GetSource();
await OnEditCommitted.InvokeAsync(new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath));
}
_draftValue = null;
}
private async Task RaiseReadonlyBadgeClicked()
{
var source = Descriptor.GetSource();
if (source == null) return;
await OnReadonlyBadgeClicked.InvokeAsync(new ReadonlyBadgeClickedEventArgs(_readonlyBadgeAnchorId, source, Descriptor.OptionName, Descriptor.GetValue()));
}
private void HandleDefaultValueChanged(string value)
{
_draftValue = value;
}
private async Task HandleDefaultBlur(string? value)
{
if (!IsActive) return;
_draftValue = value;
await CommitIfChangedAsync();
}
private async Task RaiseOptionHelpRequested()
@@ -1,15 +1,31 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
@Value
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
@if (IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1">readonly</span>
}
}
else if (IsEditMode)
{
<span class="badge bg-primary ec-badge-inline ms-1">editable</span>
}
</span>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
}
@@ -4,9 +4,9 @@
<li class="ec-field">
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
@if (IsActiveEditor)
{
<span class="ec-field-value ec-field-value-multi">
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
</span>
}
@@ -16,9 +16,11 @@
@if (Items?.Count > 0)
{
<ol class="ec-field-value-list">
@foreach (var item in Items!)
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" /></li>
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : null;
var args = new ReadonlyBadgeClickedEventArgs(anchorId ?? "", item.Source!, Label, item.Value);
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" OnReadonlyBadgeClick="@(() => _ = OnReadonlyBadgeClicked.InvokeAsync(args))" OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave" /></li>
}
</ol>
}
@@ -37,7 +39,11 @@
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private string _editLines = "";
@@ -2,12 +2,17 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
@if (SuppressOuterLi)
{
<span class="ec-field-value">
<input type="text" class="form-control form-control-sm" value="@DisplayValue" @oninput="OnValueInput" />
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
@if (ShowEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
else
@@ -16,22 +21,83 @@
@DisplayValue
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
}
}
@if (ShowEditableBadge && Source?.IsReadOnly != true)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
}
else
{
<li class="ec-field @(IsActive ? "ec-field-editing" : "")" title="@(Source?.GetReadOnlyTooltip())">
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
@if (ShowEditableBadge)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
else
{
<span class="ec-field-value">
@DisplayValue
@if (Source?.IsReadOnly == true)
{
@if (!string.IsNullOrEmpty(ReadonlyBadgeAnchorId) && OnReadonlyBadgeClick.HasDelegate)
{
<button type="button" id="@ReadonlyBadgeAnchorId" class="badge bg-secondary ec-badge-inline border-0 ms-1" title="@(Source?.GetReadOnlyTooltip())" @onclick="OnReadonlyBadgeClick" @onmouseleave="OnReadonlyBadgeMouseLeave">readonly</button>
}
else
{
<span class="badge bg-secondary ec-badge-inline ms-1" title="@(Source?.GetReadOnlyTooltip())">readonly</span>
}
}
@if (ShowEditableBadge && Source?.IsReadOnly != true)
{
<button type="button" class="badge bg-primary ec-badge-inline border-0 ms-1" @onclick="OnActivateEdit" @onclick:stopPropagation="true" title="Edit this field" aria-label="Edit">editable</button>
}
</span>
}
</li>
}
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>When true, render only the inner content (no outer li); used when parent wraps with ReadonlyEditHint.</summary>
[Parameter] public bool SuppressOuterLi { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
/// <summary>When set with OnReadonlyBadgeClick, the readonly badge is rendered as a button with this id so the parent can open a popover under it.</summary>
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeClick { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
[Parameter] public bool ShowEditableBadge { get; set; }
[Parameter] public EventCallback OnActivateEdit { get; set; }
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
[Parameter] public bool IsActive { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
[Parameter] public EventCallback<string?> OnBlur { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
@@ -47,9 +113,24 @@
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
private string? _editValue;
protected override void OnParametersSet()
{
if (DisplayMode != EffectiveConfigDisplayMode.Edit)
_editValue = null;
}
private async Task OnValueInput(ChangeEventArgs e)
{
var value = e.Value?.ToString() ?? "";
_editValue = value;
await DisplayValueChanged.InvokeAsync(value);
}
private async Task OnInputBlur(FocusEventArgs _)
{
if (OnBlur.HasDelegate)
await OnBlur.InvokeAsync(_editValue ?? DisplayValue);
}
}
@@ -0,0 +1,65 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<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">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted">The following changes will be written to your managed config file. Values set in other files will be overridden by these entries.</p>
@if (ManagedFilePath != null)
{
<p class="small mb-2"><strong>Target file:</strong> <code>@ManagedFilePath</code></p>
}
<ul class="list-unstyled mb-0">
@foreach (var c in PendingChanges)
{
<li class="small py-1 border-bottom border-light">
<strong>@c.OptionName:</strong>
@FormatValue(c.OldValue) → @FormatValue(c.NewValue)
@if (c.CurrentSourceFilePath != null)
{
<br /><span class="text-muted">(overrides value from @System.IO.Path.GetFileName(c.CurrentSourceFilePath))</span>
}
</li>
}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
</div>
</div>
</div>
<div class="modal-backdrop fade show" style="display: block;" @onclick="Close"></div>
</div>
@code {
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
private string? ManagedFilePath => Status?.ManagedFilePath;
private static string FormatValue(object? v)
{
if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off";
return v.ToString() ?? "(not set)";
}
private async Task Close()
{
await OnClose.InvokeAsync();
}
private async Task Save()
{
await OnSave.InvokeAsync();
}
}
@@ -4,26 +4,54 @@
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject DnsmasqWebUI.Infrastructure.Services.Abstractions.IDnsmasqConfigService ConfigService
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Status != null)
{
<div class="ec-container">
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
<ReadonlyEditPopover Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)"
OnClose="CloseReadonlyPopover" OnMouseEnterPopover="CancelReadonlyCloseTimerAsync" OnMouseLeavePopover="ScheduleReadonlyCloseAsync" />
<div class="ec-toolbar @(_isEditMode ? "ec-edit-toolbar" : "")">
@if (ShowSearchBox)
{
<div class="ec-toolbar">
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
}
<div class="ec-toolbar-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExpandAll" title="Expand all sections" aria-label="Expand all sections">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
</div>
@if (_isEditMode)
{
<button type="button" class="btn btn-sm btn-primary" @onclick="OpenSaveModal" disabled="@(_pendingChanges.Count == 0)" title="Save changes to managed config" aria-label="Save">
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
</button>
@if (_pendingChanges.Count > 0)
{
<span class="text-muted small">@_pendingChanges.Count change(s)</span>
}
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExitEditModeWithConfirmAsync" title="Exit edit mode" aria-label="Exit edit mode">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
}
else
{
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="EnterEditMode" title="Edit effective config" aria-label="Edit">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
}
</div>
</div>
<CascadingValue Name="IsEditMode" Value="@_isEditMode">
<CascadingValue Name="ActiveFieldKey" Value="@_activeFieldKey">
<CascadingValue Name="ManagedFilePath" Value="@(Status?.ManagedFilePath)">
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
@@ -41,9 +69,22 @@
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" OnOptionHelpRequested="@HandleOptionHelpRequested" />
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
DisplayMode="@( _isEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
IsEditMode="@_isEditMode" ActiveFieldKey="@_activeFieldKey"
OnActivateField="ActivateField" OnFieldEditCommitted="HandleFieldEditCommitted"
OnReadonlyBadgeClicked="HandleReadonlyBadgeClicked"
OnReadonlyBadgeMouseLeave="ScheduleReadonlyCloseAsync"
OnOptionHelpRequested="@HandleOptionHelpRequested" />
}
</div>
</CascadingValue>
</CascadingValue>
</CascadingValue>
@if (_showSaveModal)
{
<EffectiveConfigSaveModal PendingChanges="_pendingChanges" Status="@Status" OnClose="CloseSaveModal" OnSave="ConfirmSave" />
}
</div>
}
@@ -51,7 +92,8 @@
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public bool ShowSearchBox { get; set; } = true;
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
/// <summary>Raised after pending changes were successfully written to managed config (so the page can refresh status).</summary>
[Parameter] public EventCallback OnSaveCompleted { get; set; }
private string _searchTerm = "";
private HashSet<string> _openPanels = new();
@@ -61,6 +103,18 @@
private bool _showOptionHelp;
private CancellationTokenSource? _closeCts;
private bool _isEditMode;
private string? _activeFieldKey;
private readonly List<PendingEffectiveConfigChange> _pendingChanges = new();
private bool _showSaveModal;
private bool _showReadonlyPopover;
private string? _readonlyPopoverAnchorId;
private ConfigValueSource? _readonlyPopoverSource;
private string? _readonlyPopoverOptionName;
private object? _readonlyPopoverValue;
private CancellationTokenSource? _readonlyCloseCts;
private bool IsOpen(string id) => _openPanels.Contains(id);
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
@@ -118,6 +172,53 @@
StateHasChanged();
}
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
{
_readonlyPopoverAnchorId = args.AnchorId;
_readonlyPopoverSource = args.Source;
_readonlyPopoverOptionName = args.OptionName;
_readonlyPopoverValue = args.Value;
_showReadonlyPopover = true;
CancelReadonlyCloseTimer();
await InvokeAsync(StateHasChanged);
}
private void CancelReadonlyCloseTimer()
{
_readonlyCloseCts?.Cancel();
_readonlyCloseCts = null;
}
private Task CancelReadonlyCloseTimerAsync() { CancelReadonlyCloseTimer(); return Task.CompletedTask; }
private void ScheduleReadonlyClose()
{
_readonlyCloseCts?.Cancel();
_readonlyCloseCts = new CancellationTokenSource();
var token = _readonlyCloseCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(() =>
{
if (token.IsCancellationRequested) return;
_showReadonlyPopover = false;
_readonlyCloseCts = null;
StateHasChanged();
});
}, token);
}
private Task ScheduleReadonlyCloseAsync() { ScheduleReadonlyClose(); return Task.CompletedTask; }
private void CloseReadonlyPopover()
{
CancelReadonlyCloseTimer();
_showReadonlyPopover = false;
StateHasChanged();
}
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
@@ -146,6 +247,96 @@
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
private void EnterEditMode()
{
_isEditMode = true;
_activeFieldKey = null;
_pendingChanges.Clear();
StateHasChanged();
}
private async Task ExitEditModeWithConfirmAsync()
{
if (_pendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
try
{
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (!confirmed) return;
}
finally
{
await module.DisposeAsync();
}
}
_pendingChanges.Clear();
_activeFieldKey = null;
_isEditMode = false;
StateHasChanged();
}
private void DeactivateActiveFieldIfClicked()
{
if (_isEditMode && _activeFieldKey != null)
{
_activeFieldKey = null;
StateHasChanged();
}
}
private void ActivateField(string fieldKey)
{
_isEditMode = true;
_activeFieldKey = fieldKey;
StateHasChanged();
}
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
var existing = _pendingChanges.FirstOrDefault(c => c.SectionId == args.SectionId && c.OptionName == args.OptionName);
if (existing != null)
_pendingChanges.Remove(existing);
if (!Equals(args.OldValue, args.NewValue))
_pendingChanges.Add(new PendingEffectiveConfigChange(args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
_activeFieldKey = null;
StateHasChanged();
}
private void OpenSaveModal()
{
if (_pendingChanges.Count == 0) return;
_showSaveModal = true;
StateHasChanged();
}
private void CloseSaveModal()
{
_showSaveModal = false;
StateHasChanged();
}
private async Task ConfirmSave()
{
if (_pendingChanges.Count == 0) return;
var changes = _pendingChanges.ToList();
try
{
await ConfigService.ApplyEffectiveConfigChangesAsync(changes);
CloseSaveModal();
_pendingChanges.Clear();
_activeFieldKey = null;
_isEditMode = false;
StateHasChanged();
await OnSaveCompleted.InvokeAsync();
}
catch (Exception)
{
// TODO: show error to user (e.g. toast or modal message)
await InvokeAsync(StateHasChanged);
}
}
public ValueTask DisposeAsync()
{
_closeCts?.Cancel();
@@ -15,14 +15,27 @@
.ec-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
min-width: 0;
}
.ec-toolbar input[type="search"] {
flex: 1;
/* Search uses available space; actions stay on the right */
.ec-toolbar-search {
flex: 1 1 auto;
min-width: 0;
max-width: 24rem;
}
.ec-toolbar-actions {
display: flex;
flex-wrap: nowrap;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.ec-sections {
@@ -156,10 +169,28 @@
}
::deep .ec-field-value {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
gap: 0.25rem;
}
/* Edit wrapper (checkbox/input) shrinks to content so the editable badge stays on the same line */
::deep .ec-field-edit-wrapper {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
/* Input/select in value area can shrink so the editable badge stays on the same line */
::deep .ec-field-value > .form-control,
::deep .ec-field-value > .form-select {
flex: 1 1 auto;
min-width: 0;
}
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
@@ -13,7 +13,16 @@
<ul class="list-unstyled mb-0">
@foreach (var f in Descriptors)
{
<EffectiveConfigFieldDisplay Descriptor="@f" OnOptionHelpRequested="@OnOptionHelpRequested" />
var fieldKey = $"{SectionId}:{f.OptionName}";
<EffectiveConfigFieldDisplay Descriptor="@f"
IsEditMode="@IsEditMode"
IsActive="@(ActiveFieldKey == fieldKey)"
ActiveFieldKey="@ActiveFieldKey"
OnActivateEdit="@(() => OnActivateField.InvokeAsync(fieldKey))"
OnEditCommitted="@OnFieldEditCommitted"
OnReadonlyBadgeClicked="@OnReadonlyBadgeClicked"
OnReadonlyBadgeMouseLeave="@OnReadonlyBadgeMouseLeave"
OnOptionHelpRequested="@OnOptionHelpRequested" />
}
</ul>
</div>
@@ -29,8 +38,14 @@
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
[Parameter] public EventCallback<string> OnActivateField { get; set; }
[Parameter] public EventCallback<EffectiveConfigEditCommittedArgs> OnFieldEditCommitted { get; set; }
[Parameter] public EventCallback<ReadonlyBadgeClickedEventArgs> OnReadonlyBadgeClicked { get; set; }
[Parameter] public EventCallback OnReadonlyBadgeMouseLeave { get; set; }
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
}
@@ -0,0 +1,76 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@inject IJSRuntime JSRuntime
@if (Source is { IsReadOnly: true })
{
<div class="ec-readonly-hint small text-muted mt-1">
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="d-flex align-items-center gap-1 mt-1">
<code class="flex-grow-1 small text-break">@_removeCommand</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
<div class="d-flex align-items-center gap-1 mt-1">
<code class="flex-grow-1 small">@_overrideLine</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
</div>
}
</div>
}
@code {
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
private string _removeCommand = "";
private string _overrideLine = "";
protected override void OnParametersSet()
{
if (Source is { IsReadOnly: true })
{
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
}
else
{
_removeCommand = "";
_overrideLine = "";
}
}
private async Task CopyRemoveCommand()
{
if (string.IsNullOrEmpty(_removeCommand)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand);
}
catch
{
// Copy not supported or failed
}
}
private async Task CopyOverrideLine()
{
if (string.IsNullOrEmpty(_overrideLine)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine);
}
catch
{
// Copy not supported or failed
}
}
}
@@ -0,0 +1,184 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Extensions
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible && Source != null)
{
<div class="ec-readonly-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnPopoverMouseEnter" @onmouseleave="OnPopoverMouseLeave">
<div class="ec-readonly-popover-content">
<div class="ec-readonly-popover-header">
<span class="ec-readonly-popover-title">Read-only: @OptionName</span>
</div>
<div class="ec-readonly-popover-body small">
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="d-flex align-items-center gap-1 mb-2">
<code class="flex-grow-1 text-break" style="font-size: 0.8rem;">@_removeCommand</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyRemoveCommand" title="Copy command">Copy</button>
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
<div class="d-flex align-items-center gap-1">
<code class="flex-grow-1" style="font-size: 0.8rem;">@_overrideLine</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyOverrideLine" title="Copy line">Copy</button>
</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
private string _positionStyle = "";
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverPopover;
private IJSObjectReference? _jsModule;
private bool _moduleLoaded;
private string _removeCommand = "";
private string _overrideLine = "";
protected override void OnParametersSet()
{
if (Source is { IsReadOnly: true })
{
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
}
else
{
_removeCommand = "";
_overrideLine = "";
}
if (Visible && string.IsNullOrEmpty(_positionStyle))
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
if (!Visible)
_positionStyle = "";
if (Visible && _moduleLoaded && _jsModule != null)
{
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverPopover)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
}
else
{
_positionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
}
}
private async Task OnPopoverMouseEnter()
{
_mouseOverPopover = true;
await OnMouseEnterPopover.InvokeAsync();
}
private async Task OnPopoverMouseLeave()
{
_mouseOverPopover = false;
await OnMouseLeavePopover.InvokeAsync();
}
private async Task CopyRemoveCommand()
{
if (string.IsNullOrEmpty(_removeCommand)) return;
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _removeCommand); } catch { }
}
private async Task CopyOverrideLine()
{
if (string.IsNullOrEmpty(_overrideLine)) return;
try { await JSRuntime.InvokeVoidAsync("copyToClipboard", _overrideLine); } catch { }
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}
@@ -0,0 +1,36 @@
/* Readonly edit popover: anchored under the badge; same look as option help popover */
.ec-readonly-popover {
position: fixed;
z-index: 1060;
max-width: min(420px, calc(100vw - 1rem));
max-height: min(70vh, 400px);
overflow: auto;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
background: var(--bs-body-bg, #fff);
}
.ec-readonly-popover-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.ec-readonly-popover-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
flex-shrink: 0;
}
.ec-readonly-popover-title {
font-weight: 600;
font-size: 0.9rem;
}
.ec-readonly-popover-body {
padding: 0.75rem;
overflow: auto;
flex: 1;
min-height: 0;
}
+11 -1
View File
@@ -325,7 +325,7 @@ else
<section class="page-section">
<h2 class="page-section-title">Config affecting DHCP</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" />
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
}
@@ -416,6 +416,16 @@ else
StateHasChanged();
}
private async Task RefreshStatusAsync()
{
try
{
_status = await StatusClient.GetStatusAsync();
await InvokeAsync(StateHasChanged);
}
catch { /* ignore */ }
}
private async Task Load()
{
_error = null;
@@ -56,7 +56,7 @@ else if (_status != null)
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
}
</ul>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" />
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
@if (_status.ReloadCommandConfigured)
{
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
@@ -150,6 +150,16 @@ else if (_status != null)
await InvokeAsync(StateHasChanged);
}
private async Task RefreshStatusAsync()
{
try
{
_status = await StatusClient.GetStatusAsync(_cts.Token);
await InvokeAsync(StateHasChanged);
}
catch (OperationCanceledException) { }
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
+11 -1
View File
@@ -19,7 +19,7 @@ else
{
<section class="page-section mb-4">
<h2 class="page-section-title">Config affecting hosts</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" />
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
@@ -90,6 +90,16 @@ else
await Load();
}
private async Task RefreshStatusAsync()
{
try
{
_status = await StatusClient.GetStatusAsync();
await InvokeAsync(StateHasChanged);
}
catch { /* ignore */ }
}
private async Task Load()
{
_error = null;
@@ -0,0 +1,78 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Generates copyable commands or hints for readonly effective-config values:
/// remove or edit the line in the source file, or override by adding to managed config.
/// </summary>
public static class ReadonlyConfigCommandGenerator
{
/// <summary>
/// Suggested command to remove the option line from the readonly file (sed). User can copy and run with appropriate privileges.
/// Escapes the option name for a basic sed pattern (^option=\?.*$ or ^option$ for flags).
/// </summary>
public static string GetRemoveLineCommand(ConfigValueSource source, string optionName)
{
if (string.IsNullOrEmpty(source.FilePath)) return "";
var escaped = EscapeForSedPattern(optionName);
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
var pattern = behavior == EffectiveConfigParserBehavior.Flag
? $"^{escaped}$"
: $"^{escaped}=.*$";
var pathEscaped = source.FilePath.Replace("|", "\\|");
return $"sed -i '|{pattern}|d' {pathEscaped}";
}
/// <summary>
/// Short hint for editing the file (path only; no command).
/// </summary>
public static string GetEditFileHint(ConfigValueSource source)
{
return string.IsNullOrEmpty(source.FilePath) ? "" : $"Edit {source.FilePath} to remove or change the line.";
}
/// <summary>
/// The line to add to managed config to override (e.g. "port=53" or "expand-hosts"). Empty if managed path not set.
/// </summary>
public static string GetOverrideLine(string optionName, object? value, string? managedFilePath)
{
if (string.IsNullOrEmpty(managedFilePath)) return "";
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
if (behavior == EffectiveConfigParserBehavior.Flag)
return value is bool b && b ? optionName : "";
var v = ValueToConfString(value);
return string.IsNullOrEmpty(v) ? optionName : $"{optionName}={v}";
}
/// <summary>
/// Display text for the override hint (includes managed path).
/// </summary>
public static string GetOverrideHint(string optionName, object? value, string? managedFilePath)
{
if (string.IsNullOrEmpty(managedFilePath)) return "";
var line = GetOverrideLine(optionName, value, managedFilePath);
return string.IsNullOrEmpty(line)
? ""
: $"Add to {managedFilePath} to override: {line}";
}
private static string ValueToConfString(object? value)
{
if (value == null) return "";
if (value is bool b) return b ? "1" : "0";
return value.ToString() ?? "";
}
private static string EscapeForSedPattern(string optionName)
{
return optionName
.Replace("\\", "\\\\")
.Replace(".", "\\.")
.Replace("*", "\\*")
.Replace("[", "\\[")
.Replace("]", "\\]")
.Replace("^", "\\^")
.Replace("$", "\\$");
}
}
@@ -660,11 +660,12 @@ public static class DnsmasqConfIncludeParser
return result;
}
/// <summary>Same as <see cref="GetAddnHostsPathsFromConfigFilesWithSource(IReadOnlyList{string}, string?)"/> but uses pre-read content.</summary>
/// <summary>Same as <see cref="GetAddnHostsPathsFromConfigFilesWithSource(IReadOnlyList{string}, string?)"/> but uses pre-read content. When resolved path equals <paramref name="managedHostsFilePath"/>, the source is readonly (managed hosts file; edit on Hosts page).</summary>
public static IReadOnlyList<(string Path, ConfigValueSource Source)> GetAddnHostsPathsFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath, string? managedHostsFilePath = null)
{
var result = new List<(string Path, ConfigValueSource Source)>();
var managedHostsFull = string.IsNullOrEmpty(managedHostsFilePath) ? null : Path.GetFullPath(managedHostsFilePath);
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
@@ -681,7 +682,11 @@ public static class DnsmasqConfIncludeParser
var path = value.Trim();
if (string.IsNullOrEmpty(path))
continue;
result.Add((Path.GetFullPath(Path.Combine(dir, path)), MakeSource(configPath, managedFilePath, i + 1)));
var resolvedPath = Path.GetFullPath(Path.Combine(dir, path));
var source = managedHostsFull != null && string.Equals(resolvedPath, managedHostsFull, StringComparison.Ordinal)
? new ConfigValueSource(resolvedPath, "managed hosts", IsManaged: false, i + 1, ReadOnlyTooltipOverride: "Managed hosts file reference (readonly). Edit entries on the Hosts page.")
: MakeSource(configPath, managedFilePath, i + 1);
result.Add((resolvedPath, source));
}
}
return result;
@@ -9,6 +9,9 @@ namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>Reads and writes the managed dnsmasq config file and DHCP hosts entries (the app-managed config and dhcp-host= lines).</summary>
public interface IDnsmasqConfigService : IApplicationScopedService
{
/// <summary>Applies pending effective-config changes to the managed config file (add/replace/remove option lines), then writes and notifies cache.</summary>
Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default);
/// <summary>Reads DHCP host entries from the managed config (dhcp-host= lines).</summary>
Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default);
@@ -17,6 +17,11 @@ public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton
/// </summary>
RenderFragment<EffectiveConfigFieldDescriptor>? BuildFieldComponentFragment(string sectionId, string optionName);
/// <summary>
/// Builds a fragment that renders the custom value component and wires the given callback so the row can receive value changes (for commit-on-blur).
/// </summary>
RenderFragment<EffectiveConfigFieldDescriptor>? BuildFieldComponentFragment(string sectionId, string optionName, EventCallback<object?> onValueChanged);
/// <summary>
/// Returns a factory that creates the correct descriptor type for this field (e.g. <see cref="EffectiveIntegerConfigFieldDescriptor"/>), or null to use the default.
/// </summary>
@@ -149,7 +149,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var paths = set.Files.Select(f => f.Path).ToList();
var pathToLines = ReadAllPaths(paths, ct);
var config = BuildEffectiveConfig(paths, pathToLines);
var sources = BuildEffectiveConfigSources(paths, pathToLines, set.ManagedFilePath);
var sources = BuildEffectiveConfigSources(paths, pathToLines, set.ManagedFilePath, set.ManagedHostsFilePath);
var managedContent = BuildManagedContent(pathToLines, set.ManagedFilePath);
var dhcpHostEntries = BuildDhcpHostEntries(set, pathToLines);
@@ -412,10 +412,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
);
}
private static EffectiveConfigSources BuildEffectiveConfigSources(IReadOnlyList<string> paths, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
private static EffectiveConfigSources BuildEffectiveConfigSources(IReadOnlyList<string> paths, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath, string? managedHostsFilePath)
{
var (_, noHostsSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath, managedHostsFilePath);
var (_, hostsdirPathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Hostsdir, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
var revServerWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.RevServer, managedFilePath);
@@ -184,6 +184,96 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return snapshot.ManagedContent;
}
public async Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default)
{
if (changes.Count == 0) return;
var content = await ReadManagedConfigAsync(ct);
var list = content.Lines.ToList();
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
foreach (var c in changes)
{
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName);
var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
bool MatchesOption(DnsmasqConfLine line)
{
if (line is not OtherLine o) return false;
var raw = o.RawLine.Trim();
return raw == c.OptionName || raw.StartsWith(c.OptionName + "=", StringComparison.Ordinal);
}
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
{
var matchingIndices = new List<int>();
for (var i = 0; i < list.Count; i++)
{
if (MatchesOption(list[i])) matchingIndices.Add(i);
}
for (var i = matchingIndices.Count - 1; i >= 0; i--)
list.RemoveAt(matchingIndices[i]);
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
for (var i = 0; i < values.Count; i++)
{
var lineText = string.IsNullOrEmpty(values[i]) ? c.OptionName : c.OptionName + "=" + values[i];
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
maxLineNumber++;
list.Insert(insertIdx + i, lineObj);
}
continue;
}
var idx = list.FindIndex(MatchesOption);
if (isFlag && c.NewValue is bool b && !b)
{
if (idx >= 0) list.RemoveAt(idx);
continue;
}
string rawLine;
if (isFlag)
rawLine = c.OptionName;
else
{
var v = ToConfValue(c.NewValue);
rawLine = string.IsNullOrEmpty(v) ? c.OptionName : c.OptionName + "=" + v;
}
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
maxLineNumber++;
if (idx >= 0)
list[idx] = newLine;
else
list.Add(newLine);
}
await WriteManagedConfigAsync(list, ct);
}
private static bool TryGetMultiValues(object? value, out IReadOnlyList<string> values)
{
values = null!;
if (value == null) return false;
if (value is IReadOnlyList<string> list)
{
values = list;
return true;
}
if (value is string[] arr)
{
values = arr;
return true;
}
if (value is List<string> listMut)
{
values = listMut;
return true;
}
return false;
}
private static string ToConfValue(object? value)
{
if (value == null) return "";
if (value is bool b) return b ? "1" : "0";
return value.ToString() ?? "";
}
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
@@ -110,14 +110,24 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
/// <inheritdoc />
public RenderFragment<EffectiveConfigFieldDescriptor>? BuildFieldComponentFragment(string sectionId, string optionName)
{
return BuildFieldComponentFragment(sectionId, optionName, default);
}
/// <inheritdoc />
public RenderFragment<EffectiveConfigFieldDescriptor>? BuildFieldComponentFragment(string sectionId, string optionName, EventCallback<object?> onValueChanged)
{
if (!_displayComponents.TryGetValue((sectionId, optionName), out var componentType))
return null;
var hasCallback = onValueChanged.HasDelegate;
return descriptor => builder =>
{
builder.OpenComponent(0, componentType);
builder.AddAttribute(1, "Descriptor", descriptor);
if (hasCallback)
builder.AddAttribute(2, "ValueChanged", onValueChanged);
builder.CloseComponent();
};
}
@@ -8,13 +8,14 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <param name="FileName">Filename only (e.g. dnsmasq.conf, 01-default.conf) for compact display.</param>
/// <param name="IsManaged">True when this file is the app's managed config file (editable from UI).</param>
/// <param name="LineNumber">1-based line number in the file where the value was set; null if unknown.</param>
/// <param name="ReadOnlyTooltipOverride">When set, used as the readonly tooltip instead of the default "From FileName... Edit FilePath to change." Use for special cases (e.g. managed hosts file path).</param>
/// <remarks>
/// When <see cref="IsManaged"/> is true: the user can change or remove the value from the UI (we read/write the managed file).
/// When false (<see cref="IsReadOnly"/> is true): the value is from main or an included conf file; the user must edit that file to change it.
/// For single-value or multi-value options: show "from FileName (readonly)" and e.g. "Edit FilePath to change."
/// For flags: if the flag is set in a non-managed file, the user cannot unset it from the UI (they must remove the line in that file).
/// </remarks>
public record ConfigValueSource(string FilePath, string FileName, bool IsManaged, int? LineNumber = null)
public record ConfigValueSource(string FilePath, string FileName, bool IsManaged, int? LineNumber = null, string? ReadOnlyTooltipOverride = null)
{
/// <summary>True when the value is from a non-managed file: user cannot change it from the UI; for flags, cannot unset without editing that file.</summary>
public bool IsReadOnly => !IsManaged;
@@ -22,9 +23,9 @@ public record ConfigValueSource(string FilePath, string FileName, bool IsManaged
/// <summary>Returns a short tooltip for readonly values (which file, optional line, and how to change). Null when <see cref="IsManaged"/> (editable).</summary>
public string? GetReadOnlyTooltip() =>
IsReadOnly
? LineNumber.HasValue
? ReadOnlyTooltipOverride ?? (LineNumber.HasValue
? $"From {FileName} line {LineNumber} (readonly). Edit {FilePath} to change."
: $"From {FileName} (readonly). Edit {FilePath} to change."
: $"From {FileName} (readonly). Edit {FilePath} to change.")
: null;
}
@@ -0,0 +1,12 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Raised when the user leaves the active edit field (blur) and the value was valid and changed.
/// Section adds to pending changes and clears the active field.
/// </summary>
public record EffectiveConfigEditCommittedArgs(
string SectionId,
string OptionName,
object? OldValue,
object? NewValue,
string? CurrentSourceFilePath);
@@ -0,0 +1,17 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// A single pending change from the effective-config edit session.
/// Only applied when the user confirms Save in the confirmation modal.
/// </summary>
/// <param name="SectionId">Section the option belongs to (for display).</param>
/// <param name="OptionName">Dnsmasq option name (e.g. from DnsmasqConfKeys).</param>
/// <param name="OldValue">Value before edit (effective value at commit time).</param>
/// <param name="NewValue">User-edited value (bool for flags, int? for port/ints, string? for others).</param>
/// <param name="CurrentSourceFilePath">Path of the file that currently sets this value; shown in modal as "will override X".</param>
public record PendingEffectiveConfigChange(
string SectionId,
string OptionName,
object? OldValue,
object? NewValue,
string? CurrentSourceFilePath = null);
@@ -0,0 +1,12 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Raised when the user clicks the readonly badge in edit mode. Section shows ReadonlyEditPopover under the badge.
/// </summary>
public record ReadonlyBadgeClickedEventArgs(
string AnchorId,
ConfigValueSource Source,
string OptionName,
object? Value);
@@ -0,0 +1,6 @@
window.copyToClipboard = function (text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return Promise.reject(new Error('Clipboard not available'));
};
+5
View File
@@ -22,3 +22,8 @@ export function closeModal(dialogElement) {
dialogElement.close();
}
}
/** Returns true if the user clicked OK, false if Cancel. */
export function confirmMessage(message) {
return window.confirm(message);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,601 +0,0 @@
/*!
* Bootstrap Reboot v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,598 +0,0 @@
/*!
* Bootstrap Reboot v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long