+ @if (_editingIndex == rowIndex && !itemReadonly)
+ {
+
+
+
+ Editor mode
+
+
+
+
+
+ OnDraftUseRawChanged(false)">
+ Guided
+
+ OnDraftUseRawChanged(true)">
+ Raw line
+
+
-
- Applies to
-
- Any client address (default)
- Limit to this subnet or IP
- Limit to subnets on this interface
-
-
- @if (_draftScopeSelect == "range")
+ @if (_draftUseRaw)
+ {
+
+ }
+ else
{
-
Subnet or IP
-
-
Single IP, CIDR, or start,end pair per dnsmasq docs.
+
Domain
+
+
+
Use # to take the search domain from resolv.conf.
+
+
+
+
-
-
-
- Also add matching --local forward and reverse DNS
-
-
- }
- else if (_draftScopeSelect == "interface")
- {
- Interface
-
+ Applies to
+
+ Any client address (default)
+ Limit to this subnet or IP
+ Limit to subnets on this interface
+
+ @if (_draftScopeSelect == "range")
+ {
+
+
Subnet or IP
+
+
Single IP, CIDR, or start,end pair per dnsmasq docs.
+
+
+
+
+ Also add matching --local forward and reverse DNS
+
+
+ }
+ else if (_draftScopeSelect == "interface")
+ {
+
+ Interface
+
+
+ }
+ else if (_draftScopeSelect == "unconditional")
+ {
+
+
+
+ Append ,local
+
+
+
+
+
+ }
}
- else if (_draftScopeSelect == "unconditional")
- {
-
-
-
- Append ,local (same as a lone --local for this name; uncommon)
-
-
- }
- }
-
-
- }
- else
- {
-
-
@DisplayLine(rowValue)
- @if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
- {
-
@rowValue
- }
-
-
- @if (!itemReadonly)
+ }
+ else
+ {
+
+ @DisplayLine(rowValue)
+ @if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
{
-
+ @rowValue
}
-
-
- }
+
+
+ @if (!itemReadonly)
+ {
+
+ }
+
+
+ }
+
}
}
@@ -145,19 +180,37 @@
@if (_addingNew)
{
-
Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with expand-hosts) how simple names are qualified in DNS.
-
Edit as raw line
-
Applies to
@@ -181,8 +244,9 @@
Interface
}
else if (_draftScopeSelect == "unconditional")
{
-
@code {
@@ -354,6 +425,12 @@
_draftUseRaw = useRaw;
}
+ private void ShowDomainRuleEditorHelp() => NotificationService.ShowInfo(DomainRuleEditorHelpText);
+
+ private void ShowLocalSuffixHelp() => NotificationService.ShowInfo(LocalSuffixHelpText);
+
+ private void ShowHashDomainHelp() => NotificationService.ShowInfo(HashDomainHelpText);
+
private void OnDraftScopeAfterChange()
{
if (_draftScopeSelect == "unconditional")
@@ -490,4 +567,13 @@
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
+
+ private const string DomainRuleEditorHelpText =
+ "domain= sets the suffix dnsmasq uses for DHCP and local DNS naming. Guided mode builds the rule from fields; Raw line mode lets you enter the exact dnsmasq config line directly.";
+
+ private const string LocalSuffixHelpText =
+ "Appending ,local behaves like a matching --local rule for this domain, making dnsmasq answer locally instead of forwarding it upstream. This is uncommon for an unconditional domain= rule.";
+
+ private const string HashDomainHelpText =
+ "Use # as the domain value to take the search domain from resolv.conf instead of hard-coding a suffix in dnsmasq.";
}
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css
index 0afbd81..64ce759 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css
@@ -7,32 +7,22 @@
background-color: var(--bs-body-bg, #fff);
}
-.ec-domain-rule-editor .ec-field-value-list,
-.ec-domain-rule-list {
- list-style: none;
+/* Keep
markers (decimal) like the default multi-value editor; do not reset list-style here. */
+.ec-domain-rule-editor .ec-field-value-list {
margin-bottom: 0;
- padding-left: 0;
-}
-
-.ec-domain-rule-list > li {
- list-style: none;
-}
-
-.ec-domain-rule-intro {
- font-size: 0.8rem;
- color: var(--bs-secondary-color, #6c757d);
- margin-bottom: 0.35rem;
- line-height: 1.35;
}
.ec-domain-rule-row {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
min-height: 2rem;
}
-.ec-domain-rule-row.ec-domain-rule-row-editing {
+.ec-domain-rule-row-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.ec-domain-rule-row.ec-domain-rule-row-editing .ec-domain-rule-row-body {
align-items: stretch;
}
@@ -52,13 +42,77 @@
color: var(--bs-secondary-color, #6c757d);
}
+.ec-domain-rule-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem 0.75rem;
+ flex-wrap: wrap;
+}
+
+.ec-domain-rule-toolbar__group {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.ec-domain-rule-mode-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ padding: 0.2rem;
+ border: 1px solid var(--bs-border-color, #dee2e6);
+ border-radius: 999px;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+}
+
+.ec-domain-rule-mode-btn {
+ border: 0;
+ border-radius: 999px;
+ padding: 0.2rem 0.65rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #475569;
+ background: transparent;
+ box-shadow: none;
+}
+
+.ec-domain-rule-mode-btn:hover,
+.ec-domain-rule-mode-btn:focus-visible {
+ color: #0f172a;
+ background: rgba(148, 163, 184, 0.12);
+}
+
+.ec-domain-rule-mode-btn--active,
+.ec-domain-rule-mode-btn--active:hover,
+.ec-domain-rule-mode-btn--active:focus-visible {
+ color: #fff;
+ background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
+}
+
+.ec-domain-rule-help {
+ line-height: 1;
+ text-decoration: none;
+}
+
+.ec-domain-rule-help:hover,
+.ec-domain-rule-help:focus-visible {
+ color: var(--bs-primary) !important;
+}
+
.ec-domain-rule-actions {
display: flex;
align-items: center;
+ justify-content: flex-end;
flex-wrap: wrap;
gap: 0.35rem;
}
+.ec-domain-rule-editor .ec-field-value-list li.ec-multi-list-add-item .ec-domain-rule-actions .status-badge-inline,
+.ec-domain-rule-actions .status-badge-inline {
+ margin-left: 0;
+}
+
.ec-domain-rule-summary {
display: flex;
align-items: center;
@@ -77,3 +131,20 @@
color: var(--bs-secondary-color, #6c757d);
margin-top: -0.25rem;
}
+
+.ec-domain-rule-hint-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.25rem;
+}
+
+.ec-domain-rule-local-check {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.35rem;
+}
+
+.ec-domain-rule-local-check .form-check-label {
+ flex: 1 1 auto;
+ min-width: 0;
+}
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor
index 3b3f63f..4bf3ceb 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor
@@ -38,42 +38,10 @@ else
}
}
- @if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
- {
-
-
-
- }
- else if (Context.EffectiveItems.Count == 0)
+ else
{
- @if (Context.ShowEditableBadge)
- {
- (none)
-
- }
- else
- {
- (none)
- }
+ (none)
}
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor
index da50663..9ff2faa 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor
@@ -49,22 +49,22 @@ else if (Descriptor.IsMultiValue)
{
@* Custom multi: row badge only while this field is active (ok/cancel/revert). Otherwise per-item / entry badges in the fragment suffice — avoids duplicating "readonly" when RowSource matches item sources. *@
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
-
- @Descriptor.OptionName:
-
-
-
- @customMulti(_multiContext)
-
+
+
+ @Descriptor.OptionName:
+ @if (!Descriptor.IsCapabilityDisabled)
+ {
+
+ }
+
+
+
+ @customMulti(_multiContext)
-
- @if (customMultiActiveEditor)
- {
-
- }
+
}
else if (_multiContext is not null)
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor
index e10878f..e843c8c 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor
@@ -19,47 +19,49 @@
var rowValue = item.Value;
- @if (_editingIndex == rowIndex && !itemReadonly)
- {
-
-
}
}
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css
index 6e4c8f2..a518b10 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css
@@ -13,14 +13,18 @@
}
.ec-multi-list-row {
+ min-height: 2rem;
+}
+
+.ec-multi-list-row-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
- min-height: 2rem;
+ min-width: 0;
}
-.ec-multi-list-row.ec-multi-list-row-with-controls {
+.ec-multi-list-row.ec-multi-list-row-with-controls .ec-multi-list-row-body {
align-items: flex-start;
}
@@ -47,18 +51,18 @@
width: 100%;
}
-.ec-multi-list-row .ec-field-value-item {
+.ec-multi-list-row-body .ec-field-value-item {
min-width: 0;
}
-.ec-multi-list-row .status-badge-inline {
+.ec-multi-list-row-body .status-badge-inline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
-.ec-multi-list-row .form-control.ec-multi-list-input {
+.ec-multi-list-row-body .form-control.ec-multi-list-input {
max-width: none;
flex-shrink: 0;
width: 100%;
@@ -86,7 +90,7 @@
max-width: none;
}
-.ec-multi-list-add-item .status-badge-inline {
+.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
margin-left: auto;
}
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor
index fac8ec6..6748c7e 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor
@@ -2,8 +2,18 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
-
- @Context.Descriptor.OptionName:
+
+
+ @Context.Descriptor.OptionName:
+ @if (Context.IsActiveEditor || Context.ShowEditableBadge)
+ {
+
+ }
+
@if (Context.IsActiveEditor)
{
-
}
else
{
@@ -44,42 +49,10 @@
}
}
- @if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
- {
-
-
-
- }
else if (Context.EffectiveItems.Count == 0)
{
- @if (Context.ShowEditableBadge)
- {
- (none)
-
- }
- else
- {
- (none)
- }
+ (none)
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
index 472e3de..9ae45fd 100644
--- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
@@ -230,6 +230,12 @@ else
@onclick="OnManagedHeaderSaveAsync">
Save
+
+ Cancel
+
}
else if (ManagedSelectionCount == 1)
{
@@ -865,6 +871,11 @@ else
await TryFinishHostEditFromKeyboardAsync();
}
+ private async Task OnManagedHeaderCancelAsync()
+ {
+ await DiscardCurrentEditAsync();
+ }
+
private async Task OnManagedHeaderEditAsync()
{
DeactivateEffectiveConfigFieldIfActive();
@@ -1057,6 +1068,11 @@ else
private void RevealHostFieldValidation() => _showHostFieldValidation = true;
+ private bool IsCurrentEditBlank =>
+ string.IsNullOrWhiteSpace(_editAddress)
+ && ParseNamesList(_editNames).Count == 0
+ && string.IsNullOrWhiteSpace(_editInlineComment);
+
private void DeactivateEffectiveConfigFieldIfActive()
{
if (Session.Ui.ActiveFieldKey != null)
@@ -1072,41 +1088,8 @@ else
private async Task OnInvalidEditDiscardAsync()
{
- if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
- {
- _showInvalidHostEditModal = false;
- CancelEdit();
- await InvokeAsync(StateHasChanged);
- return;
- }
-
- var list = CloneEntries(CurrentManagedEntries).ToList();
- var entry = list.FirstOrDefault(MatchesEditingId);
- var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
- if (wasNew)
- {
- list.Remove(entry!);
- Session.Draft.SetManagedHostsDraft(
- CloneEntries(_baselineManagedEntries),
- list,
- _status.ManagedHostsFilePath);
- RebuildManagedOverlay();
- InvalidateVisibleGroupsCache();
- }
-
_showInvalidHostEditModal = false;
- var prevId = _editingRowId;
- CancelEdit();
- if (wasNew)
- _selectedManagedRowIds.Clear();
- else
- {
- _selectedManagedRowIds.Clear();
- if (!string.IsNullOrEmpty(prevId))
- _selectedManagedRowIds.Add(prevId);
- }
-
- await InvokeAsync(StateHasChanged);
+ await DiscardCurrentEditAsync();
}
private async Task OnEditFieldKeyDown(KeyboardEventArgs e)
@@ -1131,6 +1114,12 @@ else
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
return;
+ if (IsCurrentEditBlank)
+ {
+ await DiscardCurrentEditAsync();
+ return;
+ }
+
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
@@ -1168,6 +1157,10 @@ else
{
try
{
+ var recentCueInteraction = await _hostsTableScrollCueModule.InvokeAsync
("wasRecentCueInteraction");
+ if (recentCueInteraction)
+ return;
+
outside = await _hostsTableScrollCueModule.InvokeAsync("isFocusOutsideElement", _editingRowRef);
}
catch (Exception ex) when (ex is JSDisconnectedException or JSException or ObjectDisposedException or TaskCanceledException)
@@ -1178,6 +1171,12 @@ else
if (outside)
{
+ if (IsCurrentEditBlank)
+ {
+ await DiscardCurrentEditAsync();
+ return;
+ }
+
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
@@ -1220,6 +1219,41 @@ else
await InvokeAsync(StateHasChanged);
}
+ private async Task DiscardCurrentEditAsync()
+ {
+ if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
+ {
+ CancelEdit();
+ await InvokeAsync(StateHasChanged);
+ return;
+ }
+
+ var list = CloneEntries(CurrentManagedEntries).ToList();
+ var entry = list.FirstOrDefault(MatchesEditingId);
+ var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
+ var prevId = _editingRowId;
+
+ if (wasNew)
+ {
+ list.Remove(entry!);
+ Session.Draft.SetManagedHostsDraft(
+ CloneEntries(_baselineManagedEntries),
+ list,
+ _status.ManagedHostsFilePath);
+ RebuildManagedOverlay();
+ InvalidateVisibleGroupsCache();
+ }
+
+ CancelEdit();
+ _showInvalidHostEditModal = false;
+ _selectedManagedRowIds.Clear();
+ if (!wasNew && !string.IsNullOrEmpty(prevId))
+ _selectedManagedRowIds.Add(prevId);
+ _selectionAnchorRowId = !wasNew ? prevId : null;
+
+ await InvokeAsync(StateHasChanged);
+ }
+
private bool MatchesEditingId(HostEntry e)
{
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
index 4042433..c491e79 100644
--- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
+++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
@@ -376,7 +376,7 @@
/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */
.hosts-group-header__path-row--with-managed-actions {
display: grid;
- grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 9.75rem);
+ grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 13.5rem);
align-items: center;
column-gap: 0.5rem;
}
@@ -477,6 +477,19 @@
border-color: rgba(8, 66, 152, 0.92);
}
+.hosts-managed-action-btn--cancel {
+ color: #334155;
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+ border-color: rgba(148, 163, 184, 0.38);
+}
+
+.hosts-managed-action-btn--cancel:hover,
+.hosts-managed-action-btn--cancel:focus-visible {
+ color: #0f172a;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+ border-color: rgba(100, 116, 139, 0.42);
+}
+
.hosts-managed-action-btn--edit {
color: #0f172a;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
@@ -589,7 +602,7 @@
@media (max-width: 640.98px) {
.hosts-group-header__path-row--with-managed-actions {
- --hosts-managed-actions-col: 9rem;
+ --hosts-managed-actions-col: 12rem;
}
.hosts-group-header__managed-actions {
diff --git a/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js b/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js
index 4f20c98..5d6230c 100644
--- a/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js
+++ b/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js
@@ -10,10 +10,16 @@ const POLL_MS = 100;
const BTN_LEFT_CLASS = 'app-table-cue-btn--left';
const BTN_RIGHT_CLASS = 'app-table-cue-btn--right';
const CUE_BTN_CLASS = 'app-table-cue-btn';
+const RECENT_CUE_INTERACTION_MS = 300;
let pollId = null;
let resizeListener = null;
const scrollListeners = new WeakMap();
+let lastCueInteractionAt = 0;
+
+function markCueInteraction() {
+ lastCueInteractionAt = Date.now();
+}
function applyScrollCue(scrollEl, hostEl) {
if (!scrollEl || !hostEl) return;
@@ -58,7 +64,9 @@ function ensureCueButtons(hostEl) {
leftBtn.type = 'button';
leftBtn.className = CUE_BTN_CLASS + ' ' + BTN_LEFT_CLASS;
leftBtn.setAttribute('aria-label', 'Scroll table left');
+ leftBtn.addEventListener('pointerdown', markCueInteraction);
leftBtn.addEventListener('click', function () {
+ markCueInteraction();
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'left');
});
hostEl.appendChild(leftBtn);
@@ -69,7 +77,9 @@ function ensureCueButtons(hostEl) {
rightBtn.type = 'button';
rightBtn.className = CUE_BTN_CLASS + ' ' + BTN_RIGHT_CLASS;
rightBtn.setAttribute('aria-label', 'Scroll table right');
+ rightBtn.addEventListener('pointerdown', markCueInteraction);
rightBtn.addEventListener('click', function () {
+ markCueInteraction();
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'right');
});
hostEl.appendChild(rightBtn);
@@ -170,3 +180,7 @@ export function isFocusOutsideElement(container) {
if (!active) return true;
return !container.contains(active);
}
+
+export function wasRecentCueInteraction() {
+ return Date.now() - lastCueInteractionAt <= RECENT_CUE_INTERACTION_MS;
+}