Added system/reset API endpoint and exposed it via WebUI/CLI.
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"ext-sodium": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-redis": "*",
|
||||
"monolog/monolog": "^3.4",
|
||||
"symfony/console": "^6.1.4",
|
||||
"symfony/cache": "^6.1.3",
|
||||
@@ -44,8 +45,7 @@
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sockets": "For UDP commincations.",
|
||||
"ext-posix": "to check if running under super user.",
|
||||
"ext-redis": "For caching."
|
||||
"ext-posix": "to check if running under super user."
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-latest",
|
||||
|
||||
@@ -77,6 +77,41 @@ return (function (): array {
|
||||
'class' => fn() => new QueueRequests()
|
||||
],
|
||||
|
||||
Redis::class => [
|
||||
'class' => function (): Redis {
|
||||
$cacheUrl = Config::get('cache.url');
|
||||
|
||||
if (empty($cacheUrl)) {
|
||||
throw new RuntimeException('No cache server was set.');
|
||||
}
|
||||
|
||||
if (!extension_loaded('redis')) {
|
||||
throw new RuntimeException('Redis extension is not loaded.');
|
||||
}
|
||||
|
||||
$uri = new Uri($cacheUrl);
|
||||
$params = [];
|
||||
|
||||
if (!empty($uri->getQuery())) {
|
||||
parse_str($uri->getQuery(), $params);
|
||||
}
|
||||
|
||||
$redis = new Redis();
|
||||
|
||||
$redis->connect($uri->getHost(), $uri->getPort() ?? 6379);
|
||||
|
||||
if (null !== ag($params, 'password')) {
|
||||
$redis->auth(ag($params, 'password'));
|
||||
}
|
||||
|
||||
if (null !== ag($params, 'db')) {
|
||||
$redis->select((int)ag($params, 'db'));
|
||||
}
|
||||
|
||||
return $redis;
|
||||
}
|
||||
],
|
||||
|
||||
CacheInterface::class => [
|
||||
'class' => function () {
|
||||
if (true === (bool)env('WS_CACHE_NULL', false)) {
|
||||
|
||||
@@ -95,6 +95,12 @@
|
||||
<span class="icon"><i class="fas fa-sd-card"></i></span>
|
||||
<span>Backups</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/reset" @click.native="showMenu=false">
|
||||
<span class="icon"><i class="fas fa-redo"></i></span>
|
||||
<span>System reset</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
167
frontend/pages/reset.vue
Normal file
167
frontend/pages/reset.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<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-redo"></i></span>
|
||||
System reset
|
||||
</span>
|
||||
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped"></div>
|
||||
</div>
|
||||
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">Reset the system state.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="error">
|
||||
<Message message_class="is-background-warning-80 has-text-dark" title="Error" icon="fas fa-exclamation-circle"
|
||||
:use-close="true" @close="navigateTo('/backends')"
|
||||
:message="`${error.error.code}: ${error.error.message}`"/>
|
||||
</div>
|
||||
|
||||
<template v-if="isResetting">
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-warning-90 has-text-dark" title="Working..."
|
||||
icon="fas fa-spin fa-exclamation-triangle" message="Reset in progress, Please wait..."/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="column is-12">
|
||||
<Message message_class="is-background-warning-80 has-text-dark" title="Important information"
|
||||
icon="fas fa-exclamation-triangle">
|
||||
<p>
|
||||
Are you sure you want to reset the system state? This operation will remove all records and metadata from
|
||||
the database. This action is irreversible.
|
||||
</p>
|
||||
|
||||
<h5 class="has-text-dark">This operation will do the following</h5>
|
||||
|
||||
<ul>
|
||||
<li>Remove all data from local database.</li>
|
||||
<li>Attempt to flush the cache.</li>
|
||||
<li>Reset the backends last sync date.</li>
|
||||
</ul>
|
||||
|
||||
<p>There is no undo operation. This action is irreversible.</p>
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<form @submit.prevent="resetSystem()">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title">System reset</p>
|
||||
<p class="card-header-icon"><span class="icon"><i class="fas fa-redo"></i></span></p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
To confirm, please write '<code>{{ random_secret }}</code>' in the box below.
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" v-model="user_secret" placeholder="Enter the secret key"/>
|
||||
</div>
|
||||
<p class="help">
|
||||
<span class="icon has-text-warning">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</span>
|
||||
Depending on your hardware speed, the reset operation might take long time. do not interrupt the
|
||||
process, or close the browser tab. You will be redirected to the index page automatically once the
|
||||
process is complete. Otherwise, you might end up with a corrupted database and/or state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<NuxtLink to="/" class="button is-fullwidth is-primary">
|
||||
<span class="icon"><i class="fas fa-cancel"></i></span>
|
||||
<span>Cancel</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-danger is-fullwidth" type="submit" :disabled="user_secret !== random_secret">
|
||||
<span class="icon"><i class="fas fa-redo"></i></span>
|
||||
<span>Proceed</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import request from '~/utils/request'
|
||||
import Message from '~/components/Message'
|
||||
import {notification} from '~/utils/index'
|
||||
|
||||
const error = ref()
|
||||
const isResetting = ref(false)
|
||||
const random_secret = ref('')
|
||||
const user_secret = ref('')
|
||||
|
||||
const resetSystem = async () => {
|
||||
if (user_secret.value !== random_secret.value) {
|
||||
notification('error', 'Error', 'Invalid secret key. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm('Last chance! Are you sure you want to reset the system state?')) {
|
||||
return
|
||||
}
|
||||
|
||||
isResetting.value = true
|
||||
|
||||
try {
|
||||
const response = await request(`/system/reset`, {method: 'DELETE'})
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = await response.json()
|
||||
} catch (e) {
|
||||
json = {
|
||||
error: {
|
||||
code: response.status,
|
||||
message: response.statusText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (200 !== response.status) {
|
||||
error.value = json
|
||||
return
|
||||
}
|
||||
|
||||
notification('success', 'Success', `System has been successfully reset.`)
|
||||
await navigateTo('/')
|
||||
} catch (e) {
|
||||
error.value = {
|
||||
error: {
|
||||
code: 500,
|
||||
message: e.message
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isResetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const strLength = 8;
|
||||
|
||||
let result = '';
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let counter = 0;
|
||||
while (counter < strLength) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
random_secret.value = result
|
||||
})
|
||||
</script>
|
||||
@@ -132,11 +132,11 @@ final class Add
|
||||
'uuid' => $data->get('uuid'),
|
||||
'export' => [
|
||||
'enabled' => (bool)$data->get('export.enabled', false),
|
||||
'lastSync' => (int)$data->get('export.lastSync', 0),
|
||||
'lastSync' => (int)$data->get('export.lastSync', null),
|
||||
],
|
||||
'import' => [
|
||||
'enabled' => (bool)$data->get('import.enabled', false),
|
||||
'lastSync' => (int)$data->get('import.lastSync', 0),
|
||||
'lastSync' => (int)$data->get('import.lastSync', null),
|
||||
],
|
||||
'webhook' => [
|
||||
'token' => $data->get('webhook.token'),
|
||||
|
||||
46
src/API/System/Reset.php
Normal file
46
src/API/System/Reset.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Delete;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\ConfigFile;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\HTTP_STATUS;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Redis;
|
||||
use RedisException;
|
||||
|
||||
final class Reset
|
||||
{
|
||||
public const string URL = '%{api.prefix}/system/reset';
|
||||
|
||||
public function __construct(private Redis $redis, private iDB $db)
|
||||
{
|
||||
}
|
||||
|
||||
#[Delete(self::URL . '[/]', name: 'system.reset')]
|
||||
public function __invoke(iRequest $request, array $args = []): iResponse
|
||||
{
|
||||
$this->db->reset();
|
||||
|
||||
try {
|
||||
$this->redis->flushDB();
|
||||
} catch (RedisException) {
|
||||
}
|
||||
|
||||
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
|
||||
|
||||
foreach ($list->getAll() as $name => $backend) {
|
||||
$list->set("{$name}.import.lastSync", null);
|
||||
$list->set("{$name}.export.lastSync", null);
|
||||
}
|
||||
|
||||
$list->persist();
|
||||
|
||||
return api_response(HTTP_STATUS::HTTP_OK, ['message' => 'System reset.']);
|
||||
}
|
||||
}
|
||||
142
src/Commands/System/ResetCommand.php
Normal file
142
src/Commands/System/ResetCommand.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\System;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Libs\HTTP_STATUS;
|
||||
use Symfony\Component\Console\Input\InputInterface as iInput;
|
||||
use Symfony\Component\Console\Output\OutputInterface as iOutput;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
/**
|
||||
* Class SuppressCommand
|
||||
*
|
||||
* This command manage the Log Suppressor.
|
||||
*/
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class ResetCommand extends Command
|
||||
{
|
||||
public const string ROUTE = 'system:reset';
|
||||
|
||||
/**
|
||||
* Configure the command.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->setDescription('Reset the system state.')
|
||||
->setHelp(
|
||||
<<<HELP
|
||||
Reset the system history state.
|
||||
|
||||
This command will do the following:
|
||||
|
||||
1. Reset the local database.
|
||||
2. Attempt to flush the cache.
|
||||
3. Reset the last sync time for all backends.
|
||||
|
||||
HELP,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the command is not running in parallel.
|
||||
*
|
||||
* @param iInput $input The input object containing the command data.
|
||||
* @param iOutput $output The output object for displaying command output.
|
||||
*
|
||||
* @return int The exit code of the command execution.
|
||||
*/
|
||||
protected function runCommand(iInput $input, iOutput $output): int
|
||||
{
|
||||
return $this->single(fn(): int => $this->process($input, $output), $output);
|
||||
}
|
||||
|
||||
private function process(iInput $input, iOutput $output): int
|
||||
{
|
||||
if (function_exists('stream_isatty') && defined('STDERR')) {
|
||||
$tty = stream_isatty(STDERR);
|
||||
} else {
|
||||
$tty = true;
|
||||
}
|
||||
|
||||
if (false === $tty && !$input->getOption('no-interaction')) {
|
||||
$output->writeln(
|
||||
r(
|
||||
<<<ERROR
|
||||
|
||||
<error>ERROR:</error> This command require <notice>interaction</notice>. For example:
|
||||
|
||||
{cmd} <cmd>{route}</cmd>
|
||||
|
||||
ERROR,
|
||||
[
|
||||
'cmd' => trim(commandContext()),
|
||||
'route' => self::ROUTE,
|
||||
]
|
||||
)
|
||||
);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$helper = $this->getHelper('question');
|
||||
|
||||
if (!$input->getOption('no-interaction')) {
|
||||
try {
|
||||
$random = bin2hex(random_bytes(4));
|
||||
} catch (\Throwable) {
|
||||
$random = substr(md5(uniqid((string)mt_rand(), true)), 0, 8);
|
||||
}
|
||||
$question = new Question(
|
||||
r(
|
||||
<<<HELP
|
||||
<question>Are you sure you want to reset WatchState?</question>
|
||||
------------------
|
||||
<notice>WARNING:</notice> This command will completely reset your WatchState local database.
|
||||
------------------
|
||||
<notice>To confirm deletion please write '<value>{random}</value>' in the box below</notice>
|
||||
|
||||
HELP. PHP_EOL . '> ',
|
||||
[
|
||||
'random' => $random
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$response = $helper->ask($input, $output, $question);
|
||||
$output->writeln('');
|
||||
|
||||
if ($random !== $response) {
|
||||
$output->writeln(
|
||||
r(
|
||||
"<question>Reset has failed. Incorrect value provided '<value>{response}</value>' vs '<value>{random}</value>'.</question>",
|
||||
[
|
||||
'random' => $random,
|
||||
'response' => $response
|
||||
]
|
||||
)
|
||||
);
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln(
|
||||
r("<info>Requesting The API to reset the local state... Please wait, it will take a while.</info>")
|
||||
);
|
||||
|
||||
$response = APIRequest('DELETE', '/system/reset');
|
||||
if (HTTP_STATUS::HTTP_OK !== $response->status) {
|
||||
$output->writeln(r("<error>API error. {status}: {message}</error>", [
|
||||
'status' => $response->status->value,
|
||||
'message' => ag($response->body, 'error.message', 'Unknown error.')
|
||||
]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln(r("<info>Local database has been successfully reset.</info>"));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,13 @@ interface DatabaseInterface
|
||||
*/
|
||||
public function makeMigration(string $name, array $opts = []): mixed;
|
||||
|
||||
/**
|
||||
* Reset database to initial state.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function reset(): bool;
|
||||
|
||||
/**
|
||||
* Inject Logger.
|
||||
*
|
||||
|
||||
@@ -580,6 +580,20 @@ final class PDOAdapter implements iDB
|
||||
return (new PDOMigrations($this->pdo, $this->logger))->runMaintenance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function reset(): bool
|
||||
{
|
||||
$this->pdo->exec('DELETE FROM `state` WHERE `id` > 0');
|
||||
|
||||
if ('sqlite' === $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)) {
|
||||
$this->pdo->exec('DELETE FROM sqlite_sequence WHERE name = "state"');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user