feat: implement JSON backup export functionality with improved download handling
All checks were successful
Publish Container / publish (push) Successful in 3m25s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco 2026-04-24 14:16:15 +02:00
commit bf333c4a00
7 changed files with 96 additions and 5 deletions

View file

@ -76,7 +76,7 @@ else
<p class="text-muted mb-3">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.</p> <p class="text-muted mb-3">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.</p>
<div class="d-flex flex-wrap gap-2 align-items-center mb-4"> <div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<a class="btn btn-outline-secondary" href="/api/database-backup/export">Export JSON backup</a> <button class="btn btn-outline-secondary" type="button" @onclick="ExportAsync">Export JSON backup</button>
<span class="small text-muted">Current database schema version: @DatabaseSchemaVersion</span> <span class="small text-muted">Current database schema version: @DatabaseSchemaVersion</span>
</div> </div>
@ -193,4 +193,20 @@ else
isRestoring = false; 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.";
}
}
} }

View file

@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Security.Claims; using System.Security.Claims;
using NLog;
using NLog.Web; using NLog.Web;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@ -31,12 +32,22 @@ if (!runningInContainer)
builder.WebHost.UseStaticWebAssets(); builder.WebHost.UseStaticWebAssets();
} }
var configuredStorageOptions = builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName).Get<CouchbaseLiteOptions>() ?? 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.Logging.ClearProviders();
builder.Host.UseNLog(); builder.Host.UseNLog();
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DetailedErrors = builder.Configuration.GetValue("DetailedErrors", builder.Environment.IsDevelopment());
});
builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName)); builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName));
var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions(); var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions();
@ -354,3 +365,15 @@ app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
app.Run(); 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));
}

View file

@ -73,6 +73,10 @@ Docker persistence:
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container. - 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. - 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. - Set `WORKTRACKER_DATA_PATH` before `docker compose up` if you want to move the database elsewhere.
Development in VS Code without Docker: Development in VS Code without Docker:

View file

@ -2,11 +2,12 @@
"CouchbaseLite": { "CouchbaseLite": {
"Directory": "App_Data/couchbase-dev" "Directory": "App_Data/couchbase-dev"
}, },
"DetailedErrors": true,
"UseHttpsRedirection": false, "UseHttpsRedirection": false,
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Information"
} }
} }
} }

View file

@ -13,10 +13,13 @@
"DefaultUsername": "Admin", "DefaultUsername": "Admin",
"DefaultUserId": "ADMIN" "DefaultUserId": "ADMIN"
}, },
"DetailedErrors": false,
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "WorkTracker": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Components.Server.Circuits": "Information"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View file

@ -10,9 +10,20 @@
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" /> layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
<target xsi:type="Debugger" name="debug" <target xsi:type="Debugger" name="debug"
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" /> layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
<target xsi:type="File" name="file"
fileName="${gdc:item=worktrackerLogPath}"
createDirs="true"
keepFileOpen="false"
concurrentWrites="true"
archiveFileName="${gdc:item=worktrackerArchiveDirectory}/worktracker.{#}.log"
archiveNumbering="Rolling"
archiveAboveSize="10485760"
enableArchiveFileCompression="true"
maxArchiveFiles="14"
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
</targets> </targets>
<rules> <rules>
<logger name="*" minlevel="Debug" writeTo="console,debug" /> <logger name="*" minlevel="Debug" writeTo="console,debug,file" />
</rules> </rules>
</nlog> </nlog>

View file

@ -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 = (() => { window.workTrackerDateInput = (() => {
const listeners = new WeakMap(); const listeners = new WeakMap();