feat: implement JSON backup export functionality with improved download handling
All checks were successful
Publish Container / publish (push) Successful in 3m25s
All checks were successful
Publish Container / publish (push) Successful in 3m25s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
e8bbae0496
commit
bf333c4a00
7 changed files with 96 additions and 5 deletions
|
|
@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
Program.cs
23
Program.cs
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": "*"
|
||||||
|
|
|
||||||
13
nlog.config
13
nlog.config
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue