Made possible to be able to restore backend data using the backup page.

This commit is contained in:
ArabCoders
2025-02-10 18:30:26 +03:00
parent fc7f4b8249
commit e7adc3a0c6
4 changed files with 137 additions and 108 deletions

View File

@@ -10,14 +10,14 @@
<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}">
: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}">
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-sync"></i></span>
</button>
</p>
@@ -32,20 +32,21 @@
<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..."/>
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">
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="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 class="icon"><i class="fas fa-download"
:class="{ 'fa-spin': item?.isDownloading }"></i>&nbsp;</span>
<span>
<NuxtLink @click="downloadFile(item)" v-text="item.filename"/>
<NuxtLink @click="downloadFile(item)" v-text="item.filename" />
</span>
</p>
<span class="card-header-icon">
@@ -54,6 +55,24 @@
</NuxtLink>
</span>
</header>
<div class="card-content">
<div class="field is-grouped">
<div class="control is-expanded">
<div class="select is-fullwidth">
<select v-model="item.selected" class="is-capitalized" required>
<option value="" selected disabled>Restore To this Backend</option>
<option v-for="backend in backends" :key="backend.name" :value="backend.name"
v-text="backend.name" />
</select>
</div>
</div>
<div class="control">
<button class="button is-primary" :disabled="'' === item.selected" @click="generateCommand(item)">
Go
</button>
</div>
</div>
</div>
<div class="card-footer-item">
<div class="card-footer-item">
<span class="icon"><i class="fas fa-calendar"></i>&nbsp;</span>
@@ -75,7 +94,7 @@
<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">
@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>90</code> days
@@ -89,13 +108,17 @@
<li>
To generate a manual backup, go to the
<NuxtLink to="/backends"><span class="icon"><i class="fas fa-server"></i></span> Backends</NuxtLink>
page and from the drop down menu select the 4th option `Backup this backend play state`, or via cli using
<code>state:backup</code> command from the console. or by <span class="icon"><i
class="fas fa-terminal"></i></span>
page and from the drop down menu select the 4th option `Backup this backend play state`, or via cli
using
<code>state:backup</code> command from the console. or by <span class="icon"><i class="fas fa-terminal" /></span>
<NuxtLink :to="makeConsoleCommand('state:backup -s [backend] --file /config/backup/[file]')"
v-text="'Web Console'"/>
v-text="'Web Console'" />
page.
</li>
<li>
The restore process will take you to <span class="icon"><i class="fas fa-terminal" /></span>
<NuxtLink to="/console" v-text="'Web Console'" /> and pre-fill the command for you to run.
</li>
</ul>
</Message>
</div>
@@ -106,15 +129,17 @@
<script setup>
import request from '~/utils/request'
import moment from 'moment'
import {humanFileSize, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index'
import { humanFileSize, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT } from '~/utils/index'
import Message from '~/components/Message'
import {useStorage} from '@vueuse/core'
import { useStorage } from '@vueuse/core'
useHead({title: 'Backups'})
useHead({ title: 'Backups' })
const items = ref([])
const backends = ref([])
const isLoading = ref(false)
const queued = ref(true)
const show_page_tips = useStorage('show_page_tips', true)
const api_user = useStorage('api_user', 'main')
const loadContent = async () => {
items.value = []
@@ -122,7 +147,13 @@ const loadContent = async () => {
try {
const response = await request('/system/backup')
items.value = await response.json()
const json = await response.json()
json.forEach(element => {
element['selected'] = ''
items.value.push(element)
});
if (useRoute().name !== 'backup') {
return
}
@@ -173,7 +204,7 @@ const queueTask = async () => {
}
try {
const response = await request(`/tasks/backup/queue`, {method: is_queued ? 'DELETE' : 'POST'})
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
@@ -189,7 +220,7 @@ const deleteFile = async (item) => {
}
try {
const response = await request(`/system/backup/${item.filename}`, {method: 'DELETE'})
const response = await request(`/system/backup/${item.filename}`, { method: 'DELETE' })
if (200 === response.status) {
notification('success', 'Success', `Backup file '${item.filename}' has been deleted.`)
@@ -202,7 +233,7 @@ const deleteFile = async (item) => {
try {
json = await response.json()
} catch (e) {
json = {error: {code: response.status, message: response.statusText}}
json = { error: { code: response.status, message: response.statusText } }
}
notification('error', 'Error', `API error. ${json.error.code}: ${json.error.message}`)
@@ -217,5 +248,25 @@ const isQueued = async () => {
return Boolean(json.queued)
}
onMounted(async () => await loadContent())
onMounted(async () => {
const response = await request('/backends')
backends.value = await response.json()
await loadContent()
})
const generateCommand = async item => {
const user = api_user.value
const backend = item.selected
const file = item.filename
if (false === confirm(`Are you sure you want to restore '${user}@${backend}' using '${file}'?`)) {
return;
}
await navigateTo(makeConsoleCommand(r("backend:restore --assume-yes --execute -v --user '{user}' --select-backend '{backend}' -- '{file}'", {
user,
backend,
file,
})));
}
</script>

View File

@@ -7,14 +7,18 @@ namespace App\Commands\Backend;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\Extends\StreamLogHandler;
use App\Libs\LogSuppressor;
use App\Libs\Enums\Http\Status;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Mappers\Import\RestoreMapper;
use App\Libs\Message;
use App\Libs\Options;
use App\Libs\Stream;
use App\Libs\QueueRequests;
use App\Libs\UserContext;
use DirectoryIterator;
use Monolog\Logger;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
@@ -43,8 +47,11 @@ class RestoreCommand extends Command
*
* @return void
*/
public function __construct(private readonly QueueRequests $queue, private readonly iLogger $logger)
{
public function __construct(
private readonly QueueRequests $queue,
private readonly iLogger $logger,
private LogSuppressor $suppressor
) {
set_time_limit(0);
ini_set('memory_limit', '-1');
@@ -64,6 +71,7 @@ class RestoreCommand extends Command
->addOption('select-backend', 's', InputOption::VALUE_REQUIRED, 'Select backend.')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Select sub user.', 'main')
->addArgument('file', InputArgument::REQUIRED, 'Backup file to restore from')
->addOption('logfile', null, InputOption::VALUE_REQUIRED, 'Save console output to file.')
->setHelp(
r(
<<<HELP
@@ -139,6 +147,12 @@ class RestoreCommand extends Command
*/
protected function runCommand(iInput $input, iOutput $output): int
{
if (null !== ($logfile = $input->getOption('logfile')) && true === ($this->logger instanceof Logger)) {
$this->logger->setHandlers([
$this->suppressor->withHandler(new StreamLogHandler(new Stream($logfile, 'w'), $output))
]);
}
return $this->single(fn(): int => $this->process($input, $output), $output);
}
@@ -180,7 +194,7 @@ class RestoreCommand extends Command
$file = $newFile;
}
$opStart = makeDate();
$opStart = microtime(true);
$mapper = new RestoreMapper($this->logger, $file);
@@ -209,29 +223,35 @@ class RestoreCommand extends Command
return self::FAILURE;
}
if (true === (bool)ag($backend, 'import.enabled') && false === $input->getOption('assume-yes')) {
$helper = $this->getHelper('question');
$text =
<<<TEXT
<options=bold,underscore>Are you sure?</> <comment>[Y|N] [Default: No]</comment>
-----------------
You are about to restore backend that has imports enabled.
if (true === (bool)ag($backend, 'import.enabled')) {
if (false === $input->getOption('assume-yes')) {
$helper = $this->getHelper('question');
$text = <<<TEXT
<options=bold,underscore>Are you sure?</> <comment>[Y|N] [Default: No]</comment>
-----------------
You are about to restore backend that has imports enabled.
<fg=white;bg=red;options=bold>The changes will propagate back to your backends.</>
<fg=white;bg=red;options=bold>The changes will propagate back to your backends.</>
<comment>If you understand the risks then answer with [<info>yes</info>]
If you don't please run same command with <info>[--help]</info> flag.
</comment>
-----------------
TEXT;
<comment>If you understand the risks then answer with [<info>yes</info>]
If you don't please run same command with <info>[--help]</info> flag.
</comment>
-----------------
TEXT;
$question = new ConfirmationQuestion($text . PHP_EOL . '> ', false);
$question = new ConfirmationQuestion($text . PHP_EOL . '> ', false);
if (false === $helper->ask($input, $output, $question)) {
$output->writeln(
'<comment>Restore operation is cancelled, you answered no for risk assessment, or interaction is disabled.</comment>'
);
return self::SUCCESS;
if (false === $helper->ask($input, $output, $question)) {
$output->writeln(
'<comment>Restore operation is cancelled, you answered no for risk assessment, or interaction is disabled.</comment>'
);
return self::SUCCESS;
}
} else {
$this->logger->notice("The restore target '{user}@{backend}' has import enabled, which means the changes will propagate back to the other backends.", [
'user' => $userContext->name,
'backend' => $name,
]);
}
}
@@ -244,12 +264,11 @@ class RestoreCommand extends Command
],
]);
$start = makeDate();
$start = microtime(true);
$mapper->loadData();
$end = makeDate();
$this->logger->notice(
"SYSTEM: Loading restore data of '{user}@{backend}' completed in '{time.duration}'s. Memory usage '{memory.now}'.",
"SYSTEM: Loading restore data of '{user}@{backend}' completed in '{duration}'s. Memory usage '{memory.now}'.",
[
'backend' => $name,
'user' => $userContext->name,
@@ -257,16 +276,12 @@ class RestoreCommand extends Command
'now' => getMemoryUsage(),
'peak' => getPeakMemoryUsage(),
],
'time' => [
'start' => $start,
'end' => $end,
'duration' => $end->getTimestamp() - $start->getTimestamp(),
],
'duration' => round(microtime(true) - $start, 4),
]
);
if (false === $input->getOption('execute')) {
$output->writeln('<info>No changes will be committed to backend.</info>');
$this->logger->notice("No changes will be committed to backend. To execute the changes pass '--execute' flag option.");
}
$opts = [
@@ -291,7 +306,7 @@ class RestoreCommand extends Command
$requests = $backend->export($mapper, $this->queue, null);
$start = makeDate();
$start = microtime(true);
$this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests for '{user}@{backend}'.", [
'backend' => $name,
'total' => count($requests),
@@ -307,16 +322,11 @@ class RestoreCommand extends Command
}
}
$end = makeDate();
$this->logger->notice("SYSTEM: Completed '{total}' requests in '{time.duration}'s for '{user}@{backend}'.", [
$this->logger->notice("SYSTEM: Completed '{total}' requests in '{duration}'s for '{user}@{backend}'.", [
'backend' => $name,
'total' => count($requests),
'user' => $userContext->name,
'time' => [
'start' => $start,
'end' => $end,
'duration' => $end->getTimestamp() - $start->getTimestamp(),
],
'duration' => round(microtime(true) - $start, 4),
]);
$total = count($this->queue->getQueue());
@@ -335,7 +345,7 @@ class RestoreCommand extends Command
'user' => $userContext->name,
]);
}
if ($total < 1 || false === $input->getOption('execute')) {
return self::SUCCESS;
}
@@ -378,19 +388,14 @@ class RestoreCommand extends Command
}
}
$opEnd = makeDate();
$this->logger->notice(
"SYSTEM: Sent '{total}' change play state requests to '{client}: {user}@{backend}' in '{time.duration}'s.",
"SYSTEM: Sent '{total}' change play state requests to '{client}: {user}@{backend}' in '{duration}'s.",
[
'total' => $total,
'backend' => $name,
'user' => $userContext->name,
'client' => $backend->getContext()->clientName,
'time' => [
'start' => $opStart,
'end' => $opEnd,
'duration' => $opEnd->getTimestamp() - $opStart->getTimestamp(),
],
'duration' => round(microtime(true) - $opStart, 4),
]
);

View File

@@ -223,7 +223,9 @@ class ExportCommand extends Command
}
if (!isset($supported[$type])) {
$this->logger->error("SYSTEM: Ignoring '{user}@{backend}'. Unexpected type '{type}'.", [
$this->logger->error(
"SYSTEM: Ignoring '{user}@{backend}'. Unexpected type '{type}'.",
[
'type' => $type,
'backend' => $backendName,
'user' => $userContext->name,
@@ -625,7 +627,7 @@ class ExportCommand extends Command
'backends' => implode(', ', array_keys($backends)),
]);
$this->logger->notice("SYSTEM: Preloading '{user}' - '{mapper}' data. Memory: {memory.now}", [
$this->logger->notice("SYSTEM: Preloading '{user}' - '{mapper}' data. Memory usage '{memory.now}'.", [
'user' => $userContext->name,
'mapper' => afterLast($userContext->mapper::class, '\\'),
'memory' => [
@@ -636,7 +638,7 @@ class ExportCommand extends Command
$userContext->mapper->reset()->loadData();
$this->logger->notice("SYSTEM: Preloading '{mapper}' data is complete. Memory: {memory.now}", [
$this->logger->notice("SYSTEM: Preloading '{mapper}' data is complete. Memory usage '{memory.now}'.", [
'mapper' => afterLast($userContext->mapper::class, '\\'),
'memory' => [
'now' => getMemoryUsage(),
@@ -685,7 +687,7 @@ class ExportCommand extends Command
}
}
$start = makeDate();
$start = microtime(true);
$this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests for '{user}'.", [
'total' => count($requests),
'user' => $userContext->name,
@@ -700,30 +702,10 @@ class ExportCommand extends Command
}
}
$end = makeDate();
$this->logger->notice(
"SYSTEM: Completed '{total}' play state comparison requests for '{user}' in '{time.duration}'s. Parsed '{responses.size}' of data.",
[
'user' => $userContext->name,
'total' => count($requests),
'time' => [
'start' => $start,
'end' => $end,
'duration' => $end->getTimestamp() - $start->getTimestamp(),
],
'memory' => [
'now' => getMemoryUsage(),
'peak' => getPeakMemoryUsage(),
],
'responses' => [
'size' => fsize((int)Message::get('response.size', 0)),
],
]
);
$this->logger->notice("Export mode ended for '{user}: {backends}'.", [
$this->logger->notice("Export mode ended for '{user}: {backends}' in '{duration}'s.", [
'user' => $userContext->name,
'backends' => implode(', ', array_keys($backends)),
'duration' => round(microtime(true) - $start, 4),
]);
}

View File

@@ -381,7 +381,7 @@ class ImportCommand extends Command
/** @var array<array-key,ResponseInterface> $queue */
$queue = [];
$this->logger->notice("SYSTEM: Preloading '{user}' '{mapper}' data. Memory: {memory.now}.", [
$this->logger->notice("SYSTEM: Preloading '{user}' '{mapper}' data. Memory usage '{memory.now}'.", [
'user' => $userContext->name,
'mapper' => afterLast($userContext->mapper::class, '\\'),
'memory' => [
@@ -394,7 +394,7 @@ class ImportCommand extends Command
$userContext->mapper->loadData();
$this->logger->notice(
"SYSTEM: Preloading '{user}' '{mapper}' data completed in '{duration}s'. Memory: {memory.now}.",
"SYSTEM: Preloading '{user}' '{mapper}' data completed in '{duration}s'. Memory usage '{memory.now}'.",
[
'user' => $userContext->name,
'mapper' => afterLast($userContext->mapper::class, '\\'),
@@ -471,13 +471,10 @@ class ImportCommand extends Command
unset($backend);
$start = makeDate();
$start = microtime(true);
$this->logger->notice("SYSTEM: Waiting on '{total}' requests for '{user}' backends.", [
'user' => $userContext->name,
'total' => number_format(count($queue)),
'time' => [
'start' => $start,
],
'memory' => [
'now' => getMemoryUsage(),
'peak' => getPeakMemoryUsage(),
@@ -498,18 +495,12 @@ class ImportCommand extends Command
gc_collect_cycles();
}
$end = makeDate();
$this->logger->notice(
"SYSTEM: Completed waiting on '{total}' requests in '{time.duration}'s for '{user}' backends. Parsed '{responses.size}' of data.",
"SYSTEM: Completed '{total}' requests in '{duration}'s for '{user}' backends. Parsed '{responses.size}' of data.",
[
'user' => $userContext->name,
'total' => number_format(count($queue)),
'time' => [
'start' => $start,
'end' => $end,
'duration' => $end->getTimestamp() - $start->getTimestamp(),
],
'duration' => round(microtime(true) - $start, 4),
'memory' => [
'now' => getMemoryUsage(),
'peak' => getPeakMemoryUsage(),
@@ -557,7 +548,7 @@ class ImportCommand extends Command
$userContext->mapper->reset();
$this->logger->info(
"SYSTEM: Importing '{user}' play states completed in '{duration}'s. Memory: {memory.now}.",
"SYSTEM: Importing '{user}' play states completed in '{duration}'s. Memory usage '{memory.now}'.",
[
'user' => $userContext->name,
'backends' => join(', ', array_keys($list)),