mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
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:
@@ -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: |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
+38
-5
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user