diff --git a/DeveLanCacheUI_Backend.Tests/DeveLanCacheUI_Backend.Tests.csproj b/DeveLanCacheUI_Backend.Tests/DeveLanCacheUI_Backend.Tests.csproj index 5e6bf40..8836e6f 100644 --- a/DeveLanCacheUI_Backend.Tests/DeveLanCacheUI_Backend.Tests.csproj +++ b/DeveLanCacheUI_Backend.Tests/DeveLanCacheUI_Backend.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0 enable enable @@ -16,6 +16,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/DeveLanCacheUI_Backend.Tests/LogReading/LogRotationTests.cs b/DeveLanCacheUI_Backend.Tests/LogReading/LogRotationTests.cs new file mode 100644 index 0000000..0ca5d4e --- /dev/null +++ b/DeveLanCacheUI_Backend.Tests/LogReading/LogRotationTests.cs @@ -0,0 +1,193 @@ +using DeveLanCacheUI_Backend.LogReading; +using System.IO.Compression; +using System.Text; +using ZstdNet; + +namespace DeveLanCacheUI_Backend.Tests.LogReading +{ + [TestClass] + public class LogRotationTests + { + private string _tempDirectory = null!; + + [TestInitialize] + public void Setup() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + [TestMethod] + public void GetLogFiles_ReturnsCorrectOrderWithBasicFiles() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + + // Create test files + File.WriteAllText(Path.Combine(_tempDirectory, "access.log"), "current log"); + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.1"), "rotated 1"); + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.2"), "rotated 2"); + + // Act + var result = InvokePrivateMethod>(sut, "GetLogFiles", _tempDirectory); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.IsTrue(result[0].EndsWith("access.log")); + Assert.IsTrue(result[1].EndsWith("access.log.1")); + Assert.IsTrue(result[2].EndsWith("access.log.2")); + } + + [TestMethod] + public void GetLogFiles_ReturnsCorrectOrderWithCompressedFiles() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + + // Create test files + File.WriteAllText(Path.Combine(_tempDirectory, "access.log"), "current log"); + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.1.gz"), "compressed 1"); + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.2.zst"), "compressed 2"); + + // Act + var result = InvokePrivateMethod>(sut, "GetLogFiles", _tempDirectory); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.IsTrue(result[0].EndsWith("access.log")); + Assert.IsTrue(result[1].EndsWith("access.log.1.gz")); + Assert.IsTrue(result[2].EndsWith("access.log.2.zst")); + } + + [TestMethod] + public void GetLogFiles_HandlesGapInNumbers() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + + // Create test files with gaps + File.WriteAllText(Path.Combine(_tempDirectory, "access.log"), "current log"); + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.1"), "rotated 1"); + // Skip access.log.2 + File.WriteAllText(Path.Combine(_tempDirectory, "access.log.3"), "rotated 3"); + + // Act + var result = InvokePrivateMethod>(sut, "GetLogFiles", _tempDirectory); + + // Assert - should stop at the first gap + Assert.AreEqual(2, result.Count); + Assert.IsTrue(result[0].EndsWith("access.log")); + Assert.IsTrue(result[1].EndsWith("access.log.1")); + } + + [TestMethod] + public void OpenLogFileStream_HandlesRegularFile() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + var testFile = Path.Combine(_tempDirectory, "test.log"); + var testContent = "test line 1\ntest line 2\n"; + File.WriteAllText(testFile, testContent); + + // Act + using var stream = InvokePrivateMethod(sut, "OpenLogFileStream", testFile); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + // Assert + Assert.AreEqual(testContent, content); + } + + [TestMethod] + public void OpenLogFileStream_HandlesGzipFile() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + var testFile = Path.Combine(_tempDirectory, "test.log.gz"); + var testContent = "test line 1\ntest line 2\n"; + + // Create gzipped file + using (var fileStream = File.Create(testFile)) + using (var gzipStream = new GZipStream(fileStream, CompressionMode.Compress)) + using (var writer = new StreamWriter(gzipStream)) + { + writer.Write(testContent); + } + + // Act + using var stream = InvokePrivateMethod(sut, "OpenLogFileStream", testFile); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + // Assert + Assert.AreEqual(testContent, content); + } + + [TestMethod] + public void OpenLogFileStream_HandlesZstdFile() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + var testFile = Path.Combine(_tempDirectory, "test.log.zst"); + var testContent = "test line 1\ntest line 2\n"; + + // Create zstd compressed file + using (var compressor = new Compressor()) + { + var originalBytes = Encoding.UTF8.GetBytes(testContent); + var compressedBytes = compressor.Wrap(originalBytes); + File.WriteAllBytes(testFile, compressedBytes); + } + + // Act + using var stream = InvokePrivateMethod(sut, "OpenLogFileStream", testFile); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + // Assert + Assert.AreEqual(testContent, content); + } + + [TestMethod] + public void ReadAllLinesFromStream_ReadsAllLines() + { + // Arrange + var sut = new LanCacheLogReaderHostedService(null!, null!, null!, null!); + var testContent = "line1\nline2\nline3\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); + var cts = new CancellationTokenSource(); + + // Act + var result = InvokePrivateMethod>(sut, "ReadAllLinesFromStream", stream, cts.Token).ToList(); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual("line1", result[0]); + Assert.AreEqual("line2", result[1]); + Assert.AreEqual("line3", result[2]); + } + + /// + /// Helper method to invoke private methods for testing + /// + private T InvokePrivateMethod(object obj, string methodName, params object[] parameters) + { + var type = obj.GetType(); + var method = type.GetMethod(methodName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (method == null) + throw new ArgumentException($"Method {methodName} not found"); + + var result = method.Invoke(obj, parameters); + return (T)result!; + } + } +} \ No newline at end of file diff --git a/DeveLanCacheUI_Backend/Db/DbModels/DbSetting.cs b/DeveLanCacheUI_Backend/Db/DbModels/DbSetting.cs index 33a3dd4..f649757 100644 --- a/DeveLanCacheUI_Backend/Db/DbModels/DbSetting.cs +++ b/DeveLanCacheUI_Backend/Db/DbModels/DbSetting.cs @@ -9,5 +9,6 @@ public class DbSetting public const string SettingKey_DepotVersion = nameof(SettingKey_DepotVersion); public const string SettingKey_SteamChangeNumber = nameof(SettingKey_SteamChangeNumber); public const string SettingKey_TotalBytesRead = nameof(SettingKey_TotalBytesRead); + public const string SettingKey_ProcessedLogFiles = nameof(SettingKey_ProcessedLogFiles); } } diff --git a/DeveLanCacheUI_Backend/DeveLanCacheUI_Backend.csproj b/DeveLanCacheUI_Backend/DeveLanCacheUI_Backend.csproj index af60ba9..639fff8 100644 --- a/DeveLanCacheUI_Backend/DeveLanCacheUI_Backend.csproj +++ b/DeveLanCacheUI_Backend/DeveLanCacheUI_Backend.csproj @@ -1,7 +1,7 @@ - net9.0 + net8.0 enable enable 5be89f33-044f-41d5-b192-06d2df23e484 @@ -22,18 +22,19 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/DeveLanCacheUI_Backend/LogReading/LanCacheLogReaderHostedService.cs b/DeveLanCacheUI_Backend/LogReading/LanCacheLogReaderHostedService.cs index 1c24e89..326e4a6 100644 --- a/DeveLanCacheUI_Backend/LogReading/LanCacheLogReaderHostedService.cs +++ b/DeveLanCacheUI_Backend/LogReading/LanCacheLogReaderHostedService.cs @@ -1,4 +1,6 @@ using DbContext = DeveLanCacheUI_Backend.Db.DeveLanCacheUIDbContext; +using System.IO.Compression; +using ZstdNet; namespace DeveLanCacheUI_Backend.LogReading { @@ -82,7 +84,17 @@ private async Task GoRun(CancellationToken stoppingToken) { throw new NullReferenceException("LanCacheLogsDirectory == null, please ensure the LanCacheLogsDirectory ENVIRONMENT_VARIABLE is filled in"); } + + // Process historical log files first + await ProcessHistoricalLogFiles(logFilePath, oldestLog, stoppingToken); + + // Now tail the current access.log file var accessLogFilePath = Path.Combine(logFilePath, "access.log"); + if (!File.Exists(accessLogFilePath)) + { + _logger.LogWarning("Current access.log file not found: {FilePath}", accessLogFilePath); + return; + } using (var fileStream = new FileStream(accessLogFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { @@ -98,107 +110,65 @@ private async Task GoRun(CancellationToken stoppingToken) currentSet.Count, currentSet.FirstOrDefault()?.DateTime, totalLinesProcessed); totalLinesProcessed += currentSet.Count; + await ProcessLogBatch(currentSet, stoppingToken); + + // Save total bytes read await using (var scope = _services.CreateAsyncScope()) { - var retryPolicy = Policy - .Handle() - .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), - (exception, timeSpan, context) => - { - _logger.LogError("An error occurred while trying to save changes: {Message}", exception.Message); - }); - - await retryPolicy.ExecuteAsync(async () => + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var totalByteReadSetting = await dbContext.Settings.FirstOrDefaultAsync(t => t.Key == DbSetting.SettingKey_TotalBytesRead); + if (totalByteReadSetting == null) { - using var dbContext = scope.ServiceProvider.GetRequiredService(); - - //var filteredLogLines = currentSet.Where(t => t.CacheIdentifier == "steam"); - IEnumerable filteredLogLines = currentSet; - filteredLogLines = filteredLogLines.Where(t => t.CacheIdentifier != "127.0.0.1"); - filteredLogLines = filteredLogLines.Where(t => t.Referer != SkipLogLineReferrerString); - - Dictionary steamAppDownloadEventsCache = new Dictionary(); - - foreach (var lanCacheLogLine in filteredLogLines) - { - if (lanCacheLogLine.CacheIdentifier == "steam" && ExcludedAppIds.Contains(lanCacheLogLine.DownloadIdentifier)) - { - continue; - } - if (lanCacheLogLine.CacheIdentifier == "steam" && lanCacheLogLine.Request.Contains("/manifest/") && DateTime.Now < lanCacheLogLine.DateTime.AddDays(14)) - { - _logger.LogInformation("Found manifest for Depot: {DownloadIdentifier}", lanCacheLogLine.DownloadIdentifier); - var ttt = lanCacheLogLine; - _steamManifestService.TryToDownloadManifest(ttt); - } - - var cacheKey = $"{lanCacheLogLine.CacheIdentifier}_||_{lanCacheLogLine.DownloadIdentifier}_||_{lanCacheLogLine.RemoteAddress}"; - steamAppDownloadEventsCache.TryGetValue(cacheKey, out var cachedEvent); - - if (cachedEvent == null) - { - cachedEvent = await dbContext.DownloadEvents - .FirstOrDefaultAsync(t => - t.CacheIdentifier == lanCacheLogLine.CacheIdentifier && - t.DownloadIdentifierString == lanCacheLogLine.DownloadIdentifier && - t.ClientIp == lanCacheLogLine.RemoteAddress && - t.LastUpdatedAt > lanCacheLogLine.DateTime.AddMinutes(-5) - ); - if (cachedEvent != null) - { - steamAppDownloadEventsCache[cacheKey] = cachedEvent; - } - } - - if (cachedEvent == null || !(cachedEvent.LastUpdatedAt > lanCacheLogLine.DateTime.AddMinutes(-5))) - { - _logger.LogInformation("Adding new event because more than 5 minutes no update: {CacheKey} ({DateTime})", cacheKey, lanCacheLogLine.DateTime); - - uint.TryParse(lanCacheLogLine.DownloadIdentifier, out var downloadIdentifierInt); - cachedEvent = new DbDownloadEvent() - { - CacheIdentifier = lanCacheLogLine.CacheIdentifier, - DownloadIdentifierString = lanCacheLogLine.DownloadIdentifier, - DownloadIdentifier = downloadIdentifierInt, - CreatedAt = lanCacheLogLine.DateTime, - LastUpdatedAt = lanCacheLogLine.DateTime, - ClientIp = lanCacheLogLine.RemoteAddress - }; - steamAppDownloadEventsCache[cacheKey] = cachedEvent; - await dbContext.DownloadEvents.AddAsync(cachedEvent); - } - - cachedEvent.LastUpdatedAt = lanCacheLogLine.DateTime; - if (lanCacheLogLine.UpstreamCacheStatus == "HIT") - { - cachedEvent.CacheHitBytes += lanCacheLogLine.BodyBytesSentLong; - } - else - { - cachedEvent.CacheMissBytes += lanCacheLogLine.BodyBytesSentLong; - } - } - - //Save total bytes read - var totalByteReadSetting = await dbContext.Settings.FirstOrDefaultAsync(t => t.Key == DbSetting.SettingKey_TotalBytesRead); - if (totalByteReadSetting == null) + totalByteReadSetting = new DbSetting() { - totalByteReadSetting = new DbSetting() - { - Key = DbSetting.SettingKey_TotalBytesRead - }; - await dbContext.Settings.AddAsync(totalByteReadSetting); - } - totalByteReadSetting.Value = TotalBytesRead.ToString(); - - await dbContext.SaveChangesAsync(); - FrontendRefresherService.RequireFrontendRefresh(); - }); + Key = DbSetting.SettingKey_TotalBytesRead + }; + await dbContext.Settings.AddAsync(totalByteReadSetting); + } + totalByteReadSetting.Value = TotalBytesRead.ToString(); + await dbContext.SaveChangesAsync(); } } } } + /// + /// Processes historical log files (rotated and compressed) before tailing the current log. + /// + private async Task ProcessHistoricalLogFiles(string logDirectory, DateTime skipOlderThan, CancellationToken stoppingToken) + { + var logFiles = GetLogFiles(logDirectory); + var processedFiles = await GetProcessedLogFiles(); + + // Skip the current access.log file - we'll tail that separately + var historicalFiles = logFiles.Skip(1).ToList(); + + foreach (var logFile in historicalFiles) + { + stoppingToken.ThrowIfCancellationRequested(); + + var fileName = Path.GetFileName(logFile); + if (processedFiles.Contains(fileName)) + { + _logger.LogDebug("Skipping already processed log file: {FileName}", fileName); + continue; + } + + try + { + await ProcessCompleteLogFile(logFile, skipOlderThan, stoppingToken); + + // Mark as processed + processedFiles.Add(fileName); + await SaveProcessedLogFiles(processedFiles); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing log file {LogFile}", logFile); + } + } + } + public IEnumerable> Batch2(IEnumerable collection, int batchSize, DateTime skipOlderThen) { int skipCounter = 0; @@ -248,6 +218,247 @@ public IEnumerable> Batch2(IEnumerable + /// Gets all available log files in order (newest to oldest). + /// Returns access.log first, then access.log.1, access.log.2, etc. + /// + private List GetLogFiles(string logDirectory) + { + var logFiles = new List(); + var baseLogFile = Path.Combine(logDirectory, "access.log"); + + // Add the current log file first + if (File.Exists(baseLogFile)) + { + logFiles.Add(baseLogFile); + } + + // Look for rotated files + for (int i = 1; i <= 100; i++) // Reasonable limit + { + var rotatedFile = Path.Combine(logDirectory, $"access.log.{i}"); + var rotatedGzFile = rotatedFile + ".gz"; + var rotatedZstFile = rotatedFile + ".zst"; + + if (File.Exists(rotatedFile)) + { + logFiles.Add(rotatedFile); + } + else if (File.Exists(rotatedGzFile)) + { + logFiles.Add(rotatedGzFile); + } + else if (File.Exists(rotatedZstFile)) + { + logFiles.Add(rotatedZstFile); + } + else + { + // No more files found, break the loop + break; + } + } + + return logFiles; + } + + /// + /// Opens a stream for a log file, handling compression automatically. + /// + private Stream OpenLogFileStream(string filePath) + { + var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + if (filePath.EndsWith(".gz")) + { + return new GZipStream(fileStream, CompressionMode.Decompress); + } + else if (filePath.EndsWith(".zst")) + { + // For zstd, we need to decompress to memory since ZstdNet doesn't support streaming decompression directly + using (var decompressor = new Decompressor()) + { + var compressedData = new byte[fileStream.Length]; + fileStream.Read(compressedData, 0, compressedData.Length); + fileStream.Close(); + + var decompressedData = decompressor.Unwrap(compressedData); + return new MemoryStream(decompressedData); + } + } + + return fileStream; + } + + /// + /// Gets the set of already processed log files from the database. + /// + private async Task> GetProcessedLogFiles() + { + await using (var scope = _services.CreateAsyncScope()) + { + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var setting = await dbContext.Settings.FirstOrDefaultAsync(t => t.Key == DbSetting.SettingKey_ProcessedLogFiles); + + if (setting?.Value != null) + { + return new HashSet(setting.Value.Split(';', StringSplitOptions.RemoveEmptyEntries)); + } + + return new HashSet(); + } + } + + /// + /// Saves the set of processed log files to the database. + /// + private async Task SaveProcessedLogFiles(HashSet processedFiles) + { + await using (var scope = _services.CreateAsyncScope()) + { + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var setting = await dbContext.Settings.FirstOrDefaultAsync(t => t.Key == DbSetting.SettingKey_ProcessedLogFiles); + + if (setting == null) + { + setting = new DbSetting { Key = DbSetting.SettingKey_ProcessedLogFiles }; + await dbContext.Settings.AddAsync(setting); + } + + setting.Value = string.Join(";", processedFiles); + await dbContext.SaveChangesAsync(); + } + } + + /// + /// Processes a complete log file (for rotated/compressed files). + /// + private async Task ProcessCompleteLogFile(string filePath, DateTime skipOlderThan, CancellationToken stoppingToken) + { + _logger.LogInformation("Processing complete log file: {FilePath}", filePath); + + using var stream = OpenLogFileStream(filePath); + var allLines = ReadAllLinesFromStream(stream, stoppingToken).ToList(); + var parsedLogLines = allLines.Select(line => LanCacheLogLineParser.ParseLogEntry(line)).Where(entry => entry != null).ToList(); + + // Process in batches + for (int i = 0; i < parsedLogLines.Count; i += 5000) + { + var batch = parsedLogLines.Skip(i).Take(5000).ToList(); + var filteredBatch = batch.Where(entry => entry!.DateTime > skipOlderThan).ToList(); + + if (filteredBatch.Any()) + { + await ProcessLogBatch(filteredBatch!, stoppingToken); + } + } + + _logger.LogInformation("Completed processing log file: {FilePath}, processed {Count} lines", filePath, parsedLogLines.Count); + } + + /// + /// Reads all lines from a stream. + /// + private IEnumerable ReadAllLinesFromStream(Stream stream, CancellationToken stoppingToken) + { + using var reader = new StreamReader(stream); + string? line; + while ((line = reader.ReadLine()) != null) + { + stoppingToken.ThrowIfCancellationRequested(); + yield return line; + } + } + + /// + /// Processes a batch of log entries. + /// + private async Task ProcessLogBatch(List logEntries, CancellationToken stoppingToken) + { + await using (var scope = _services.CreateAsyncScope()) + { + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + (exception, timeSpan, context) => + { + _logger.LogError("An error occurred while trying to save changes: {Message}", exception.Message); + }); + + await retryPolicy.ExecuteAsync(async () => + { + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var filteredLogLines = logEntries.AsEnumerable(); + filteredLogLines = filteredLogLines.Where(t => t.CacheIdentifier != "127.0.0.1"); + filteredLogLines = filteredLogLines.Where(t => t.Referer != SkipLogLineReferrerString); + + Dictionary steamAppDownloadEventsCache = new Dictionary(); + + foreach (var lanCacheLogLine in filteredLogLines) + { + if (lanCacheLogLine.CacheIdentifier == "steam" && ExcludedAppIds.Contains(lanCacheLogLine.DownloadIdentifier)) + { + continue; + } + if (lanCacheLogLine.CacheIdentifier == "steam" && lanCacheLogLine.Request.Contains("/manifest/") && DateTime.Now < lanCacheLogLine.DateTime.AddDays(14)) + { + _logger.LogInformation("Found manifest for Depot: {DownloadIdentifier}", lanCacheLogLine.DownloadIdentifier); + var ttt = lanCacheLogLine; + _steamManifestService.TryToDownloadManifest(ttt); + } + + var cacheKey = $"{lanCacheLogLine.CacheIdentifier}_||_{lanCacheLogLine.DownloadIdentifier}_||_{lanCacheLogLine.RemoteAddress}"; + steamAppDownloadEventsCache.TryGetValue(cacheKey, out var cachedEvent); + + if (cachedEvent == null) + { + cachedEvent = await dbContext.DownloadEvents + .FirstOrDefaultAsync(t => + t.CacheIdentifier == lanCacheLogLine.CacheIdentifier && + t.DownloadIdentifierString == lanCacheLogLine.DownloadIdentifier && + t.ClientIp == lanCacheLogLine.RemoteAddress && + t.LastUpdatedAt > lanCacheLogLine.DateTime.AddMinutes(-5) + ); + if (cachedEvent != null) + { + steamAppDownloadEventsCache[cacheKey] = cachedEvent; + } + } + + if (cachedEvent == null || !(cachedEvent.LastUpdatedAt > lanCacheLogLine.DateTime.AddMinutes(-5))) + { + uint.TryParse(lanCacheLogLine.DownloadIdentifier, out var downloadIdentifierInt); + cachedEvent = new DbDownloadEvent() + { + CacheIdentifier = lanCacheLogLine.CacheIdentifier, + DownloadIdentifierString = lanCacheLogLine.DownloadIdentifier, + DownloadIdentifier = downloadIdentifierInt, + CreatedAt = lanCacheLogLine.DateTime, + LastUpdatedAt = lanCacheLogLine.DateTime, + ClientIp = lanCacheLogLine.RemoteAddress + }; + steamAppDownloadEventsCache[cacheKey] = cachedEvent; + await dbContext.DownloadEvents.AddAsync(cachedEvent); + } + + cachedEvent.LastUpdatedAt = lanCacheLogLine.DateTime; + if (lanCacheLogLine.UpstreamCacheStatus == "HIT") + { + cachedEvent.CacheHitBytes += lanCacheLogLine.BodyBytesSentLong; + } + else + { + cachedEvent.CacheMissBytes += lanCacheLogLine.BodyBytesSentLong; + } + } + + await dbContext.SaveChangesAsync(); + FrontendRefresherService.RequireFrontendRefresh(); + }); + } + } + static IEnumerable TailFrom(string file, CancellationToken stoppingToken) { using (var reader = File.OpenText(file))