From bf333c4a00a91600abf06a3b13996340a3441f1b Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 24 Apr 2026 14:16:15 +0200 Subject: [PATCH] feat: implement JSON backup export functionality with improved download handling Co-authored-by: Copilot --- Components/Pages/Settings.razor | 18 +++++++++++++++++- Program.cs | 23 +++++++++++++++++++++++ README.Docker.md | 4 ++++ appsettings.Development.json | 5 +++-- appsettings.json | 5 ++++- nlog.config | 13 ++++++++++++- wwwroot/theme.js | 33 +++++++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 5 deletions(-) diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 64fca6e..25a1987 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -76,7 +76,7 @@ else

Export the full database as JSON or restore a previously exported JSON backup. Restore replaces the current database only when the backup format version and database schema version are supported.

- Export JSON backup + Current database schema version: @DatabaseSchemaVersion
@@ -193,4 +193,20 @@ else isRestoring = false; } } + + private async Task ExportAsync() + { + backupStatusMessage = null; + backupErrorMessage = null; + + try + { + await JS.InvokeVoidAsync("workTrackerDownloads.downloadFromUrl", "/api/database-backup/export"); + backupStatusMessage = $"Backup export started at {DateTime.Now:t}."; + } + catch (JSException) + { + backupErrorMessage = "Unable to start the backup download."; + } + } } diff --git a/Program.cs b/Program.cs index 312d5e6..5d39ee3 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Security.Claims; +using NLog; using NLog.Web; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; @@ -31,12 +32,22 @@ if (!runningInContainer) builder.WebHost.UseStaticWebAssets(); } +var configuredStorageOptions = builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName).Get() ?? new CouchbaseLiteOptions(); +var resolvedDatabaseDirectory = ResolveStorageDirectory(configuredStorageOptions.Directory, builder.Environment.ContentRootPath); +var resolvedLogDirectory = Path.Combine(resolvedDatabaseDirectory, "logs"); +GlobalDiagnosticsContext.Set("worktrackerLogPath", Path.Combine(resolvedLogDirectory, "worktracker.log")); +GlobalDiagnosticsContext.Set("worktrackerArchiveDirectory", Path.Combine(resolvedLogDirectory, "archive")); + builder.Logging.ClearProviders(); builder.Host.UseNLog(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +builder.Services.Configure(options => +{ + options.DetailedErrors = builder.Configuration.GetValue("DetailedErrors", builder.Environment.IsDevelopment()); +}); builder.Services.Configure(builder.Configuration.GetSection(AppAuthOptions.SectionName)); var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get() ?? new AppAuthOptions(); @@ -354,3 +365,15 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode(); app.Run(); + +static string ResolveStorageDirectory(string configuredDirectory, string contentRootPath) +{ + if (string.IsNullOrWhiteSpace(configuredDirectory)) + { + return Path.Combine(contentRootPath, "App_Data", "couchbase"); + } + + return Path.IsPathRooted(configuredDirectory) + ? configuredDirectory + : Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory)); +} diff --git a/README.Docker.md b/README.Docker.md index 58592fe..073a1d9 100644 --- a/README.Docker.md +++ b/README.Docker.md @@ -73,6 +73,10 @@ Docker persistence: - The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container. - The compose file mounts that path from `${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}` on the host. +- NLog also writes `logs/worktracker.log` under that same persisted Couchbase Lite directory, so logs survive container replacement. +- Archived logs are rotated by size, compressed, and old archives are pruned automatically. +- The same log events are still written to container stdout, so `docker logs` remains the primary production inspection path. +- Keep `DetailedErrors=false` in production. That setting is for client-visible Blazor circuit details; production diagnostics should come from `docker logs` and `worktracker.log`, not the browser console. - Set `WORKTRACKER_DATA_PATH` before `docker compose up` if you want to move the database elsewhere. Development in VS Code without Docker: diff --git a/appsettings.Development.json b/appsettings.Development.json index 5a832b8..4f7cffb 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -2,11 +2,12 @@ "CouchbaseLite": { "Directory": "App_Data/couchbase-dev" }, + "DetailedErrors": true, "UseHttpsRedirection": false, "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Debug", + "Microsoft.AspNetCore": "Information" } } } diff --git a/appsettings.json b/appsettings.json index d6fd259..eee0422 100644 --- a/appsettings.json +++ b/appsettings.json @@ -13,10 +13,13 @@ "DefaultUsername": "Admin", "DefaultUserId": "ADMIN" }, + "DetailedErrors": false, "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "WorkTracker": "Debug", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Components.Server.Circuits": "Information" } }, "AllowedHosts": "*" diff --git a/nlog.config b/nlog.config index 119c063..38714a0 100644 --- a/nlog.config +++ b/nlog.config @@ -10,9 +10,20 @@ layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" /> + - + diff --git a/wwwroot/theme.js b/wwwroot/theme.js index 6007550..8371017 100644 --- a/wwwroot/theme.js +++ b/wwwroot/theme.js @@ -86,6 +86,39 @@ window.workTrackerPreferences = { } }; +window.workTrackerDownloads = { + async downloadFromUrl(url) { + const response = await fetch(url, { + credentials: "same-origin" + }); + + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get("content-disposition") || ""; + const fileNameMatch = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(contentDisposition); + const fileName = decodeURIComponent(fileNameMatch?.[1] || fileNameMatch?.[2] || "worktracker-backup.json"); + + const objectUrl = URL.createObjectURL(blob); + + try { + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileName; + anchor.rel = "noopener"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + finally { + URL.revokeObjectURL(objectUrl); + } + } +}; + window.workTrackerDateInput = (() => { const listeners = new WeakMap();