UI, logging, and settings improvements

- Add app version to sidebar footer with link to GitHub release
- CSS cache busting: append version query string to static assets
- Remove LogEvents; improve startup logging with version, env, paths, uptime
- Settings modal: collapsible groups with search-aware expand/collapse
- Release workflow and publish script: pass version from tag for builds
This commit is contained in:
2026-02-11 07:26:35 +10:00
parent 1bdd176c18
commit 5ac2006b80
27 changed files with 360 additions and 221 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }}
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -40,7 +40,7 @@ jobs:
- name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Zip publish output
run: |
+21 -4
View File
@@ -59,14 +59,17 @@ default_rid() {
esac
}
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
VERSION_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
-h|-?|--help)
echo "Usage: $0 [OPTIONS] [RID]"
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
echo ""
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
echo "Pass options first, then RID if desired (e.g. $0 --trim linux-x64)."
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
echo ""
echo "Options:"
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
*)
RID="$1"
shift
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
VERSION_OVERRIDE="$1"
shift
fi
break
;;
esac
@@ -148,19 +155,29 @@ if [ "$CLEAN" = true ]; then
dotnet clean "$PROJECT" -c Release -nologo -v q
fi
VERSION_ARGS=""
if [ -n "$VERSION_OVERRIDE" ]; then
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
VER="${VERSION_OVERRIDE#v}"
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
echo "Using version: $VER (from $VERSION_OVERRIDE)"
fi
if [ "$TRIM" = true ]; then
echo "Publishing self-contained (trimmed, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true \
-p:PublishTrimmed=true
-p:PublishTrimmed=true \
$VERSION_ARGS
else
echo "Publishing self-contained (no trim, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true
--self-contained true \
$VERSION_ARGS
fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
+13 -10
View File
@@ -1,3 +1,4 @@
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
<!DOCTYPE html>
<html lang="en">
@@ -6,16 +7,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
<title>@_appTitle</title>
<HeadOutlet />
</head>
@@ -27,5 +28,7 @@
</html>
@code {
private static readonly string _cacheBust = typeof(App).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
}
@@ -29,6 +29,8 @@ main.main {
}
.sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
}
@@ -1,4 +1,5 @@
@namespace DnsmasqWebUI.Components.Layout
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
@@ -35,9 +36,27 @@
</NavLink>
</div>
</nav>
<div class="nav-footer px-3 py-2" aria-label="Application version">
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">
v@(BaseVersion(Version))
</a>
</div>
</div>
@code {
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string Version = typeof(NavMenu).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
/// <summary>Base version without build metadata (e.g. 0.0.4+abc123 -> 0.0.4) for GitHub tag URLs.</summary>
private static string BaseVersion(string v)
{
if (string.IsNullOrEmpty(v) || v == "?") return v;
var i = v.IndexOf('+');
return i >= 0 ? v[..i] : v;
}
private string ReleaseUrl => Version != "?"
? $"{ReleasesBaseUrl}/tag/v{BaseVersion(Version)}"
: ReleasesBaseUrl;
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
@@ -131,13 +131,29 @@
color: white;
}
.nav-footer {
margin-top: auto;
flex-shrink: 0;
font-size: 0.8125rem;
}
.nav-footer-link {
color: rgba(255, 255, 255, 0.65) !important;
text-decoration: none;
}
.nav-footer-link:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-scrollable {
display: none;
flex-direction: column;
}
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
display: block;
display: flex;
}
@media (min-width: 641px) {
@@ -146,11 +162,10 @@
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
/* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
}
}
@@ -33,77 +33,86 @@
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div>
}
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
@foreach (var group in GetVisibleGroups())
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Logs))
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogs))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Leases))
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
@bind="_editingSettings.RecentLogsMaxLines" />
var isExpanded = IsGroupExpanded(group);
<div class="settings-group">
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
@onclick="() => ToggleGroup(group.Id)"
aria-expanded="@isExpanded"
aria-controls="group-@group.Id">
<span class="settings-group-title">@group.DisplayName</span>
<span class="settings-group-chevron" aria-hidden="true"></span>
</button>
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
role="region" aria-label="@group.DisplayName">
@foreach (var key in group.SectionKeys)
{
@if (ShouldShowSection(key))
{
<div class="settings-section">
@switch (key)
{
case SettingsModalSections.ServiceStatus:
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
break;
}
case SettingsModalSections.Logs:
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
break;
}
case SettingsModalSections.AppLogs:
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
break;
}
case SettingsModalSections.Leases:
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
break;
}
case SettingsModalSections.RecentLogsDisplay:
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
@bind="_editingSettings.RecentLogsMaxLines" />
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
case SettingsModalSections.AppLogsDisplay:
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
@bind="_editingSettings.AppLogsMaxLines" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
</div>
}
}
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
@bind="_editingSettings.AppLogsMaxLines" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
</div>
}
</div>
<div class="modal-footer">
@@ -18,6 +18,7 @@ public partial class SettingsModal : IAsyncDisposable
private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty;
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
@@ -40,6 +41,10 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened)
{
_searchTerm = string.Empty;
_expandedGroupIds.Clear();
var visible = SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm)).ToList();
if (visible.Count > 0)
_expandedGroupIds.Add(visible[0].Id);
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
@@ -84,6 +89,26 @@ public partial class SettingsModal : IAsyncDisposable
private void FilterSettings() => StateHasChanged();
private IEnumerable<SettingsModalSections.CollapsibleGroup> GetVisibleGroups() =>
SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm));
private bool IsGroupExpanded(SettingsModalSections.CollapsibleGroup group)
{
var hasActiveFilter = SettingsContext == SettingsModalContext.All && !string.IsNullOrWhiteSpace(_searchTerm);
if (hasActiveFilter)
return true;
return _expandedGroupIds.Contains(group.Id);
}
private void ToggleGroup(string groupId)
{
if (_expandedGroupIds.Contains(groupId))
_expandedGroupIds.Remove(groupId);
else
_expandedGroupIds.Add(groupId);
StateHasChanged();
}
private bool ShouldShowSection(string key)
{
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
@@ -33,3 +33,80 @@
border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem;
}
/* Collapsible section groups */
.settings-group {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.375rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
font-weight: 500;
color: #212529;
background: rgba(0, 0, 0, 0.03);
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
}
.settings-group-header:hover {
background: rgba(0, 0, 0, 0.06);
}
.settings-group-header.expanded {
background: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.settings-group-chevron {
flex-shrink: 0;
width: 0.75rem;
height: 0.75rem;
margin-left: 0.5rem;
border-right: 2px solid #6c757d;
border-bottom: 2px solid #6c757d;
transform: rotate(-45deg);
transition: transform 0.2s ease;
}
.settings-group-header.expanded .settings-group-chevron {
transform: rotate(135deg);
}
.settings-group-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease-out;
}
.settings-group-content.expanded {
max-height: 500px;
transition: max-height 0.35s ease-in;
}
.settings-group-content > * {
padding: 1rem 0.75rem;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-section + .settings-section {
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
@@ -38,7 +37,7 @@ public class ConfigController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetSetFailed, ex, "Get config set failed");
_logger.LogError(ex, "Get config set failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -55,7 +54,7 @@ public class ConfigController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetManagedFailed, ex, "Get managed config failed");
_logger.LogError(ex, "Get managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -70,12 +69,12 @@ public class ConfigController : ControllerBase
{
await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ConfigPutManagedSuccess, "Config managed updated, reload success={Success}", reload.Success);
_logger.LogInformation("Config managed updated, reload success={Success}", reload.Success);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigPutManagedFailed, ex, "Put managed config failed");
_logger.LogError(ex, "Put managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
@@ -33,7 +32,7 @@ public class DhcpController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpGetHostsFailed, ex, "Get DHCP hosts failed");
_logger.LogError(ex, "Get DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -47,17 +46,17 @@ public class DhcpController : ControllerBase
{
await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.DhcpPutHostsSuccess, "DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
_logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (ArgumentException ex)
{
_logger.LogWarning(LogEvents.DhcpPutHostsValidationFailed, ex, "DHCP hosts put validation failed");
_logger.LogWarning(ex, "DHCP hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpPutHostsFailed, ex, "Put DHCP hosts failed");
_logger.LogError(ex, "Put DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Hosts;
@@ -35,7 +34,7 @@ public class HostsController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed");
_logger.LogError(ex, "Get hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -52,7 +51,7 @@ public class HostsController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetReadOnlyFailed, ex, "Get readonly hosts failed");
_logger.LogError(ex, "Get readonly hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -66,17 +65,17 @@ public class HostsController : ControllerBase
{
await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.HostsPutSuccess, "Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
_logger.LogInformation("Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(LogEvents.HostsPutValidationFailed, ex, "Hosts put validation failed");
_logger.LogWarning(ex, "Hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed");
_logger.LogError(ex, "Put hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.AspNetCore.Mvc;
@@ -38,7 +37,7 @@ public class LeasesController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed");
_logger.LogError(ex, "Get leases failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -43,19 +43,19 @@ public class LoggingController : ControllerBase
var level = ParseLevel(request?.LogLevel);
if (level == null)
{
_logger.LogWarning(LogEvents.LogLevelSetBadRequest, "Set log level failed: invalid level requested");
_logger.LogWarning("Set log level failed: invalid level requested");
return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
}
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null))
{
_logger.LogError(LogEvents.LogLevelSetFailed, "Failed to write log level overrides file to {Path}", path);
_logger.LogError("Failed to write log level overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.LogLevelChanged, "Log level changed to {Level}", LevelName(level.Value));
_logger.LogInformation("Log level changed to {Level}", LevelName(level.Value));
return Ok(new LogLevelResponse(LevelName(level.Value)));
}
@@ -75,12 +75,12 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to write filters overrides file to {Path}", path);
_logger.LogError("Failed to write filters overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters updated, count={Count}", prefixes.Count);
_logger.LogInformation("App logs filters updated, count={Count}", prefixes.Count);
return Ok(new FiltersResponse(prefixes));
}
@@ -91,13 +91,13 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath();
if (!TryRemoveFilterOverrides(path))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to remove filter overrides from {Path}", path);
_logger.LogError("Failed to remove filter overrides from {Path}", path);
return StatusCode(500, new { error = "Failed to update overrides file." });
}
_configurationRoot.Reload();
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration);
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters restored to defaults, count={Count}", defaults.Count);
_logger.LogInformation("App logs filters restored to defaults, count={Count}", defaults.Count);
return Ok(new FiltersResponse(defaults));
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -24,12 +23,12 @@ public class ReloadController : ControllerBase
try
{
var result = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ReloadRequestSuccess, "Reload requested, success={Success}", result.Success);
_logger.LogInformation("Reload requested, success={Success}", result.Success);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed");
_logger.LogError(ex, "Reload request failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
@@ -104,7 +103,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed");
_logger.LogError(ex, "Get status failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -162,7 +161,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusLogsDownloadFailed, ex, "Status logs download failed");
_logger.LogError(ex, "Status logs download failed");
return StatusCode(500, new { error = ex.Message });
}
}
+1
View File
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<Version>0.0.4</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression>
@@ -1,70 +0,0 @@
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Logging;
/// <summary>Structured event IDs for application logging. Enables filtering and alerting by event type.</summary>
internal static class LogEvents
{
// Config (1xxx)
public static readonly EventId ConfigGetSetFailed = new(1001, nameof(ConfigGetSetFailed));
public static readonly EventId ConfigGetManagedFailed = new(1002, nameof(ConfigGetManagedFailed));
public static readonly EventId ConfigPutManagedFailed = new(1003, nameof(ConfigPutManagedFailed));
public static readonly EventId ConfigPutManagedSuccess = new(1004, nameof(ConfigPutManagedSuccess));
public static readonly EventId ConfigNoManagedFilePath = new(1005, nameof(ConfigNoManagedFilePath));
public static readonly EventId ConfigWroteManagedFile = new(1006, nameof(ConfigWroteManagedFile));
// Hosts (2xxx)
public static readonly EventId HostsGetFailed = new(2001, nameof(HostsGetFailed));
public static readonly EventId HostsGetReadOnlyFailed = new(2002, nameof(HostsGetReadOnlyFailed));
public static readonly EventId HostsPutFailed = new(2003, nameof(HostsPutFailed));
public static readonly EventId HostsPutValidationFailed = new(2004, nameof(HostsPutValidationFailed));
public static readonly EventId HostsPutSuccess = new(2005, nameof(HostsPutSuccess));
public static readonly EventId HostsWroteManagedFile = new(2006, nameof(HostsWroteManagedFile));
// Dhcp (3xxx)
public static readonly EventId DhcpGetHostsFailed = new(3001, nameof(DhcpGetHostsFailed));
public static readonly EventId DhcpPutHostsFailed = new(3002, nameof(DhcpPutHostsFailed));
public static readonly EventId DhcpPutHostsValidationFailed = new(3003, nameof(DhcpPutHostsValidationFailed));
public static readonly EventId DhcpPutHostsSuccess = new(3004, nameof(DhcpPutHostsSuccess));
// Reload (4xxx)
public static readonly EventId ReloadRequestFailed = new(4001, nameof(ReloadRequestFailed));
public static readonly EventId ReloadRequestSuccess = new(4002, nameof(ReloadRequestSuccess));
public static readonly EventId ReloadCommandNotConfigured = new(4003, nameof(ReloadCommandNotConfigured));
public static readonly EventId ReloadRejectedConcurrent = new(4004, nameof(ReloadRejectedConcurrent));
public static readonly EventId ReloadNonZeroExit = new(4005, nameof(ReloadNonZeroExit));
public static readonly EventId ReloadSucceeded = new(4006, nameof(ReloadSucceeded));
public static readonly EventId ReloadFailed = new(4007, nameof(ReloadFailed));
// Status (5xxx)
public static readonly EventId StatusGetFailed = new(5001, nameof(StatusGetFailed));
public static readonly EventId StatusLogsDownloadFailed = new(5002, nameof(StatusLogsDownloadFailed));
// Leases (6xxx)
public static readonly EventId LeasesGetFailed = new(6001, nameof(LeasesGetFailed));
// Logging (7xxx)
public static readonly EventId LogLevelChanged = new(7001, nameof(LogLevelChanged));
public static readonly EventId LogLevelSetBadRequest = new(7002, nameof(LogLevelSetBadRequest));
public static readonly EventId LogLevelSetFailed = new(7003, nameof(LogLevelSetFailed));
public static readonly EventId FiltersChanged = new(7004, nameof(FiltersChanged));
public static readonly EventId FiltersSetFailed = new(7005, nameof(FiltersSetFailed));
// LogsService (8010)
public static readonly EventId DnsmasqLogsPushFailed = new(8010, nameof(DnsmasqLogsPushFailed));
// ProcessRunner (8xxx)
public static readonly EventId CommandFailed = new(8001, nameof(CommandFailed));
public static readonly EventId CommandTimeout = new(8002, nameof(CommandTimeout));
public static readonly EventId CommandStarted = new(8003, nameof(CommandStarted));
public static readonly EventId CommandCompleted = new(8004, nameof(CommandCompleted));
// LeasesFileService (9xxx)
public static readonly EventId LeasesReadSuccess = new(9001, nameof(LeasesReadSuccess));
// Application lifecycle (9xxx)
public static readonly EventId ApplicationStarted = new(9101, nameof(ApplicationStarted));
public static readonly EventId ApplicationStopping = new(9102, nameof(ApplicationStopping));
public static readonly EventId ManagedConfigConfFileLineSet = new(9110, nameof(ManagedConfigConfFileLineSet));
public static readonly EventId ManagedConfigCreatedAtStartup = new(9111, nameof(ManagedConfigCreatedAtStartup));
}
@@ -1,29 +1,62 @@
using DnsmasqWebUI.Infrastructure.Logging;
using System.Reflection;
using System.Runtime.InteropServices;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>Logs application started and stopping so the app logs viewer shows lifecycle events.</summary>
/// <summary>Logs application start and stop with runtime and configuration context.</summary>
public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
{
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger;
private readonly IHostEnvironment _env;
private readonly DnsmasqOptions _dnsmasq;
private readonly DateTimeOffset _startedAt;
public ApplicationLifecycleLoggingHostedService(ILogger<ApplicationLifecycleLoggingHostedService> logger)
public ApplicationLifecycleLoggingHostedService(
ILogger<ApplicationLifecycleLoggingHostedService> logger,
IHostEnvironment env,
IOptions<DnsmasqOptions> dnsmasq)
{
_logger = logger;
_env = env;
_dnsmasq = dnsmasq.Value;
_startedAt = DateTimeOffset.UtcNow;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStarted, "Application started");
var version = typeof(ApplicationLifecycleLoggingHostedService).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
var mainDir = Path.GetDirectoryName(Path.GetFullPath(_dnsmasq.MainConfigPath)) ?? "";
var managedConfigPath = Path.Combine(mainDir, _dnsmasq.ManagedFileName);
var managedHostsPath = Path.Combine(mainDir, _dnsmasq.ManagedHostsFileName);
_logger.LogInformation(
"Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " +
"MainConfig={MainConfig}, managedConfig={ManagedConfig}, managedHosts={ManagedHosts}, systemHosts={SystemHosts}. " +
"ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
version,
_env.EnvironmentName,
RuntimeInformation.OSDescription.Trim(),
RuntimeInformation.OSArchitecture,
_dnsmasq.MainConfigPath,
managedConfigPath,
managedHostsPath,
_dnsmasq.SystemHostsPath ?? "(not set)",
string.IsNullOrWhiteSpace(_dnsmasq.ReloadCommand) ? "(not set)" : _dnsmasq.ReloadCommand,
string.IsNullOrWhiteSpace(_dnsmasq.StatusCommand) ? "(not set)" : _dnsmasq.StatusCommand);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStopping, "Application stopping");
var uptime = DateTimeOffset.UtcNow - _startedAt;
_logger.LogInformation("Application stopping: uptime={Uptime:F1}s", uptime.TotalSeconds);
return Task.CompletedTask;
}
}
@@ -1,6 +1,5 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
@@ -36,7 +35,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
{
_logger.LogDebug(LogEvents.ConfigNoManagedFilePath, "No managed file path (no conf-dir in main config); returning empty dhcp hosts");
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>();
}
var allEntries = snapshot.DhcpHostEntries.ToList();
@@ -151,7 +150,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
@@ -206,7 +205,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
}
@@ -1,6 +1,5 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
@@ -81,7 +80,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
_logger.LogInformation(LogEvents.ManagedConfigConfFileLineSet, "Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=", confFileLine, mainFull);
}
else
{
@@ -93,7 +92,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
if (File.Exists(set.ManagedFilePath))
return;
_logger.LogInformation(LogEvents.ManagedConfigCreatedAtStartup, "Creating managed config file at startup: {Path}", set.ManagedFilePath);
_logger.LogInformation("Creating managed config file at startup: {Path}", set.ManagedFilePath);
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
}
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Hosts;
@@ -41,6 +40,6 @@ public class HostsFileService : IHostsFileService
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries);
_logger.LogInformation(LogEvents.HostsWroteManagedFile, "Wrote managed hosts file: {Path}", path);
_logger.LogInformation("Wrote managed hosts file: {Path}", path);
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public class LeasesFileService : ILeasesFileService
{
var (available, entries) = await _cache.GetOrRefreshAsync(ct);
if (available && entries != null)
_logger.LogInformation(LogEvents.LeasesReadSuccess, "Leases read, count={Count}", entries.Count);
_logger.LogInformation("Leases read, count={Count}", entries.Count);
return (available, entries);
}
}
@@ -1,5 +1,4 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Logs;
@@ -77,7 +76,7 @@ public sealed class LogsService : ILogsService
}
catch (Exception ex)
{
_logger.LogWarning(LogEvents.DnsmasqLogsPushFailed, ex, "Failed to run or push dnsmasq logs");
_logger.LogWarning(ex, "Failed to run or push dnsmasq logs");
await PushChunkedAsync("DnsmasqLogsUpdate", "replace", $"(Error: {ex.Message})\n", ct);
}
}
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public sealed class ProcessRunner : IProcessRunner
var trimmed = command!.Trim();
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
_logger.LogDebug(LogEvents.CommandStarted, "Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
_logger.LogDebug("Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
var stdout = new StringBuilder();
var stderr = new StringBuilder();
@@ -83,7 +82,7 @@ public sealed class ProcessRunner : IProcessRunner
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
_logger.LogWarning(LogEvents.CommandTimeout, "Command timed out after {Timeout}s", timeout.TotalSeconds);
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
@@ -92,7 +91,7 @@ public sealed class ProcessRunner : IProcessRunner
}
var exitCode = process.HasExited ? process.ExitCode : -1;
_logger.LogDebug(LogEvents.CommandCompleted, "Command completed, exit code={ExitCode}", exitCode);
_logger.LogDebug("Command completed, exit code={ExitCode}", exitCode);
return new ProcessRunResult(
exitCode,
stdout.ToString(),
@@ -101,7 +100,7 @@ public sealed class ProcessRunner : IProcessRunner
}
catch (Exception ex)
{
_logger.LogError(LogEvents.CommandFailed, ex, "Failed to run command");
_logger.LogError(ex, "Failed to run command");
return new ProcessRunResult(null, "", "", false, ex.Message);
}
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging;
@@ -24,13 +23,13 @@ public class ReloadService : IReloadService
{
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
{
_logger.LogDebug(LogEvents.ReloadCommandNotConfigured, "Reload command not configured");
_logger.LogDebug("Reload command not configured");
return new ReloadResult(true, 0, null, "Reload command not configured");
}
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct))
{
_logger.LogDebug(LogEvents.ReloadRejectedConcurrent, "Reload already in progress, rejecting concurrent request");
_logger.LogDebug("Reload already in progress, rejecting concurrent request");
return new ReloadResult(false, -1, null, "Reload already in progress.");
}
@@ -44,9 +43,9 @@ public class ReloadService : IReloadService
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue)
_logger.LogWarning(LogEvents.ReloadNonZeroExit, "Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
else if (result.ExitCode == 0)
_logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded");
_logger.LogInformation("Reload command succeeded");
return new ReloadResult(
result.ExitCode == 0,
@@ -56,7 +55,7 @@ public class ReloadService : IReloadService
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ReloadFailed, ex, "Failed to run reload command");
_logger.LogError(ex, "Failed to run reload command");
return new ReloadResult(false, -1, null, ex.Message);
}
finally
@@ -69,4 +69,25 @@ public static class SettingsModalSections
if (!All.TryGetValue(key, out var meta)) return false;
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Collapsible group: display name and the section keys it contains.</summary>
public sealed record CollapsibleGroup(string Id, string DisplayName, string[] SectionKeys);
/// <summary>Groups for the collapsible settings UI. Order determines display order.</summary>
public static readonly IReadOnlyList<CollapsibleGroup> Groups =
[
new CollapsibleGroup("service", "Service status", [ServiceStatus]),
new CollapsibleGroup("dnsmasq-logs", "Dnsmasq logs", [Logs, RecentLogsDisplay]),
new CollapsibleGroup("app-logs", "App logs", [AppLogs, AppLogsDisplay]),
new CollapsibleGroup("leases", "DHCP leases", [Leases]),
];
/// <summary>Whether a group should be shown: for context mode, any child section matches; for All+search, any child matches search.</summary>
public static bool GroupMatchesContextOrSearch(CollapsibleGroup group, SettingsModalContext context, string searchTerm)
{
var keys = GetSectionKeysForContext(context);
if (keys != null)
return group.SectionKeys.Any(k => keys.Contains(k, StringComparer.OrdinalIgnoreCase));
return group.SectionKeys.Any(k => MatchesSearch(k, searchTerm));
}
}