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 \ docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \ -v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 \ 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 - name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -40,7 +40,7 @@ jobs:
- name: Publish for ${{ matrix.rid }} (musl on host) - name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' 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 - name: Zip publish output
run: | run: |
+21 -4
View File
@@ -59,14 +59,17 @@ default_rid() {
esac 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 while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-h|-?|--help) -h|-?|--help)
echo "Usage: $0 [OPTIONS] [RID]" echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
echo "" echo ""
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script" 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 "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 ""
echo "Options:" echo "Options:"
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)" echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
*) *)
RID="$1" RID="$1"
shift shift
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
VERSION_OVERRIDE="$1"
shift
fi
break break
;; ;;
esac esac
@@ -148,19 +155,29 @@ if [ "$CLEAN" = true ]; then
dotnet clean "$PROJECT" -c Release -nologo -v q dotnet clean "$PROJECT" -c Release -nologo -v q
fi 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 if [ "$TRIM" = true ]; then
echo "Publishing self-contained (trimmed, folder) for $RID..." echo "Publishing self-contained (trimmed, folder) for $RID..."
dotnet publish "$PROJECT" \ dotnet publish "$PROJECT" \
-c Release \ -c Release \
-r "$RID" \ -r "$RID" \
--self-contained true \ --self-contained true \
-p:PublishTrimmed=true -p:PublishTrimmed=true \
$VERSION_ARGS
else else
echo "Publishing self-contained (no trim, folder) for $RID..." echo "Publishing self-contained (no trim, folder) for $RID..."
dotnet publish "$PROJECT" \ dotnet publish "$PROJECT" \
-c Release \ -c Release \
-r "$RID" \ -r "$RID" \
--self-contained true --self-contained true \
$VERSION_ARGS
fi fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish" 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 @inject IOptions<ApplicationOptions> AppOptions
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -6,16 +7,16 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" /> <base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" /> <link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" /> <link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css?v=@_cacheBust" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" /> <link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" /> <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" /> <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" /> <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" /> <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" /> <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" /> <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
<title>@_appTitle</title> <title>@_appTitle</title>
<HeadOutlet /> <HeadOutlet />
</head> </head>
@@ -27,5 +28,7 @@
</html> </html>
@code { @code {
private static readonly string _cacheBust = typeof(App).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
private string? _appTitle => AppOptions?.Value?.EffectiveTitle; private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
} }
@@ -29,6 +29,8 @@ main.main {
} }
.sidebar { .sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #052767 0%, #3a0647 70%); background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
} }
@@ -1,4 +1,5 @@
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService @inject ISettingsModalService SettingsModalService
@@ -35,9 +36,27 @@
</NavLink> </NavLink>
</div> </div>
</nav> </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> </div>
@code { @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 string? AppTitle => AppOptions?.Value?.EffectiveTitle;
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings"); private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
@@ -131,13 +131,29 @@
color: white; 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 { .nav-scrollable {
display: none; display: none;
flex-direction: column;
} }
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */ /* Toggler is inside top-row; nav-scrollable is sibling of top-row */
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable { .top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
display: block; display: flex;
} }
@media (min-width: 641px) { @media (min-width: 641px) {
@@ -146,11 +162,10 @@
} }
.nav-scrollable { .nav-scrollable {
/* Never collapse the sidebar for wide screens */ /* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
display: block; display: flex;
flex: 1;
/* Allow sidebar to scroll for tall menus */ min-height: 0;
height: calc(100vh - 3.5rem);
overflow-y: auto; overflow-y: auto;
} }
} }
@@ -33,77 +33,86 @@
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" /> @bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div> </div>
} }
@if (ShouldShowSection(SettingsModalSections.ServiceStatus)) @foreach (var group in GetVisibleGroups())
{ {
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus]; var isExpanded = IsGroupExpanded(group);
<section class="mb-4"> <div class="settings-group">
<h6 class="text-muted">@meta.DisplayName</h6> <button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
<label for="status-interval" class="form-label">@meta.FieldLabel</label> @onclick="() => ToggleGroup(group.Id)"
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1" aria-expanded="@isExpanded"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" /> aria-controls="group-@group.Id">
</section> <span class="settings-group-title">@group.DisplayName</span>
} <span class="settings-group-chevron" aria-hidden="true"></span>
@if (ShouldShowSection(SettingsModalSections.Logs)) </button>
{ <div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
var meta = SettingsModalSections.All[SettingsModalSections.Logs]; role="region" aria-label="@group.DisplayName">
<section class="mb-4"> @foreach (var key in group.SectionKeys)
<h6 class="text-muted">@meta.DisplayName</h6> {
<label for="logs-interval" class="form-label">@meta.FieldLabel</label> @if (ShouldShowSection(key))
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1" {
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" /> <div class="settings-section">
</section> @switch (key)
} {
@if (ShouldShowSection(SettingsModalSections.AppLogs)) case SettingsModalSections.ServiceStatus:
{ {
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs]; var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4"> <label for="status-interval" class="form-label">@meta.FieldLabel</label>
<h6 class="text-muted">@meta.DisplayName</h6> <input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label> @bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1" break;
@bind="_editingSettings.AppLogsPollingIntervalSeconds" /> }
</section> case SettingsModalSections.Logs:
} {
@if (ShouldShowSection(SettingsModalSections.Leases)) var meta = SettingsModalSections.All[SettingsModalSections.Logs];
{ <label for="logs-interval" class="form-label">@meta.FieldLabel</label>
var meta = SettingsModalSections.All[SettingsModalSections.Leases]; <input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
<section class="mb-4"> @bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
<h6 class="text-muted">@meta.DisplayName</h6> break;
<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" case SettingsModalSections.AppLogs:
@bind="_editingSettings.LeasesPollingIntervalSeconds" /> {
</section> var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
} <label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay)) <input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
{ @bind="_editingSettings.AppLogsPollingIntervalSeconds" />
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay]; break;
<section class="mb-4"> }
<h6 class="text-muted">@meta.DisplayName</h6> case SettingsModalSections.Leases:
<div class="mb-2"> {
<label for="recent-logs-max-lines" class="form-label">Max lines</label> var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1" <label for="leases-interval" class="form-label">@meta.FieldLabel</label>
@bind="_editingSettings.RecentLogsMaxLines" /> <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>
<div class="form-check"> </div>
<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"> <div class="modal-footer">
@@ -18,6 +18,7 @@ public partial class SettingsModal : IAsyncDisposable
private ClientSettings _editingSettings = new(); private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new(); private List<string> _validationErrors = new();
private string _searchTerm = string.Empty; private string _searchTerm = string.Empty;
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
private bool _moduleLoaded; private bool _moduleLoaded;
private bool _dialogInitialized; private bool _dialogInitialized;
private bool _wasVisible; private bool _wasVisible;
@@ -40,6 +41,10 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened) if (justOpened)
{ {
_searchTerm = string.Empty; _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(); _validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync(); _editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings _editingSettings = new ClientSettings
@@ -84,6 +89,26 @@ public partial class SettingsModal : IAsyncDisposable
private void FilterSettings() => StateHasChanged(); 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) private bool ShouldShowSection(string key)
{ {
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext); var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
@@ -33,3 +33,80 @@
border-top: 1px solid rgba(0, 0, 0, 0.125); border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem; 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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
@@ -38,7 +37,7 @@ public class ConfigController : ControllerBase
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -55,7 +54,7 @@ public class ConfigController : ControllerBase
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -70,12 +69,12 @@ public class ConfigController : ControllerBase
{ {
await _configService.WriteManagedConfigAsync(lines, ct); await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(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 } }); return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
@@ -33,7 +32,7 @@ public class DhcpController : ControllerBase
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -47,17 +46,17 @@ public class DhcpController : ControllerBase
{ {
await _configService.WriteDhcpHostsAsync(entries, ct); await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(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)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (ArgumentException ex) 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 }); return BadRequest(new { error = ex.Message });
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Models.Hosts;
@@ -35,7 +34,7 @@ public class HostsController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed"); _logger.LogError(ex, "Get hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -52,7 +51,7 @@ public class HostsController : ControllerBase
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -66,17 +65,17 @@ public class HostsController : ControllerBase
{ {
await _hostsService.WriteAsync(entries, ct); await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(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)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (InvalidOperationException ex) 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 }); return BadRequest(new { error = ex.Message });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed"); _logger.LogError(ex, "Put hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -38,7 +37,7 @@ public class LeasesController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed"); _logger.LogError(ex, "Get leases failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -43,19 +43,19 @@ public class LoggingController : ControllerBase
var level = ParseLevel(request?.LogLevel); var level = ParseLevel(request?.LogLevel);
if (level == null) 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." }); return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
} }
var path = GetOverridesPath(); var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null)) 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." }); return StatusCode(500, new { error = "Failed to write overrides file." });
} }
_configurationRoot.Reload(); _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))); return Ok(new LogLevelResponse(LevelName(level.Value)));
} }
@@ -75,12 +75,12 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath(); var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes)) 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." }); return StatusCode(500, new { error = "Failed to write overrides file." });
} }
_configurationRoot.Reload(); _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)); return Ok(new FiltersResponse(prefixes));
} }
@@ -91,13 +91,13 @@ public class LoggingController : ControllerBase
var path = GetOverridesPath(); var path = GetOverridesPath();
if (!TryRemoveFilterOverrides(path)) 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." }); return StatusCode(500, new { error = "Failed to update overrides file." });
} }
_configurationRoot.Reload(); _configurationRoot.Reload();
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration); 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)); return Ok(new FiltersResponse(defaults));
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -24,12 +23,12 @@ public class ReloadController : ControllerBase
try try
{ {
var result = await _reloadService.ReloadAsync(ct); 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); return Ok(result);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed"); _logger.LogError(ex, "Reload request failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
@@ -104,7 +103,7 @@ public class StatusController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed"); _logger.LogError(ex, "Get status failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -162,7 +161,7 @@ public class StatusController : ControllerBase
} }
catch (Exception ex) 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 }); return StatusCode(500, new { error = ex.Message });
} }
} }
+1
View File
@@ -3,6 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.0.4</Version>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). --> <!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression> <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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services; 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 public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
{ {
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger; 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; _logger = logger;
_env = env;
_dnsmasq = dnsmasq.Value;
_startedAt = DateTimeOffset.UtcNow;
} }
public Task StartAsync(CancellationToken cancellationToken) 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; return Task.CompletedTask;
} }
public Task StopAsync(CancellationToken cancellationToken) 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; return Task.CompletedTask;
} }
} }
@@ -1,6 +1,5 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers; using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
@@ -36,7 +35,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var snapshot = await _configSetCache.GetSnapshotAsync(ct); var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath)) 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>(); return Array.Empty<DhcpHostEntry>();
} }
var allEntries = snapshot.DhcpHostEntries.ToList(); var allEntries = snapshot.DhcpHostEntries.ToList();
@@ -151,7 +150,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp)); _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> /// <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); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath)); _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 System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers; using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
@@ -81,7 +80,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
lines.Add(""); lines.Add("");
lines.Add(confFileLine); lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken); 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 else
{ {
@@ -93,7 +92,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
if (File.Exists(set.ManagedFilePath)) if (File.Exists(set.ManagedFilePath))
return; 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); await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
} }
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers; using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Models.Hosts;
@@ -41,6 +40,6 @@ public class HostsFileService : IHostsFileService
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct); await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries); _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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public class LeasesFileService : ILeasesFileService
{ {
var (available, entries) = await _cache.GetOrRefreshAsync(ct); var (available, entries) = await _cache.GetOrRefreshAsync(ct);
if (available && entries != null) 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); return (available, entries);
} }
} }
@@ -1,5 +1,4 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Logs; using DnsmasqWebUI.Models.Logs;
@@ -77,7 +76,7 @@ public sealed class LogsService : ILogsService
} }
catch (Exception ex) 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); await PushChunkedAsync("DnsmasqLogsUpdate", "replace", $"(Error: {ex.Message})\n", ct);
} }
} }
@@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -26,7 +25,7 @@ public sealed class ProcessRunner : IProcessRunner
var trimmed = command!.Trim(); var trimmed = command!.Trim();
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "..."; 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 stdout = new StringBuilder();
var stderr = new StringBuilder(); var stderr = new StringBuilder();
@@ -83,7 +82,7 @@ public sealed class ProcessRunner : IProcessRunner
} }
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) 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 */ } try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString(); var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n"; if (!string.IsNullOrEmpty(err)) err += "\n";
@@ -92,7 +91,7 @@ public sealed class ProcessRunner : IProcessRunner
} }
var exitCode = process.HasExited ? process.ExitCode : -1; 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( return new ProcessRunResult(
exitCode, exitCode,
stdout.ToString(), stdout.ToString(),
@@ -101,7 +100,7 @@ public sealed class ProcessRunner : IProcessRunner
} }
catch (Exception ex) 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); return new ProcessRunResult(null, "", "", false, ex.Message);
} }
} }
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -24,13 +23,13 @@ public class ReloadService : IReloadService
{ {
if (string.IsNullOrWhiteSpace(_options.ReloadCommand)) 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"); return new ReloadResult(true, 0, null, "Reload command not configured");
} }
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct)) 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."); 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; stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue) 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) else if (result.ExitCode == 0)
_logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded"); _logger.LogInformation("Reload command succeeded");
return new ReloadResult( return new ReloadResult(
result.ExitCode == 0, result.ExitCode == 0,
@@ -56,7 +55,7 @@ public class ReloadService : IReloadService
} }
catch (Exception ex) 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); return new ReloadResult(false, -1, null, ex.Message);
} }
finally finally
@@ -69,4 +69,25 @@ public static class SettingsModalSections
if (!All.TryGetValue(key, out var meta)) return false; if (!All.TryGetValue(key, out var meta)) return false;
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase); 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));
}
} }