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();