diff --git a/Server/API/ServerLogsController.cs b/Server/API/ServerLogsController.cs index 3c1a68524..f154096e5 100644 --- a/Server/API/ServerLogsController.cs +++ b/Server/API/ServerLogsController.cs @@ -14,7 +14,6 @@ namespace Remotely.Server.API [ApiController] public class ServerLogsController : ControllerBase { - private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; private readonly ILogsManager _logsManager; private readonly ILogger _logger; diff --git a/Server/Components/LoaderHarness.razor b/Server/Components/LoaderHarness.razor new file mode 100644 index 000000000..efdd347d2 --- /dev/null +++ b/Server/Components/LoaderHarness.razor @@ -0,0 +1,29 @@ +@using Nihs.SimpleMessenger; +@using Remotely.Server.Models.Messages; +@inject IMessenger Messenger + +@if (_loaderShown) +{ + +} + +@code { + private bool _loaderShown; + private string _statusMessage = string.Empty; + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + Messenger.Register(this, HandleShowLoaderMessage); + } + return base.OnAfterRenderAsync(firstRender); + } + + private async Task HandleShowLoaderMessage(ShowLoaderMessage message) + { + _loaderShown = message.IsShown; + _statusMessage = message.StatusMessage; + await InvokeAsync(StateHasChanged); + } +} diff --git a/Server/Components/LoadingSignal.razor b/Server/Components/LoadingSignal.razor index b3d6fe9ae..a05168c18 100644 --- a/Server/Components/LoadingSignal.razor +++ b/Server/Components/LoadingSignal.razor @@ -1,21 +1,31 @@ @inject IClientAppState AppState - -@if (_theme == Theme.Dark) -{ -
-} -else if (_theme == Theme.Light) -{ -
-} +
+
+ @if (!string.IsNullOrEmpty(StatusMessage)) + { +

+ @StatusMessage +

+ } +
+
+
@code { private Theme _theme; + [Parameter] + public string StatusMessage { get; set; } = string.Empty; + protected override async Task OnInitializedAsync() { - await base.OnInitializedAsync(); _theme = await AppState.GetEffectiveTheme(); + await base.OnInitializedAsync(); + } + + private string GetSignalClass() + { + return _theme == Theme.Dark ? "signal-dark" : "signal-light"; } } \ No newline at end of file diff --git a/Server/Components/LoadingSignal.razor.css b/Server/Components/LoadingSignal.razor.css index 74692f8e7..ee7452f0a 100644 --- a/Server/Components/LoadingSignal.razor.css +++ b/Server/Components/LoadingSignal.razor.css @@ -1,18 +1,42 @@ -.signal { +.signal-background { position: fixed; - top: 45vh; - left: calc(50% - 25px); + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background-color: rgba(0,0,0,0.5); + z-index: 1000; +} + +.signal-wrapper { + position: absolute; + display: inline-block; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.signal { + display: inline-block; border-width: 8px; border-style: solid; + margin-top: 10px; border-radius: 100%; height: 50px; width: 50px; opacity: 0; - position: absolute; + transform-origin: center; animation: pulsate .85s ease-out; animation-iteration-count: infinite; } + .signal.signal-dark { + border-color: whitesmoke; + } + .signal.signal-light { + border-color: #333; + } + @keyframes pulsate { 0% { transform: scale(.1); diff --git a/Server/Components/ModalHarness.razor b/Server/Components/ModalHarness.razor index cb079d2b8..910aa2f6a 100644 --- a/Server/Components/ModalHarness.razor +++ b/Server/Components/ModalHarness.razor @@ -49,8 +49,8 @@ { _displayStyle = "block"; await InvokeAsync(StateHasChanged); - // The fade animation won't work without a delay here. - await Task.Delay(100); + // The fade animation won't work without a delay here. + await Task.Delay(100); _showClass = "show"; await InvokeAsync(StateHasChanged); }; diff --git a/Server/Data/AppDb.cs b/Server/Data/AppDb.cs index 9062bc3d1..39a0446de 100644 --- a/Server/Data/AppDb.cs +++ b/Server/Data/AppDb.cs @@ -57,9 +57,6 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(x => x.RemotelyUsers) .WithOne(x => x.Organization); - builder.Entity() - .HasMany(x => x.EventLogs) - .WithOne(x => x.Organization); builder.Entity() .HasMany(x => x.DeviceGroups) .WithOne(x => x.Organization); diff --git a/Server/Models/Messages/ShowLoaderMessage.cs b/Server/Models/Messages/ShowLoaderMessage.cs new file mode 100644 index 000000000..94537209d --- /dev/null +++ b/Server/Models/Messages/ShowLoaderMessage.cs @@ -0,0 +1,13 @@ +namespace Remotely.Server.Models.Messages; + +public class ShowLoaderMessage +{ + public ShowLoaderMessage(bool isShown, string statusMessage) + { + IsShown = isShown; + StatusMessage = statusMessage; + } + + public bool IsShown { get; } + public string StatusMessage { get; } +} \ No newline at end of file diff --git a/Server/Pages/ServerLogs.razor b/Server/Pages/ServerLogs.razor index 9cdb91dbd..538a1177f 100644 --- a/Server/Pages/ServerLogs.razor +++ b/Server/Pages/ServerLogs.razor @@ -1,4 +1,5 @@ @page "/server-logs" +@using System.Collections.ObjectModel; @attribute [Authorize] @inherits AuthComponentBase @@ -6,6 +7,7 @@ @inject IToastService ToastService @inject IJsInterop JsInterop @inject ILogsManager LogsManager +@inject ILoaderService LoaderService

Server Logs

@@ -35,9 +37,9 @@
Type:
- - @foreach (var eventType in Enum.GetValues(typeof(EventType))) + @foreach (var eventType in Enum.GetValues(typeof(LogLevel))) { } @@ -46,51 +48,23 @@
Filter:
- +
From:
- +
To:
- +
- - - - - - - - - - - - @foreach (var eventLog in FilteredLogs) - { - - - - - - - - } - -
TypeTimestampMessageSourceStack Trace
@eventLog.EventType@eventLog.TimeStamp - @if (!string.IsNullOrWhiteSpace(eventLog.Message)) - { - foreach (var line in eventLog.Message.Split("\n", StringSplitOptions.RemoveEmptyEntries)) - { -
@line
- } - } -
@eventLog.Source@eventLog.StackTrace
+ } else { @@ -98,35 +72,104 @@ else } @code { - private readonly List _filteredLogs = new(); - private EventType? _eventType; - private string _messageFilter; + private readonly string _messageDebounceKey = Guid.NewGuid().ToString(); + private readonly List _filteredLogs = new(); + private LogLevel? _logLevel; + private string _messageFilter = string.Empty; + private DateTimeOffset _fromDate = DateTimeOffset.Now.AddDays(-7); private DateTimeOffset _toDate = DateTimeOffset.Now; + private DateTimeOffset FromDate + { + get => _fromDate; + set + { + if (value > _toDate) + { + ToastService.ShowToast("Invalid date range.", classString: "bg-warning"); + return; + } + _fromDate = value; + _ = RefreshLogs(); + } + } + private LogLevel? LogLevel + { + get => _logLevel; + set + { + _logLevel = value; + _ = RefreshLogs(); + } + } - private IEnumerable FilteredLogs + private string MessageFilter { - get + get => _messageFilter; + set { - return Enumerable.Empty(); - //return DataService.GetEventLogs(User.UserName, - // _fromDate, - // _toDate, - // _eventType, - // _messageFilter); + _messageFilter = value; + + Debouncer.Debounce( + TimeSpan.FromSeconds(1), + () => _ = RefreshLogs(), + _messageDebounceKey); } } + private DateTimeOffset ToDate + { + get => _toDate; + set + { + if (value < _fromDate) + { + ToastService.ShowToast("Invalid date range.", classString: "bg-warning"); + return; + } + _toDate = value; + _ = RefreshLogs(); + } + } + + protected override async Task OnInitializedAsync() + { + await RefreshLogs(); + await base.OnInitializedAsync(); + } + private async Task ClearAllLogs() { var result = await JsInterop.Confirm("Are you sure you want to delete all logs?"); if (result) { - await LogsManager.DeleteLogs(); + using (var _ = LoaderService.ShowLoader("Deleting logs")) + { + await LogsManager.DeleteLogs(); + } + await RefreshLogs(); ToastService.ShowToast("Logs deleted."); } } + private async Task RefreshLogs() + { + using var _ = await LoaderService.ShowLoader("Refreshing logs"); + _filteredLogs.Clear(); + + var logsAsync = LogsManager.GetLogs( + _fromDate, + _toDate, + _messageFilter, + _logLevel); + + await foreach (var line in logsAsync) + { + _filteredLogs.Add(line); + } + + await InvokeAsync(StateHasChanged); + } } diff --git a/Server/Pages/ServerLogs.razor.css b/Server/Pages/ServerLogs.razor.css index 2aa1e48be..e01c6ec16 100644 --- a/Server/Pages/ServerLogs.razor.css +++ b/Server/Pages/ServerLogs.razor.css @@ -4,13 +4,18 @@ grid-column-gap: 2em; } - .filters-row { display: grid; grid-template-columns: auto auto auto 1fr; grid-column-gap: 1em; } +.logs-content { + width: 100%; + white-space: pre; + height: 500px; +} + @media (max-width: 641px) { .buttons-row { diff --git a/Server/Program.cs b/Server/Program.cs index 70b221eba..b1138051e 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -36,6 +36,7 @@ using System; using Immense.RemoteControl.Server.Services; using Serilog; +using Nihs.SimpleMessenger; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; @@ -43,8 +44,6 @@ ConfigureSerilog(builder); -builder.Host.UseSerilog(); - builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); if (OperatingSystem.IsWindows() && @@ -190,13 +189,14 @@ services.AddScoped(); services.AddScoped(); services.AddScoped(); -services.AddHostedService(); +services.AddHostedService(); services.AddHostedService(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); +services.AddScoped(); services.AddScoped(x => (CircuitHandler)x.GetRequiredService()); services.AddSingleton(); services.AddScoped(); @@ -206,6 +206,7 @@ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(LogsManager.Default); +services.AddSingleton(WeakReferenceMessenger.Default); services.AddRemoteControlServer(config => { @@ -336,9 +337,11 @@ void ApplySharedLoggerConfig(LoggerConfiguration loggerConfiguration) { loggerConfiguration .Enrich.FromLogContext() - .MinimumLevel.Debug() .WriteTo.Console() - .WriteTo.File($"{logPath}/Remotely_Server.log", rollingInterval: RollingInterval.Day, retainedFileTimeLimit: TimeSpan.FromDays(dataRetentionDays)); + .WriteTo.File($"{logPath}/Remotely_Server.log", + rollingInterval: RollingInterval.Day, + retainedFileTimeLimit: TimeSpan.FromDays(dataRetentionDays), + shared: true); } var loggerConfig = new LoggerConfiguration(); diff --git a/Server/Server.csproj b/Server/Server.csproj index 118ac63e6..df99d5f6f 100644 --- a/Server/Server.csproj +++ b/Server/Server.csproj @@ -31,6 +31,7 @@ + diff --git a/Server/Services/DataCleanupService.cs b/Server/Services/DataCleanupService.cs new file mode 100644 index 000000000..2cad79993 --- /dev/null +++ b/Server/Services/DataCleanupService.cs @@ -0,0 +1,62 @@ +using Microsoft.Build.Framework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Remotely.Server.Services +{ + public class DataCleanupService : IHostedService, IDisposable + { + private readonly ILogger _logger; + + private readonly IServiceProvider _services; + + private System.Timers.Timer _cleanupTimer = new(TimeSpan.FromDays(1)); + + + public DataCleanupService( + IServiceProvider serviceProvider, + ILogger logger) + { + _services = serviceProvider; + _logger = logger; + + _cleanupTimer.Elapsed += CleanupTimer_Elapsed; + } + public void Dispose() + { + _cleanupTimer?.Dispose(); + GC.SuppressFinalize(this); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cleanupTimer.Start(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _cleanupTimer.Stop(); + _cleanupTimer.Dispose(); + return Task.CompletedTask; + } + + private void CleanupTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) + { + try + { + using var scope = _services.CreateScope(); + var dataService = scope.ServiceProvider.GetRequiredService(); + dataService.CleanupOldRecords(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during data cleanup."); + } + } + } +} diff --git a/Server/Services/DbCleanupService.cs b/Server/Services/DbCleanupService.cs deleted file mode 100644 index c186fa841..000000000 --- a/Server/Services/DbCleanupService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Remotely.Server.Services -{ - public class DbCleanupService : IHostedService, IDisposable - { - public DbCleanupService(IServiceProvider serviceProvider) - { - Services = serviceProvider; - } - - private IServiceProvider Services { get; } - private System.Timers.Timer CleanupTimer { get; set; } - - public void Dispose() - { - CleanupTimer?.Dispose(); - GC.SuppressFinalize(this); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - CleanupTimer?.Dispose(); - CleanupTimer = new System.Timers.Timer(TimeSpan.FromDays(1).TotalMilliseconds); - CleanupTimer.Elapsed += CleanupTimer_Elapsed; - CleanupTimer.Start(); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - CleanupTimer?.Dispose(); - return Task.CompletedTask; - } - - private void CleanupTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) - { - using var scope = Services.CreateScope(); - var dataService = scope.ServiceProvider.GetRequiredService(); - - dataService.CleanupOldRecords(); - } - } -} diff --git a/Server/Services/LoaderService.cs b/Server/Services/LoaderService.cs new file mode 100644 index 000000000..3222eef1d --- /dev/null +++ b/Server/Services/LoaderService.cs @@ -0,0 +1,34 @@ +using Nihs.SimpleMessenger; +using Remotely.Server.Models.Messages; +using Remotely.Shared.Primitives; +using System; +using System.Threading.Tasks; + +namespace Remotely.Server.Services; + +public interface ILoaderService +{ + Task ShowLoader(string statusMessage); + void HideLoader(); +} + +public class LoaderService : ILoaderService +{ + private readonly IMessenger _messenger; + + public LoaderService(IMessenger messenger) + { + _messenger = messenger; + } + + public async Task ShowLoader(string statusMessage) + { + await _messenger.Send(new ShowLoaderMessage(true, statusMessage)); + return new CallbackDisposable(HideLoader); + } + + public void HideLoader() + { + _messenger.Send(new ShowLoaderMessage(false, string.Empty)); + } +} \ No newline at end of file diff --git a/Server/Services/LogsManager.cs b/Server/Services/LogsManager.cs index 2fd9854e7..e69f33c21 100644 --- a/Server/Services/LogsManager.cs +++ b/Server/Services/LogsManager.cs @@ -1,9 +1,16 @@ -using Remotely.Shared.Extensions; +using Microsoft.Extensions.Logging; +using Remotely.Shared.Extensions; using Serilog; using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; using System.Threading.Tasks; namespace Remotely.Server.Services @@ -13,6 +20,11 @@ public interface ILogsManager string GetLogsDirectory(); Task ZipAllLogs(); Task DeleteLogs(); + IAsyncEnumerable GetLogs( + DateTimeOffset startDate, + DateTimeOffset endDate, + string messageFilter, + LogLevel? logLevelFilter); } public class LogsManager : ILogsManager @@ -72,9 +84,123 @@ public async Task DeleteLogs() } catch (Exception ex) { - Console.WriteLine("Failed to delete log file: {filename}. Message: {exMessage}", file, ex.Message); + Console.WriteLine($"Failed to delete log file: {file}. Message: {ex.Message}"); + try + { + Console.WriteLine("Attempting to zero out log contents."); + using var fs = File.Open(file, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + fs.SetLength(0); + } + catch (Exception ex2) + { + Console.WriteLine($"Failed to clear log contents: {file}. Message: {ex2.Message}"); + } } } } + + public async IAsyncEnumerable GetLogs( + DateTimeOffset startDate, + DateTimeOffset endDate, + string messageFilter, + LogLevel? logLevelFilter) + { + var fromDate = startDate.UtcDateTime.Date; + var toDate = endDate.UtcDateTime.Date.AddDays(1); + + var result = new StringBuilder(); + var logsDir = GetLogsDirectory(); + + var files = Directory + .GetFiles(logsDir) + .Select(x => new FileInfo(x)) + .Where(x => + x.LastWriteTimeUtc >= fromDate && + x.LastWriteTimeUtc <= toDate); + + foreach (var file in files) + { + var linesAsync = GetLines(file, messageFilter, logLevelFilter); + await foreach (var line in linesAsync) + { + yield return line; + } + + } + } + + private async IAsyncEnumerable GetLines( + FileInfo file, + string messageFilter, + LogLevel? logLevelFilter) + { + LogLevel? currentLogLevel = null; + + using var fs = File.Open(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var sr = new StreamReader(fs); + + while (true) + { + var currentLine = await sr.ReadLineAsync(); + + if (currentLine is null) + { + break; + } + + if (logLevelFilter is not null) + { + if (TryGetLogLevel(currentLine, out var parsedLevel)) + { + currentLogLevel = parsedLevel; + } + + if (currentLogLevel != logLevelFilter) + { + continue; + } + } + + if (!string.IsNullOrWhiteSpace(messageFilter) && + !currentLine.Contains(messageFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + yield return currentLine; + } + } + + private bool TryGetLogLevel( + string lineContent, + [NotNullWhen(true)] out LogLevel? logLevel) + { + try + { + var logLevelTag = lineContent[31..36]; + if (_logLevelMap.TryGetValue(logLevelTag, out var result)) + { + logLevel = result; + return true; + } + } + catch + { + // Ignored. + } + + logLevel = default; + return false; + } + + private static readonly ReadOnlyDictionary _logLevelMap = new(new Dictionary() + { + ["[VRB]"] = LogLevel.Trace, + ["[DBG]"] = LogLevel.Debug, + ["[INF]"] = LogLevel.Information, + ["[WRN]"] = LogLevel.Warning, + ["[ERR]"] = LogLevel.Error, + ["[FTL]"] = LogLevel.Critical + }); } } diff --git a/Server/Services/ScriptScheduleDispatcher.cs b/Server/Services/ScriptScheduleDispatcher.cs index 3b71e450f..7705820dc 100644 --- a/Server/Services/ScriptScheduleDispatcher.cs +++ b/Server/Services/ScriptScheduleDispatcher.cs @@ -38,13 +38,13 @@ public async Task DispatchPendingScriptRuns() { try { - _logger.LogInformation("Script Schedule Dispatcher started."); + _logger.LogDebug("Script Schedule Dispatcher started."); var schedules = await _dataService.GetScriptSchedulesDue(); if (schedules?.Any() != true) { - _logger.LogInformation("No schedules are due."); + _logger.LogDebug("No schedules are due."); return; } @@ -52,18 +52,18 @@ public async Task DispatchPendingScriptRuns() { try { - _logger.LogInformation("Considering {scheduleName}. Interval: {interval}. Next Run: {nextRun}.", + _logger.LogDebug("Considering {scheduleName}. Interval: {interval}. Next Run: {nextRun}.", schedule.Name, schedule.Interval, schedule.NextRun); if (!AdvanceSchedule(schedule)) { - _logger.LogInformation("Schedule is not due."); + _logger.LogDebug("Schedule is not due."); continue; } - _logger.LogInformation("Creating script run for schedule {scheduleName}.", schedule.Name); + _logger.LogDebug("Creating script run for schedule {scheduleName}.", schedule.Name); var scriptRun = new ScriptRun() { @@ -100,7 +100,7 @@ public async Task DispatchPendingScriptRuns() await _circuitConnection.RunScript(onlineDevices, schedule.SavedScriptId, scriptRun.Id, ScriptInputType.ScheduledScript, true); - _logger.LogInformation("Created script run for schedule {scheduleName}.", schedule.Name); + _logger.LogDebug("Created script run for schedule {scheduleName}.", schedule.Name); schedule.LastRun = Time.Now; await _dataService.AddOrUpdateScriptSchedule(schedule); diff --git a/Server/Shared/MainLayout.razor b/Server/Shared/MainLayout.razor index 9f69fb50f..412c06635 100644 --- a/Server/Shared/MainLayout.razor +++ b/Server/Shared/MainLayout.razor @@ -16,7 +16,7 @@ - + diff --git a/Server/Shared/NavMenu.razor b/Server/Shared/NavMenu.razor index a14fe9a4f..e56e0b4bb 100644 --- a/Server/Shared/NavMenu.razor +++ b/Server/Shared/NavMenu.razor @@ -83,14 +83,14 @@ API Keys - @if (_user?.IsServerAdmin == true) { +