diff --git a/.gitignore b/.gitignore
index 9c379d8..484e56c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,9 @@ artifacts/
# Data (dev files - optional: track sample or ignore)
# Data/
+# Test harness: copy of testdata used as container mount (do not edit repo testdata)
+testdata-mount/
+
# IDE
.idea/
.vscode/
diff --git a/Dockerfile.dnsmasq b/Dockerfile.dnsmasq
index be057b1..3bd1632 100644
--- a/Dockerfile.dnsmasq
+++ b/Dockerfile.dnsmasq
@@ -23,7 +23,7 @@ ENV ASPNETCORE_URLS=http://+:8080
COPY --from=publish /app/publish .
FROM app AS final
-RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq \
+RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index ef8fea8..0818242 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -1,8 +1,9 @@
# Run the app with real dnsmasq and a DHCP client so hosts/leases are real.
-# Build from repo root: docker compose -f docker-compose.test.yml up --build
+# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
+# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
#
# app builds from Dockerfile.dnsmasq (app + dnsmasq in one container; app is main process).
-# ReloadCommand/StatusCommand work because dnsmasq runs in the same container.
+# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq.
#
# Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50.
services:
@@ -12,15 +13,17 @@ services:
dockerfile: Dockerfile.dnsmasq
ports:
- "8080:8080"
+ cap_add:
+ - NET_ADMIN
environment:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__HostsPath: /data/hosts
Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf
Dnsmasq__LeasesPath: /data/leases
- Dnsmasq__ReloadCommand: "killall -HUP dnsmasq"
+ Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
volumes:
- - ./testdata:/data
+ - ${TESTDATA_MOUNT:-./testdata-mount}:/data
networks:
testnet:
ipv4_address: 172.28.0.2
diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh
new file mode 100755
index 0000000..9152d3a
--- /dev/null
+++ b/scripts/prepare-test-mount.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env sh
+# Prepare testdata-mount from testdata (or --source) and optionally run the Docker test harness.
+# testdata/leases is excluded so dnsmasq creates the real leases file in the container.
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+COMPOSE_FILE="docker-compose.test.yml"
+
+SOURCE_DIR=""
+MOUNT_DIR=""
+PREPARE_ONLY=false
+NO_BUILD=false
+RECREATE=false
+
+usage() {
+ echo "Usage: $0 [OPTIONS] [--]"
+ echo ""
+ echo "Copy test data into the mount directory (default: testdata -> testdata-mount),"
+ echo "then run 'docker compose -f $COMPOSE_FILE up' unless --prepare-only is set."
+ echo ""
+ echo "Options:"
+ echo " -h, --help Show this help"
+ echo " --source DIR Source directory to copy from (default: testdata)"
+ echo " --mount DIR Target mount directory (default: testdata-mount)"
+ echo " --prepare-only Only copy data; do not run docker compose"
+ echo " --no-build Run 'docker compose up' without --build (use existing images)"
+ echo " --recreate Pass --force-recreate to docker compose up"
+ echo ""
+ echo "Examples:"
+ echo " $0 # Prepare from testdata, then up --build"
+ echo " $0 --no-build # Prepare, then up without rebuilding"
+ echo " $0 --prepare-only # Only sync testdata -> testdata-mount"
+ echo " $0 --source myfixtures --mount mymount --prepare-only"
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ --source)
+ shift
+ [ $# -gt 0 ] || { echo "Error: --source requires DIR" >&2; exit 1; }
+ SOURCE_DIR="$1"
+ shift
+ ;;
+ --mount)
+ shift
+ [ $# -gt 0 ] || { echo "Error: --mount requires DIR" >&2; exit 1; }
+ MOUNT_DIR="$1"
+ shift
+ ;;
+ --prepare-only)
+ PREPARE_ONLY=true
+ shift
+ ;;
+ --no-build)
+ NO_BUILD=true
+ shift
+ ;;
+ --recreate)
+ RECREATE=true
+ shift
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ echo "Error: unknown option $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+cd "$REPO_ROOT"
+
+: "${SOURCE_DIR:=testdata}"
+: "${MOUNT_DIR:=testdata-mount}"
+
+if [ ! -d "$SOURCE_DIR" ]; then
+ echo "Error: source directory '$SOURCE_DIR' does not exist" >&2
+ exit 1
+fi
+
+mkdir -p "$MOUNT_DIR"
+
+if command -v rsync >/dev/null 2>&1; then
+ rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/"
+else
+ find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
+ cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
+ rm -f "$MOUNT_DIR/leases"
+fi
+
+echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)."
+
+if [ "$PREPARE_ONLY" = true ]; then
+ echo "Run manually: docker compose -f $COMPOSE_FILE up --build"
+ exit 0
+fi
+
+# Compose uses TESTDATA_MOUNT for the data volume (default: ./testdata-mount)
+export TESTDATA_MOUNT="./$MOUNT_DIR"
+
+COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
+if [ "$NO_BUILD" = true ]; then
+ COMPOSE_CMD="$COMPOSE_CMD"
+else
+ COMPOSE_CMD="$COMPOSE_CMD --build"
+fi
+if [ "$RECREATE" = true ]; then
+ COMPOSE_CMD="$COMPOSE_CMD --force-recreate"
+fi
+
+echo "Running: $COMPOSE_CMD"
+exec $COMPOSE_CMD
diff --git a/src/DnsmasqWebUI/Components/Pages/Leases.razor b/src/DnsmasqWebUI/Components/Pages/Leases.razor
index d0c568d..0be4e08 100644
--- a/src/DnsmasqWebUI/Components/Pages/Leases.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Leases.razor
@@ -47,7 +47,7 @@ else
}
-
Path: @_status.leasesPath (read-only). Data is read from disk on each request; use Refresh to refetch.
+ Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.
}
}
diff --git a/src/DnsmasqWebUI/DnsmasqWebUI.csproj b/src/DnsmasqWebUI/DnsmasqWebUI.csproj
index 4b38974..bf03929 100644
--- a/src/DnsmasqWebUI/DnsmasqWebUI.csproj
+++ b/src/DnsmasqWebUI/DnsmasqWebUI.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
index 0643985..902aa14 100644
--- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
+++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
@@ -6,24 +6,40 @@ namespace DnsmasqWebUI.Extensions;
public static class ServiceCollectionExtensions
{
+ delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
+
+ static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
+ [
+ (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
+ (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
+ ];
+
///
- /// Scans the assembly for types implementing
- /// and registers each interface → implementation as scoped.
+ /// Scans the assembly for types implementing application marker interfaces
+ /// (, , etc.)
+ /// and registers each interface → implementation with the configured lifetime.
/// Skips open generics; requires exactly one public implementation per interface.
///
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
+ foreach (var (markerInterface, register) in ApplicationRegistrations)
+ ScanAndRegister(services, markerInterface, register);
+ return services;
+ }
+
+ static void ScanAndRegister(
+ IServiceCollection services,
+ Type markerInterface,
+ RegisterService register)
+ {
var assembly = Assembly.GetExecutingAssembly();
- var marker = typeof(IApplicationScopedService);
- // Only closed (non-generic) interfaces; open generics need typeof(IRepo<>, Repo<>) and are not handled here.
var serviceInterfaces = assembly.GetTypes()
- .Where(t => t.IsInterface && t.IsPublic && t != marker && !t.IsGenericTypeDefinition && marker.IsAssignableFrom(t))
+ .Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t))
.ToList();
foreach (var iface in serviceInterfaces)
{
- // Public, concrete, non-abstract, closed (no open generic classes).
var implementations = assembly.GetTypes()
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && iface.IsAssignableFrom(t))
.ToList();
@@ -35,9 +51,7 @@ public static class ServiceCollectionExtensions
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
"Register one explicitly or exclude the others from the scan.");
- services.AddScoped(iface, implementations[0]);
+ register(services, iface, implementations[0]);
}
-
- return services;
}
}
diff --git a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs b/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs
index e89fb33..0935646 100644
--- a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs
@@ -1,6 +1,8 @@
using System.Text.RegularExpressions;
using DnsmasqWebUI.Models;
-using Sprache;
+using Superpower;
+using Superpower.Model;
+using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
@@ -13,31 +15,35 @@ public static class DhcpHostParser
{
private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled);
- // Optional ## or # at start
- private static readonly Parser<(bool isComment, bool isDeleted)> Prefix =
- Parse.Char('#').Repeat(1, 2).Optional().Select(o =>
- {
- if (!o.IsDefined) return (false, false);
- var s = string.Concat(o.Get());
- return (true, s.Length == 2);
- });
+ // Allow optional whitespace around a parser
+ private static TextParser Token(TextParser parser) =>
+ Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
+ Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
+
+ // Optional ## or # at start (Try so that single # backtracks and we can match one #)
+ private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix =
+ Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try()
+ .Or(Character.EqualTo('#').Select(_ => (true, false)))
+ .OptionalOrDefault((false, false));
// Literal "dhcp-host=" (consumed, value discarded)
- private static readonly Parser> DhcpHostTag =
- Parse.String("dhcp-host=").Token();
+ private static readonly TextParser DhcpHostTag =
+ Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value);
// One field: no comma, no # (stops at next comma or trailing comment)
- private static readonly Parser Field =
- Parse.AnyChar.Where(c => c != ',' && c != '#').AtLeastOnce().Text().Token();
+ private static readonly TextParser Field =
+ Character.Matching(c => c != ',' && c != '#', "field").AtLeastOnce().Text().Then(s =>
+ Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s)));
- // Comma-delimited fields (Sprache DelimitedBy), then optional trailing # comment
- private static readonly Parser<(List fields, string? comment)> FieldsAndComment =
- from fields in Field.DelimitedBy(Parse.Char(',').Token()).Select(l => l.ToList())
- from comment in Parse.Char('#').Token().Then(_ => Parse.AnyChar.Many().Text()).Optional()
- select (fields, comment.IsDefined ? comment.Get().Trim() : null);
+ // Comma-delimited fields, then optional # comment
+ private static readonly TextParser<(List fields, string? comment)> FieldsAndComment =
+ from fields in Field.AtLeastOnceDelimitedBy(Token(Character.EqualTo(',')))
+ from comment in Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text())
+ .Select(s => (string?)s).OptionalOrDefault(null)
+ select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim());
// Full line: optional ##/# prefix, "dhcp-host=", comma-separated fields, optional # comment
- private static readonly Parser<(bool isComment, bool isDeleted, List fields, string? comment)> LineParser =
+ private static readonly TextParser<(bool isComment, bool isDeleted, List fields, string? comment)> LineParser =
from prefix in Prefix
from _ in DhcpHostTag
from fc in FieldsAndComment
@@ -52,7 +58,7 @@ public static class DhcpHostParser
return null;
var result = LineParser.TryParse(remain);
- if (!result.WasSuccessful)
+ if (!result.HasValue)
return null;
var (isComment, isDeleted, fields, comment) = result.Value;
diff --git a/src/DnsmasqWebUI/Parsers/HostsParser.cs b/src/DnsmasqWebUI/Parsers/HostsParser.cs
index 240d06f..6a98ee5 100644
--- a/src/DnsmasqWebUI/Parsers/HostsParser.cs
+++ b/src/DnsmasqWebUI/Parsers/HostsParser.cs
@@ -1,5 +1,7 @@
using DnsmasqWebUI.Models;
-using Sprache;
+using Superpower;
+using Superpower.Model;
+using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
@@ -9,26 +11,32 @@ namespace DnsmasqWebUI.Parsers;
///
public static class HostsParser
{
+ // Allow optional whitespace around a parser (Superpower has no built-in Token for text parsers)
+ private static TextParser Token(TextParser parser) =>
+ Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
+ Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
+
// Token: non-whitespace, non-# (stops at inline comment)
- private static readonly Parser Token =
- Parse.AnyChar.Where(c => !char.IsWhiteSpace(c) && c != '#').AtLeastOnce().Text();
+ private static readonly TextParser TokenField =
+ Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token")
+ .AtLeastOnce().Text();
// Optional leading # (comment line); when present, rest of line is comment text, not data
- private static readonly Parser OptionalComment =
- Parse.Char('#').Token().Optional().Select(o => o.IsDefined);
+ private static readonly TextParser OptionalComment =
+ Token(Character.EqualTo('#')).Select(_ => true).OptionalOrDefault(false);
// Content: address (first token) then one or more names (tokens). Per hosts(5): IP then canonical name [aliases...].
- private static readonly Parser<(string address, List names)> Content =
- from address in Token
- from _ in Parse.WhiteSpace.AtLeastOnce()
- from names in Token.AtLeastOnce()
+ private static readonly TextParser<(string address, List names)> Content =
+ from address in TokenField
+ from _ in Span.WhiteSpace
+ from names in TokenField.AtLeastOnce()
select (address, names.ToList());
// Full line: if starts with #, treat entire line as comment (do not parse rest as address/names); else parse Content.
- private static readonly Parser<(bool isComment, string address, List names)> LineContent =
+ private static readonly TextParser<(bool isComment, string address, List names)> LineContent =
OptionalComment.Then(hasComment =>
hasComment
- ? Parse.AnyChar.Many().Text().Select(_ => (true, "", new List()))
+ ? Character.AnyChar.Many().Text().Select(_ => (true, "", new List()))
: Content.Select(c => (false, c.address, c.names)));
public static HostEntry? ParseLine(string line, int lineNumber)
@@ -38,7 +46,7 @@ public static class HostsParser
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
var result = LineContent.TryParse(trimmed);
- if (!result.WasSuccessful)
+ if (!result.HasValue)
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
var (isComment, address, names) = result.Value;
diff --git a/src/DnsmasqWebUI/Parsers/LeasesParser.cs b/src/DnsmasqWebUI/Parsers/LeasesParser.cs
index e6b7359..43946e7 100644
--- a/src/DnsmasqWebUI/Parsers/LeasesParser.cs
+++ b/src/DnsmasqWebUI/Parsers/LeasesParser.cs
@@ -1,5 +1,7 @@
using DnsmasqWebUI.Models;
-using Sprache;
+using Superpower;
+using Superpower.Model;
+using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
@@ -20,12 +22,18 @@ namespace DnsmasqWebUI.Parsers;
///
public static class LeasesParser
{
- // Non-whitespace token (MAC, IP, hostname, or * for unknown)
- private static readonly Parser Field =
- Parse.AnyChar.Where(c => !char.IsWhiteSpace(c)).AtLeastOnce().Text().Token();
+ // Allow optional whitespace around a parser
+ private static TextParser Token(TextParser parser) =>
+ Character.WhiteSpace.Many().IgnoreThen(parser).Then(x =>
+ Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x)));
- private static readonly Parser LineParser =
- (from epoch in Parse.Number.Token().Select(s => long.Parse(s))
+ // Non-whitespace token (MAC, IP, hostname, or * for unknown)
+ private static readonly TextParser Field =
+ Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s =>
+ Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s)));
+
+ private static readonly TextParser LineParser =
+ (from epoch in Token(Numerics.IntegerInt64)
from mac in Field
from address in Field
from name in Field
@@ -37,7 +45,7 @@ public static class LeasesParser
Address = address,
Name = name,
ClientId = clientId
- }).End();
+ }).AtEnd();
public static LeaseEntry? ParseLine(string line)
{
@@ -46,6 +54,6 @@ public static class LeasesParser
return null;
var result = LineParser.TryParse(trimmed);
- return result.WasSuccessful ? result.Value : null;
+ return result.HasValue ? result.Value : null;
}
}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs b/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs
new file mode 100644
index 0000000..8c6d485
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs
@@ -0,0 +1,9 @@
+namespace DnsmasqWebUI.Services.Abstractions;
+
+///
+/// Marker interface for application services that are registered as singleton.
+/// Used by assembly scanning for service registration.
+///
+public interface IApplicationSingleton
+{
+}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
new file mode 100644
index 0000000..0075315
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
@@ -0,0 +1,11 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Services.Abstractions;
+
+///
+/// Singleton cache for the leases file; invalidates when the file changes (e.g. via file watcher).
+///
+public interface ILeasesCache : IApplicationSingleton
+{
+ Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs
new file mode 100644
index 0000000..267c67e
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/LeasesCache.cs
@@ -0,0 +1,124 @@
+using System.Text;
+using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Options;
+using DnsmasqWebUI.Parsers;
+using DnsmasqWebUI.Services.Abstractions;
+using Microsoft.Extensions.Options;
+
+namespace DnsmasqWebUI.Services;
+
+///
+/// Singleton cache for the leases file with a so we re-read only when the file changes.
+///
+public sealed class LeasesCache : ILeasesCache, IDisposable
+{
+ private readonly string? _path;
+ private readonly ILogger _logger;
+ private FileSystemWatcher? _watcher;
+ private readonly object _lock = new();
+ private (bool Available, IReadOnlyList? Entries)? _cache;
+ private bool _dirty = true;
+
+ public LeasesCache(IOptions options, ILogger logger)
+ {
+ _path = options.Value.LeasesPath;
+ _logger = logger;
+ if (string.IsNullOrEmpty(_path))
+ {
+ _logger.LogDebug("Leases path not configured; no file watcher");
+ return;
+ }
+ var dir = Path.GetDirectoryName(_path);
+ var fileName = Path.GetFileName(_path);
+ if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName))
+ {
+ _logger.LogDebug("Invalid leases path for watcher: {Path}", _path);
+ return;
+ }
+ try
+ {
+ if (!Directory.Exists(dir))
+ {
+ _logger.LogDebug("Leases directory does not exist yet: {Dir}", dir);
+ return;
+ }
+ _watcher = new FileSystemWatcher(dir)
+ {
+ Filter = fileName,
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size
+ };
+ _watcher.Changed += OnFileChanged;
+ _watcher.Created += OnFileChanged;
+ _watcher.EnableRaisingEvents = true;
+ _logger.LogDebug("Watching leases file: {Path}", _path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not create file watcher for leases: {Path}", _path);
+ }
+ }
+
+ private void OnFileChanged(object sender, FileSystemEventArgs e)
+ {
+ lock (_lock)
+ {
+ _dirty = true;
+ }
+ }
+
+ public (bool Available, IReadOnlyList? Entries) GetOrRefresh(CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(_path))
+ return (false, null);
+
+ lock (_lock)
+ {
+ if (!_dirty && _cache.HasValue)
+ return _cache.Value;
+
+ if (!File.Exists(_path))
+ {
+ _logger.LogDebug("Leases file not found: {Path}", _path);
+ _cache = (true, Array.Empty());
+ _dirty = false;
+ return _cache.Value;
+ }
+ try
+ {
+ var lines = File.ReadAllLines(_path, Encoding.UTF8);
+ var entries = new List();
+ foreach (var line in lines)
+ {
+ var entry = LeasesParser.ParseLine(line);
+ if (entry != null)
+ entries.Add(entry);
+ }
+ _cache = (true, entries);
+ _dirty = false;
+ return _cache.Value;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
+ _cache = (true, null);
+ _dirty = false;
+ return _cache.Value;
+ }
+ }
+ }
+
+ public async Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(_path))
+ return (false, null);
+
+ // Run file I/O on thread pool to avoid blocking
+ return await Task.Run(() => GetOrRefresh(ct), ct);
+ }
+
+ public void Dispose()
+ {
+ _watcher?.Dispose();
+ _watcher = null;
+ }
+}
diff --git a/src/DnsmasqWebUI/Services/LeasesFileService.cs b/src/DnsmasqWebUI/Services/LeasesFileService.cs
index 75b1218..d36e1aa 100644
--- a/src/DnsmasqWebUI/Services/LeasesFileService.cs
+++ b/src/DnsmasqWebUI/Services/LeasesFileService.cs
@@ -1,57 +1,23 @@
-using System.Text;
using DnsmasqWebUI.Models;
-using DnsmasqWebUI.Options;
-using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
-using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
public class LeasesFileService : ILeasesFileService
{
- private readonly string? _path;
- private readonly ILogger _logger;
+ private readonly ILeasesCache _cache;
- public LeasesFileService(IOptions options, ILogger logger)
+ public LeasesFileService(ILeasesCache cache)
{
- _path = options.Value.LeasesPath;
- _logger = logger;
+ _cache = cache;
}
public async Task> ReadAsync(CancellationToken ct = default)
{
- var (available, entries) = await TryReadAsync(ct);
+ var (_, entries) = await _cache.GetOrRefreshAsync(ct);
return entries ?? Array.Empty();
}
public async Task<(bool Available, IReadOnlyList? Entries)> TryReadAsync(CancellationToken ct = default)
- {
- if (string.IsNullOrEmpty(_path))
- {
- _logger.LogDebug("Leases path not configured");
- return (false, null);
- }
- if (!File.Exists(_path))
- {
- _logger.LogDebug("Leases file not found: {Path}", _path);
- return (true, Array.Empty());
- }
- try
- {
- var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct);
- var entries = new List();
- foreach (var line in lines)
- {
- var entry = LeasesParser.ParseLine(line);
- if (entry != null)
- entries.Add(entry);
- }
- return (true, entries);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
- return (true, null);
- }
- }
+ => await _cache.GetOrRefreshAsync(ct);
}
diff --git a/testdata/README.md b/testdata/README.md
index c4314b7..db6f115 100644
--- a/testdata/README.md
+++ b/testdata/README.md
@@ -1,10 +1,15 @@
-# Test data for docker-compose.test.yml
+# Test data
+
+Used in two ways:
+
+1. **Unit tests** – Parsers and services read from `testdata/` (e.g. `testdata/leases` for lease-format tests).
+2. **Docker test harness** – Run `scripts/prepare-test-mount.sh` to copy this into `testdata-mount/` (gitignored). Compose mounts `testdata-mount` as `/data`. **leases** is excluded from the copy so dnsmasq creates and owns the real leases file in the harness.
+
+Files:
- **hosts** – Shared by app and dnsmasq (read/write by app, `addn-hosts` for dnsmasq).
- **dnsmasq.conf** – Sample main config (reference only; not used by the test containers).
- **dnsmasq-test.conf** – Config for dnsmasq when run inside the app container (DHCP-only, paths under `/data`).
- **dnsmasq.d/dhcp.conf** – DHCP static hosts; app edits this, dnsmasq loads it via `conf-dir`.
- **dnsmasq.d/01-other.conf** – Other snippet (not managed by app).
-- **leases** – Empty at start. dnsmasq creates and populates it when the dhcp-client container gets a lease. App reads it (read-only).
-
-All of this is bind-mounted as `/data` in the **app** container; the app runs as the main process and dnsmasq runs in the background in the same container.
+- **leases** – For **unit tests only**. In the Docker harness, dnsmasq creates `/data/leases`; the app watches it and shows live leases.