Skip to content

Commit 678bc2f

Browse files
authored
Merge pull request #47 from nootey/feat/database-backups
Simple database backups
2 parents 1cb877b + 1a1ba19 commit 678bc2f

File tree

13 files changed

+724
-10
lines changed

13 files changed

+724
-10
lines changed

build/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ COPY pkg/ ./pkg
2929
# Build main binary
3030
RUN --mount=type=cache,target="/root/.cache/go-build" \
3131
go build \
32-
-ldflags="-X 'main.Version=${VERSION}' -X 'main.CommitSHA=${COMMIT_SHA}' -X 'main.BuildTime=${BUILD_TIME}'" \
32+
-ldflags="-X 'wealth-warden/pkg/version.Version=${VERSION}' -X 'wealth-warden/pkg/version.CommitSHA=${COMMIT_SHA}' -X 'wealth-warden/pkg/version.BuildTime=${BUILD_TIME}'" \
3333
-o ./server ./cmd
3434

3535
# Stage 2: Runtime
3636
FROM alpine AS runtime
3737

3838
WORKDIR /app
3939

40-
RUN apk add --no-cache curl
40+
RUN apk add --no-cache curl postgresql-client
4141

4242
# Copy only what's needed at runtime
4343
COPY --from=builder /app/server /usr/local/bin/server
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<script setup lang="ts">
2+
import { computed, onMounted, ref } from "vue";
3+
import type { Column } from "../../../services/filter_registry.ts";
4+
import LoadingSpinner from "../base/LoadingSpinner.vue";
5+
import dateHelper from "../../../utils/date_helper.ts";
6+
import { useSettingsStore } from "../../../services/stores/settings_store.ts";
7+
import { useToastStore } from "../../../services/stores/toast_store.ts";
8+
import { usePermissions } from "../../../utils/use_permissions.ts";
9+
import { useConfirm } from "primevue/useconfirm";
10+
import type { BackupInfo } from "../../../models/dataio_models.ts";
11+
import { useAuthStore } from "../../../services/stores/auth_store.ts";
12+
13+
const settingsStore = useSettingsStore();
14+
const toastStore = useToastStore();
15+
const authStore = useAuthStore();
16+
17+
const { hasPermission } = usePermissions();
18+
const confirm = useConfirm();
19+
20+
const backups = ref<BackupInfo[]>([]);
21+
const loading = ref(false);
22+
23+
onMounted(async () => {
24+
await getData();
25+
});
26+
27+
async function getData() {
28+
try {
29+
loading.value = true;
30+
const response = await settingsStore.getBackups();
31+
backups.value = response.data.backups || [];
32+
} catch (e) {
33+
toastStore.errorResponseToast(e);
34+
} finally {
35+
loading.value = false;
36+
}
37+
}
38+
39+
function refresh() {
40+
getData();
41+
}
42+
43+
defineExpose({ refresh });
44+
45+
const activeColumns = computed<Column[]>(() => [
46+
{ field: "name", header: "Name" },
47+
{ field: "metadata.app_version", header: "App ver.", hideOnMobile: true },
48+
{ field: "metadata.db_version", header: "DB ver.", hideOnMobile: true },
49+
{ field: "metadata.backup_size", header: "Size" },
50+
{ field: "metadata.created_at", header: "Created" },
51+
]);
52+
53+
function formatBytes(bytes: number): string {
54+
if (bytes === 0) return "0 Bytes";
55+
const k = 1024;
56+
const sizes = ["Bytes", "KB", "MB", "GB"];
57+
const i = Math.floor(Math.log(bytes) / Math.log(k));
58+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
59+
}
60+
61+
function getNestedValue(obj: any, path: string): any {
62+
return path.split(".").reduce((current, key) => current?.[key], obj);
63+
}
64+
65+
async function restoreConfirmation(backupName: string) {
66+
confirm.require({
67+
header: "Restore database?",
68+
message: `This will restore the database from backup: "${backupName}". All current data will be replaced. This action cannot be undone.`,
69+
icon: "pi pi-exclamation-triangle",
70+
acceptLabel: "Restore",
71+
rejectLabel: "Cancel",
72+
acceptClass: "p-button-danger",
73+
rejectClass: "p-button-text",
74+
accept: () => restoreBackup(backupName),
75+
});
76+
}
77+
78+
async function restoreBackup(backupName: string) {
79+
if (!hasPermission("manage_data")) {
80+
toastStore.createInfoToast(
81+
"Access denied",
82+
"You don't have permission to perform this action.",
83+
);
84+
return;
85+
}
86+
87+
try {
88+
loading.value = true;
89+
await settingsStore.restoreFromDatabaseDump(backupName);
90+
toastStore.successResponseToast({
91+
title: "Success",
92+
message: "Database restored successfully",
93+
});
94+
authStore.logout();
95+
} catch (error) {
96+
toastStore.errorResponseToast(error);
97+
} finally {
98+
loading.value = false;
99+
}
100+
}
101+
102+
async function downloadBackup(backupName: string) {
103+
if (!hasPermission("manage_data")) {
104+
toastStore.createInfoToast(
105+
"Access denied",
106+
"You don't have permission to perform this action.",
107+
);
108+
return;
109+
}
110+
111+
try {
112+
loading.value = true;
113+
await settingsStore.downloadBackup(backupName);
114+
toastStore.createInfoToast(
115+
"Success",
116+
"Backup downloaded successfully.",
117+
);
118+
} catch (error) {
119+
toastStore.errorResponseToast(error);
120+
} finally {
121+
loading.value = false;
122+
}
123+
}
124+
</script>
125+
126+
<template>
127+
<div class="w-full flex flex-row gap-2 justify-content-center">
128+
<DataTable
129+
data-key="name"
130+
class="w-full enhanced-table"
131+
:loading="loading"
132+
:value="backups"
133+
scrollable
134+
scroll-height="50vh"
135+
column-resize-mode="fit"
136+
scroll-direction="both"
137+
>
138+
<template #empty>
139+
<div style="padding: 10px">No backups found.</div>
140+
</template>
141+
<template #loading>
142+
<LoadingSpinner />
143+
</template>
144+
<Column header="Actions">
145+
<template #body="{ data }">
146+
<div class="flex flex-row align-items-center gap-2">
147+
<i
148+
v-if="hasPermission('manage_data')"
149+
v-tooltip.top="'Download'"
150+
class="pi pi-download hover-icon"
151+
style="font-size: 0.875rem"
152+
@click="downloadBackup(data?.name)"
153+
/>
154+
<i
155+
v-if="hasPermission('manage_data')"
156+
v-tooltip.top="'Restore'"
157+
class="pi pi-refresh hover-icon"
158+
style="font-size: 0.875rem; color: var(--p-orange-300)"
159+
@click="restoreConfirmation(data?.name)"
160+
/>
161+
</div>
162+
</template>
163+
</Column>
164+
<Column
165+
v-for="col of activeColumns"
166+
:key="col.field"
167+
:header="col.header"
168+
:field="col.field"
169+
:header-class="col.hideOnMobile ? 'mobile-hide ' : ''"
170+
:body-class="col.hideOnMobile ? 'mobile-hide ' : ''"
171+
>
172+
<template #body="{ data }">
173+
<template v-if="col.field === 'metadata.created_at'">
174+
{{ dateHelper.formatDate(data.metadata.created_at, true) }}
175+
</template>
176+
<template v-else-if="col.field === 'metadata.backup_size'">
177+
{{ formatBytes(data.metadata.backup_size) }}
178+
</template>
179+
<template v-else>
180+
{{ getNestedValue(data, col.field) }}
181+
</template>
182+
</template>
183+
</Column>
184+
</DataTable>
185+
</div>
186+
</template>
187+
188+
<style scoped></style>

client/src/_vue/pages/Settings/DataSettings.vue

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ import { useToastStore } from "../../../services/stores/toast_store.ts";
77
import ExportModule from "../../features/imports/ExportModule.vue";
88
import ExportList from "../../components/data/ExportList.vue";
99
import ImportModule from "../../components/data/ImportModule.vue";
10+
import { useSettingsStore } from "../../../services/stores/settings_store.ts";
11+
import BackupList from "../../components/data/BackupList.vue";
1012
1113
const toastStore = useToastStore();
1214
const { hasPermission } = usePermissions();
1315
1416
const importListRef = ref<InstanceType<typeof ImportList> | null>(null);
1517
const exportListRef = ref<InstanceType<typeof ExportList> | null>(null);
1618
const importModuleRef = ref<InstanceType<typeof ImportModule> | null>(null);
19+
const backupListRef = ref<InstanceType<typeof BackupList> | null>(null);
1720
1821
const addImportModal = ref(false);
1922
const addExportModal = ref(false);
2023
const transferModal = ref(false);
2124
25+
const settingsStore = useSettingsStore();
26+
2227
function refreshData(module: string) {
2328
switch (module) {
2429
case "import": {
@@ -32,13 +37,17 @@ function refreshData(module: string) {
3237
exportListRef.value?.refresh();
3338
break;
3439
}
40+
case "backup": {
41+
backupListRef.value?.refresh();
42+
break;
43+
}
3544
default: {
3645
break;
3746
}
3847
}
3948
}
4049
41-
function manipulateDialog(modal: string, value: any) {
50+
async function manipulateDialog(modal: string, value: any) {
4251
switch (modal) {
4352
case "addImport": {
4453
if (!hasPermission("manage_data")) {
@@ -62,6 +71,19 @@ function manipulateDialog(modal: string, value: any) {
6271
addExportModal.value = value;
6372
break;
6473
}
74+
case "createDatabaseDump": {
75+
try {
76+
await settingsStore.createDatabaseDump();
77+
toastStore.successResponseToast({
78+
title: "Success",
79+
message: "Database backup created successfully",
80+
});
81+
refreshData("backup");
82+
} catch (e) {
83+
toastStore.errorResponseToast(e);
84+
}
85+
break;
86+
}
6587
default: {
6688
break;
6789
}
@@ -150,6 +172,32 @@ function manipulateDialog(modal: string, value: any) {
150172
<ExportList ref="exportListRef" />
151173
</div>
152174
</SettingsSkeleton>
175+
176+
<SettingsSkeleton class="w-full">
177+
<div class="w-full flex flex-column gap-3 p-2">
178+
<div class="flex flex-row align-items-center gap-2 w-full">
179+
<div class="w-full flex flex-column gap-2">
180+
<h3>Database backups</h3>
181+
<h5 style="color: var(--text-secondary)">
182+
Manage database backups and restores.
183+
</h5>
184+
</div>
185+
<Button
186+
class="main-button"
187+
@click="manipulateDialog('createDatabaseDump', null)"
188+
>
189+
<div class="flex flex-row gap-1 align-items-center">
190+
<i class="pi pi-plus" />
191+
<span> New </span>
192+
<span class="mobile-hide"> Backup </span>
193+
</div>
194+
</Button>
195+
</div>
196+
197+
<h3>Backups</h3>
198+
<BackupList ref="backupListRef" />
199+
</div>
200+
</SettingsSkeleton>
153201
</div>
154202
</template>
155203

client/src/models/dataio_models.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,17 @@ export type Export = {
4040
started_at: Date;
4141
completed_at: Date | null;
4242
};
43+
44+
export interface BackupInfo {
45+
name: string;
46+
metadata: BackupMetadata;
47+
}
48+
49+
export interface BackupMetadata {
50+
app_version: string;
51+
commit_sha: string;
52+
build_time: string;
53+
db_version: number;
54+
created_at: string;
55+
backup_size: number;
56+
}

client/src/services/stores/settings_store.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,40 @@ export const useSettingsStore = defineStore("settings", {
2424
async updateProfileSettings(settings: object) {
2525
return await apiClient.put(`${this.apiPrefix}/users/profile`, settings);
2626
},
27+
28+
async getBackups() {
29+
return await apiClient.get(`${this.apiPrefix}/backups`);
30+
},
31+
32+
async createDatabaseDump() {
33+
const res = await apiClient.post(`${this.apiPrefix}/backups/create`);
34+
return res.data;
35+
},
36+
37+
async restoreFromDatabaseDump(backup_name: string) {
38+
const res = await apiClient.post(`${this.apiPrefix}/backups/restore`, {
39+
backup_name: backup_name,
40+
});
41+
return res.data;
42+
},
43+
44+
async downloadBackup(backup_name: string) {
45+
const res = await apiClient.post(
46+
`${this.apiPrefix}/backups/download`,
47+
{ backup_name: backup_name },
48+
{
49+
responseType: "blob",
50+
},
51+
);
52+
53+
const blob = new Blob([res.data], { type: "application/zip" });
54+
const url = window.URL.createObjectURL(blob);
55+
const a = document.createElement("a");
56+
a.href = url;
57+
a.download = `${backup_name}.zip`;
58+
document.body.appendChild(a);
59+
a.click();
60+
a.remove();
61+
}
2762
},
2863
});

cmd/root.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,20 @@ import (
44
"fmt"
55
"wealth-warden/pkg/config"
66
logging "wealth-warden/pkg/logger"
7+
"wealth-warden/pkg/version"
78

89
"github.com/spf13/cobra"
910
"go.uber.org/zap"
1011
)
1112

1213
var (
13-
// Version info (injected at build time via ldflags)
14-
Version = "dev"
15-
CommitSHA = "unknown"
16-
BuildTime = "unknown"
17-
1814
cfg *config.Config
1915
logger *zap.Logger
2016
)
2117
var rootCmd = &cobra.Command{
2218
Use: "wealth-warden",
2319
Short: "WealthWarden server",
24-
Version: fmt.Sprintf("%s (commit: %s, built: %s)", Version, CommitSHA, BuildTime),
20+
Version: fmt.Sprintf("%s (commit: %s, built: %s)", version.Version, version.CommitSHA, version.BuildTime),
2521
}
2622

2723
func init() {

internal/bootstrap/container.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func NewContainer(cfg *config.Config, db *gorm.DB, logger *zap.Logger) (*Contain
7272
userService := services.NewUserService(userRepo, roleRepo, loggingRepo, jobDispatcher, mail)
7373
accountService := services.NewAccountService(accountRepo, transactionRepo, settingsRepo, loggingRepo, jobDispatcher, currencyConverter)
7474
transactionService := services.NewTransactionService(transactionRepo, accountRepo, settingsRepo, loggingRepo, jobDispatcher, currencyConverter)
75-
settingsService := services.NewSettingsService(settingsRepo, userRepo, loggingRepo, jobDispatcher)
75+
settingsService := services.NewSettingsService(cfg, logger.Named("settings_serv"), settingsRepo, userRepo, loggingRepo, jobDispatcher)
7676
chartingService := services.NewChartingService(chartingRepo, accountRepo, transactionRepo, statsRepo)
7777
statsService := services.NewStatisticsService(statsRepo, accountRepo, transactionRepo, settingsRepo)
7878
importService := services.NewImportService(importRepo, transactionRepo, accountRepo, investmentRepo, settingsRepo, loggingRepo, jobDispatcher)

0 commit comments

Comments
 (0)