Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Backup to the API and exposed via WebUI #500

Merged
merged 8 commits into from
Jun 19, 2024
5 changes: 5 additions & 0 deletions frontend/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@
<span class="icon"><i class="fas fa-bug-slash"></i></span>
<span>Log Suppression</span>
</NuxtLink>

<NuxtLink class="navbar-item" to="/backup" @click.native="showMenu=false">
<span class="icon"><i class="fas fa-sd-card"></i></span>
<span>Backups</span>
</NuxtLink>
</div>
</div>

Expand Down
7 changes: 4 additions & 3 deletions frontend/pages/backend/[backend]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@
<div class="column is-4-tablet is-6-mobile has-text-left-mobile">
<span class="icon-text">
<span class="icon"><i class="fas fa-calendar"></i>&nbsp;</span>
<span class="has-tooltip" v-tooltip="moment.unix(history.updated).format('YYYY-MM-DD h:mm:ss A')">
{{ moment.unix(history.updated).fromNow() }}
<span class="has-tooltip"
v-tooltip="`Updated at: ${moment.unix(history.updated_at ?? history.updated).format(TOOLTIP_DATE_FORMAT)}`">
{{ moment.unix(history.updated_at ?? history.updated).fromNow() }}
</span>
</span>
</div>
Expand Down Expand Up @@ -139,7 +140,7 @@
<script setup>
import moment from 'moment'
import Message from '~/components/Message.vue'
import {formatDuration, makeName, notification} from "~/utils/index.js";
import {formatDuration, makeName, notification, TOOLTIP_DATE_FORMAT} from "~/utils/index.js";

const backend = useRoute().params.backend

Expand Down
7 changes: 4 additions & 3 deletions frontend/pages/backend/[backend]/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,9 @@
<div class="card-footer">
<div class="card-footer-item">
<span class="icon"><i class="fas fa-calendar"></i>&nbsp;</span>
<span class="has-tooltip" v-tooltip="moment.unix(item.updated).format('YYYY-MM-DD h:mm:ss A')">
{{ moment.unix(item.updated).fromNow() }}
<span class="has-tooltip"
v-tooltip="moment.unix(item.updated_at ?? item.updated).format(TOOLTIP_DATE_FORMAT)">
{{ moment.unix(item.updated_at ?? item.updated).fromNow() }}
</span>
</div>
<div class="card-footer-item">
Expand Down Expand Up @@ -196,7 +197,7 @@
<script setup>
import request from '~/utils/request.js'
import moment from 'moment'
import {makeName, makeSearchLink, notification} from '~/utils/index.js'
import {makeName, makeSearchLink, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index.js'
import Message from "~/components/Message.vue";
import {useStorage} from "@vueuse/core";

Expand Down
7 changes: 5 additions & 2 deletions frontend/pages/backend/[backend]/users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
</div>

<div class="column is-6" v-if="item?.updatedAt">
<strong>Updated:</strong> {{ moment(item.updatedAt).fromNow() }}
<strong>Updated:&nbsp;</strong>
<span class="has-tooltip" v-tooltip="moment(item.updatedAt).format(TOOLTIP_DATE_FORMAT)">
{{ moment(item.updatedAt).fromNow() }}
</span>
</div>

<div class="column is-6 has-text-right" v-if="undefined !== item?.restricted">
Expand Down Expand Up @@ -83,7 +86,7 @@
</template>

<script setup>
import {notification} from '~/utils/index.js'
import {notification, TOOLTIP_DATE_FORMAT} from '~/utils/index.js'
import {useStorage} from '@vueuse/core'
import request from '~/utils/request.js'
import moment from "moment";
Expand Down
48 changes: 41 additions & 7 deletions frontend/pages/backends/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,50 @@
</header>
<div class="card-content">
<div class="columns is-multiline has-text-centered">
<div class="column is-6 has-text-left-mobile" v-if="backend.export.enabled">
<strong>Last Export:</strong>
{{ backend.export.lastSync ? moment(backend.export.lastSync).fromNow() : 'None' }}
<div class="column is-6 has-text-left-mobile">
<strong>Last Export:&nbsp;</strong>
<template v-if="backend.export.enabled">
<span v-if="backend.export.lastSync" class="has-tooltip"
v-tooltip="moment(backend.export.lastSync).format(TOOLTIP_DATE_FORMAT)">
{{ moment(backend.export.lastSync).fromNow() }}
</span>
<template v-else>Never</template>
</template>
<template v-else>
<span class="tag is-danger is-light">Disabled</span>
</template>
</div>
<div class="column is-6 has-text-left-mobile" v-if="backend.import.enabled">
<strong>Last Import:</strong>
{{ backend.import.lastSync ? moment(backend.import.lastSync).fromNow() : 'None' }}
<div class="column is-6 has-text-left-mobile">
<strong>Last Import:&nbsp;</strong>
<template v-if="backend.import.enabled">
<span v-if="backend.import.lastSync" class="has-tooltip"
v-tooltip="moment(backend.import.lastSync).format(TOOLTIP_DATE_FORMAT)">
{{ moment(backend.import.lastSync).fromNow() }}
</span>
<template v-else>Never</template>
</template>
<template v-else>
<span class="tag is-danger is-light">Disabled</span>
</template>
</div>
</div>
</div>
<footer class="card-footer">
<div class="card-footer-item" v-if="backend.export.enabled">
<NuxtLink class="button is-danger is-fullwidth"
:to="makeConsoleCommand(`state:export -v -s ${backend.name}`)">
<span class="icon"><i class="fas fa-upload"></i></span>
<span>Run export now</span>
</NuxtLink>
</div>
<div class="card-footer-item" v-if="backend.import.enabled">
<NuxtLink class="button is-primary is-fullwidth"
:to="makeConsoleCommand(`state:import -v -s ${backend.name}`)">
<span class="icon"><i class="fas fa-download"></i></span>
<span>Run import now</span>
</NuxtLink>
</div>
</footer>
<footer class="card-footer">
<div class="card-footer-item">
<div class="field">
Expand Down Expand Up @@ -133,7 +167,7 @@ import 'assets/css/bulma-switch.css'
import moment from 'moment'
import request from '~/utils/request.js'
import BackendAdd from '~/components/BackendAdd.vue'
import {copyText, notification} from '~/utils/index.js'
import {copyText, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index.js'
import {useStorage} from "@vueuse/core";
import Message from "~/components/Message.vue";

Expand Down
212 changes: 212 additions & 0 deletions frontend/pages/backup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<template>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-sd-card"></i></span>
Backups
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="queueTask" :disabled="isLoading"
:class="{'is-loading':isLoading, 'is-primary':!queued, 'is-danger':queued}">
<span class="icon"><i class="fas fa-sd-card"></i></span>
<span>{{ !queued ? 'Queue backup' : 'Remove from queue' }}</span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading" :class="{'is-loading':isLoading}">
<span class="icon"><i class="fas fa-sync"></i></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
This page contains all of your manually generated and automatic backups.
</span>
</div>
</div>

<div class="column is-12" v-if="items.length < 1 || isLoading">
<Message v-if="isLoading" message_class="is-background-info-90 has-text-dark" icon="fas fa-spinner fa-spin"
title="Loading" message="Loading data. Please wait..."/>
<Message v-else title="Warning" message_class="is-background-warning-80 has-text-dark"
icon="fas fa-exclamation-triangle">
No backups found.
</Message>
</div>

<div class="column is-6-tablet" v-for="(item, index) in items" :key="'backup-'+index">
<div class="card">
<header class="card-header">
<p class="card-header-title is-text-overflow pr-1">
<span class="icon"><i class="fas fa-download" :class="{'fa-spin':item?.isDownloading}"></i>&nbsp;</span>
<span>
<NuxtLink @click="downloadFile(item)" v-text="item.filename"/>
</span>
</p>
<span class="card-header-icon">
<NuxtLink @click="deleteFile(item)" class="has-text-danger" v-tooltip="'Delete this backup file.'">
<span class="icon"><i class="fas fa-trash"></i></span>
</NuxtLink>
</span>
</header>
<div class="card-footer-item">
<div class="card-footer-item">
<span class="icon"><i class="fas fa-calendar"></i>&nbsp;</span>
<span class="has-tooltip" v-tooltip="`Last Update: ${moment(item.date).format(TOOLTIP_DATE_FORMAT)}`">
{{ moment(item.date).fromNow() }}
</span>
</div>
<div class="card-footer-item">
<span class="icon"><i class="fas fa-hdd"></i>&nbsp;</span>
<span>{{ humanFileSize(item.size) }}</span>
</div>
<div class="card-footer-item">
<span class="icon"><i class="fas fa-tag"></i>&nbsp;</span>
<span class="is-capitalized">{{ item.type }}</span>
</div>
</div>
</div>
</div>

<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
Backups that are tagged <code>Automatic</code> are subject to auto deletion after <code>9</code> days from
the date of creation.
</li>
<li>
You can trigger a backup task to run in the background by clicking the
<code><span class="icon"><i class="fas fa-sd-card"></i></span> Queue backup</code> button. on top right.
Those backups will be tagged as <code>Automatic</code>.
</li>
<li>
To generate a manual backup, you need to use the <code>state:backup</code> command from the console.
or by <span class="icon"><i class="fas fa-terminal"></i></span>
<NuxtLink :to="makeConsoleCommand('state:backup -s [backend] --file /config/backup/[file]')"
v-text="'Web Console'"/>
page.
</li>
</ul>
</Message>
</div>
</div>
</template>

<script setup>
import request from '~/utils/request.js'
import moment from 'moment'
import {humanFileSize, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index.js'
import Message from '~/components/Message.vue'
import {useStorage} from '@vueuse/core'

useHead({title: 'Backups'})
const items = ref([])
const isLoading = ref(false)
const queued = ref(true)
const show_page_tips = useStorage('show_page_tips', true)

const loadContent = async () => {
items.value = []
isLoading.value = true

try {
const response = await request('/system/backup')
items.value = await response.json()

queued.value = await isQueued()
} catch (e) {
notification('error', 'Error', e.message)
} finally {
isLoading.value = false
}
}

const downloadFile = async item => {
if (true === item?.isDownloading) {
return
}
const filename = item.filename
item.isDownloading = true

const response = request(`/system/backup/${filename}`)

if ('showSaveFilePicker' in window) {
response.then(async res => {
item.isDownloading = false

return res.body.pipeTo(await (await showSaveFilePicker({
suggestedName: `${filename}`
})).createWritable())
})
} else {
response.then(res => res.blob()).then(blob => {
const fileURL = URL.createObjectURL(blob)
const fileLink = document.createElement('a')
fileLink.href = fileURL
fileLink.download = `${filename}`
fileLink.click()
item.isDownloading = false
})
}
}

const queueTask = async () => {
const is_queued = await isQueued()
const message = is_queued ? 'Remove backup task from queue?' : 'Queue backup task to run in background?'

if (!confirm(message)) {
return
}

try {
const response = await request(`/tasks/backup/queue`, {method: is_queued ? 'DELETE' : 'POST'})
if (response.ok) {
notification('success', 'Success', `Task backup has been ${is_queued ? 'removed from the queue' : 'queued'}.`)
queued.value = !is_queued
}
} catch (e) {
notification('error', 'Error', `Request error. ${e.message}`)
}
}

const deleteFile = async (item) => {
if (!confirm(`Delete backup file '${item.filename}'?`)) {
return
}

try {
const response = await request(`/system/backup/${item.filename}`, {method: 'DELETE'})

if (200 === response.status) {
notification('success', 'Success', `Backup file '${item.filename}' has been deleted.`)
items.value = items.value.filter(i => i.filename !== item.filename)
return
}

let json

try {
json = await response.json()
} catch (e) {
json = {error: {code: response.status, message: response.statusText}}
}

notification('error', 'Error', `API error. ${json.error.code}: ${json.error.message}`)
} catch (e) {
notification('error', 'Error', `Request error. ${e.message}`)
}
}

const isQueued = async () => {
const response = await request('/tasks/backup')
const json = await response.json()
return Boolean(json.queued)
}

onMounted(async () => await loadContent())
</script>
Loading