Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.29.0",
"version": "0.29.1",
"description": "DigiScript front end",
"author": "DreamTeamProd",
"private": true,
Expand Down
4 changes: 4 additions & 0 deletions client/src/assets/styles/dark.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
@import '../../../node_modules/bootswatch/dist/darkly/variables';
@import 'bootstrap/scss/bootstrap';
@import '../../../node_modules/bootswatch/dist/darkly/bootswatch';
// Use white-fill sort icons so they're visible on the dark background
$b-table-sort-icon-bg-not-sorted: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='101' height='101' view-box='0 0 101 101' preserveAspectRatio='none'><path fill='white' opacity='.5' d='M51 1l25 23 24 22H1l25-22zM51 101l25-23 24-22H1l25 22z'/></svg>");
$b-table-sort-icon-bg-ascending: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='101' height='101' view-box='0 0 101 101' preserveAspectRatio='none'><path fill='white' d='M51 1l25 23 24 22H1l25-22z'/><path fill='white' opacity='.5' d='M51 101l25-23 24-22H1l25 22z'/></svg>");
$b-table-sort-icon-bg-descending: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='101' height='101' view-box='0 0 101 101' preserveAspectRatio='none'><path fill='white' opacity='.5' d='M51 1l25 23 24 22H1l25-22z'/><path fill='white' d='M51 101l25-23 24-22H1l25 22z'/></svg>");
@import 'bootstrap-vue/src/index.scss';

:root {
Expand Down
11 changes: 11 additions & 0 deletions client/src/types/api/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface BackupFile {
filename: string;
size_bytes: number;
created_at: number;
}

export interface BackupsResponse {
backups: BackupFile[];
count: number;
total_size_bytes: number;
}
6 changes: 5 additions & 1 deletion client/src/views/config/ConfigView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
<b-tab title="Logs">
<config-logs />
</b-tab>
<b-tab title="Backups">
<config-backups />
</b-tab>
</b-tabs>
</b-col>
</b-row>
Expand All @@ -32,9 +35,10 @@ import ConfigSettings from '@/vue_components/config/ConfigSettings.vue';
import ConfigUsers from '@/vue_components/config/ConfigUsers.vue';
import ConfigShows from '@/vue_components/config/ConfigShows.vue';
import ConfigLogs from '@/vue_components/config/ConfigLogs.vue';
import ConfigBackups from '@/vue_components/config/ConfigBackups.vue';

export default defineComponent({
name: 'ConfigView',
components: { ConfigShows, ConfigUsers, ConfigSettings, ConfigSystem, ConfigLogs },
components: { ConfigShows, ConfigUsers, ConfigSettings, ConfigSystem, ConfigLogs, ConfigBackups },
});
</script>
124 changes: 124 additions & 0 deletions client/src/vue_components/config/ConfigBackups.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<template>
<div v-if="!loading">
<div v-if="backups.length > 0">
<p class="text-muted mb-3">
{{ count }} {{ count === 1 ? 'backup' : 'backups' }} &middot;
{{ formatBytes(totalSizeBytes) }} total
</p>
<b-table :items="backups" :fields="fields" sort-by="created_at" :sort-desc="true">
<template #cell(size_bytes)="data">
{{ formatBytes(data.item.size_bytes) }}
</template>
<template #cell(created_at)="data">
{{ formatDate(data.item.created_at) }}
</template>
<template #cell(actions)="data">
<b-button
variant="danger"
size="sm"
:disabled="isDeleting"
@click="deleteBackup(data.item)"
>
Delete
</b-button>
</template>
</b-table>
</div>
<b-alert v-else variant="info" show>
No backup files found. Backups are created automatically before database migrations.
</b-alert>
</div>
<div v-else class="text-center center-spinner">
<b-spinner style="width: 10rem; height: 10rem" variant="info" />
</div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import log from 'loglevel';
import { makeURL } from '@/js/utils';
import type { BackupFile, BackupsResponse } from '@/types/api/backup';

export default defineComponent({
name: 'ConfigBackups',
data() {
return {
backups: [] as BackupFile[],
count: 0,
totalSizeBytes: 0,
loading: true,
isDeleting: false,
fields: [
{ key: 'filename', label: 'Filename' },
{ key: 'size_bytes', label: 'Size', sortable: true },
{ key: 'created_at', label: 'Date Created', sortable: true },
{ key: 'actions', label: '' },
],
};
},
async mounted() {
await this.fetchBackups();
this.loading = false;
},
methods: {
async fetchBackups(): Promise<void> {
try {
const response = await fetch(makeURL('/api/v1/admin/db-backups'));
if (response.ok) {
const data: BackupsResponse = await response.json();
this.backups = data.backups;
this.count = data.count;
this.totalSizeBytes = data.total_size_bytes;
} else {
log.error('Unable to fetch backup files');
}
} catch (error) {
log.error('Error fetching backup files:', error);
}
},
async deleteBackup(backup: BackupFile): Promise<void> {
const confirmed = await (this as any).$bvModal.msgBoxConfirm(
`Are you sure you want to permanently delete "${backup.filename}"? This cannot be undone.`,
{ title: 'Delete Backup', okVariant: 'danger', okTitle: 'Delete' }
);
if (!confirmed) return;

this.isDeleting = true;
try {
const response = await fetch(
makeURL(`/api/v1/admin/db-backups?timestamp=${backup.created_at}`),
{ method: 'DELETE' }
);
if (response.ok) {
this.$toast.success('Backup deleted');
await this.fetchBackups();
} else {
const body = await response.json();
this.$toast.error(`Failed to delete backup: ${body.message}`);
}
} catch (error) {
this.$toast.error('Failed to delete backup');
log.error('Error deleting backup:', error);
} finally {
this.isDeleting = false;
}
},
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
},
formatDate(unixTimestamp: number): string {
const d = new Date(unixTimestamp * 1000);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
},
},
});
</script>
12 changes: 12 additions & 0 deletions docs/pages/user_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ Once users have been created, their permissions can be configured by clicking th

RBAC configuration determines what shows a user can access and what actions they can perform within those shows.

### Backup Management

The **Backups** tab allows admin users to view and manage database backup files. DigiScript automatically creates a timestamped copy of the database file before running any database migration, ensuring you can recover data if a migration causes issues.

The tab displays:
- A summary line showing the total number of backups and combined disk usage
- A table listing each backup file with its filename, size, and creation date

To delete a backup, click the **Delete** button next to it and confirm the action. Deletion is permanent and cannot be undone.

> **Note:** Backup files accumulate over time as you upgrade DigiScript. Periodically reviewing and removing old backups helps reclaim disk space once you are confident the corresponding migrations completed successfully.

### RBAC Roles and Mappings

The current RBAC mappings are as follows:
Expand Down
4 changes: 2 additions & 2 deletions electron/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "digiscript-electron",
"version": "0.29.0",
"version": "0.29.1",
"description": "DigiScript Electron Desktop Application",
"author": "DreamTeamProd",
"license": "GPL-3.0",
Expand Down
92 changes: 92 additions & 0 deletions server/controllers/api/db_backups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import os

from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
from utils.web.web_decorators import api_authenticated, require_admin


def _get_db_file_path(application) -> str:
"""
:return: Absolute filesystem path to the SQLite database file.
:rtype: str
"""
db_path: str = application.digi_settings.settings.get("db_path").get_value()
if db_path.startswith("sqlite:///"):
db_path = db_path.replace("sqlite:///", "")
return db_path


def _list_backups(db_path: str) -> list[dict]:
"""
:param db_path: Filesystem path to the main database file.
:type db_path: str
:return: List of backup file dicts, sorted newest first.
:rtype: list[dict]
"""
db_dir = os.path.dirname(db_path) or "."
db_basename = os.path.basename(db_path)
prefix = db_basename + "."

backups = []
if os.path.isdir(db_dir):
for fname in os.listdir(db_dir):
if not fname.startswith(prefix):
continue
suffix = fname[len(prefix) :]
if not suffix.isdigit():
continue
full_path = os.path.join(db_dir, fname)
stat = os.stat(full_path)
backups.append(
{
"filename": fname,
"size_bytes": stat.st_size,
"created_at": int(suffix),
}
)

backups.sort(key=lambda x: x["created_at"], reverse=True)
return backups


@ApiRoute("admin/db-backups", ApiVersion.V1)
class BackupsController(BaseAPIController):
@api_authenticated
@require_admin
async def get(self):
db_path = _get_db_file_path(self.application)
backups = _list_backups(db_path)
total_size = sum(b["size_bytes"] for b in backups)
self.set_status(200)
await self.finish(
{
"backups": backups,
"count": len(backups),
"total_size_bytes": total_size,
}
)

@api_authenticated
@require_admin
async def delete(self):
timestamp = self.get_argument("timestamp", None)
if not timestamp:
self.set_status(400)
await self.finish({"message": "timestamp query argument is required"})
return
if not timestamp.isdigit():
self.set_status(400)
await self.finish({"message": "timestamp must be a positive integer"})
return

db_path = _get_db_file_path(self.application)
backup_path = f"{db_path}.{timestamp}"

if not os.path.isfile(backup_path):
self.set_status(404)
await self.finish({"message": "Backup file not found"})
return

os.remove(backup_path)
self.set_status(200)
await self.finish({"message": "Backup deleted"})
2 changes: 1 addition & 1 deletion server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "digiscript-server"
version = "0.29.0"
version = "0.29.1"
description = "DigiScript server - Digital script management for theatrical shows"
readme = "../README.md"
requires-python = ">=3.13"
Expand Down
Loading
Loading