Implement zero-downtime backups + nightly backups (#1574)

* Create new backup and restore commands allowing live backups.
* Switch migrate script to use new backup method.
* Avoid loading fixtures, ensure directories exist when restarting stations.
* Include album art in media backup.
* First portion of automated backup management code.
* Further backup page work; add download/delete functionality.
* Implement automatic backups and "manual run" page.
* Switch automatic backup filename to match text.
* Add new locales.
* Add restore instructions and ability to view latest backup log.
This commit is contained in:
Buster "Silver Eagle" Neece 2019-05-23 10:29:22 -05:00 committed by GitHub
parent 54c9e52259
commit 16fc2c54bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2222 additions and 971 deletions

8
composer.lock generated
View File

@ -95,12 +95,12 @@
"source": {
"type": "git",
"url": "https://github.com/AzuraCast/azuracore.git",
"reference": "f4f25ddeaa4b941154947a88f8509fa37649163a"
"reference": "4b84a6051127772dc0abc53518270cfc206e0ae5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/f4f25ddeaa4b941154947a88f8509fa37649163a",
"reference": "f4f25ddeaa4b941154947a88f8509fa37649163a",
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/4b84a6051127772dc0abc53518270cfc206e0ae5",
"reference": "4b84a6051127772dc0abc53518270cfc206e0ae5",
"shasum": ""
},
"require": {
@ -150,7 +150,7 @@
}
],
"description": "A lightweight core application framework.",
"time": "2019-05-07T14:25:24+00:00"
"time": "2019-05-23T01:44:47+00:00"
},
{
"name": "azuracast/azuraforms",

View File

@ -66,6 +66,8 @@ return function (\Azura\EventDispatcher $dispatcher)
new Command\SetAdministrator,
new Command\ListSettings,
new Command\SetSetting,
new Command\Backup,
new Command\Restore,
]);
}, 0);

73
config/forms/backup.php Normal file
View File

@ -0,0 +1,73 @@
<?php
use App\Entity;
return [
'groups' => [
'backup' => [
'use_grid' => true,
'elements' => [
Entity\Settings::BACKUP_ENABLED => [
'toggle',
[
'label' => __('Run Automatic Nightly Backups'),
'description' => __('Enable to have AzuraCast automatically run nightly backups at the time specified.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
'form_group_class' => 'col-md-6',
]
],
Entity\Settings::BACKUP_TIME => [
'PlaylistTime',
[
'label' => __('Scheduled Backup Time'),
'description' => __('The time (in UTC) to run the automated backup, if enabled.'),
'form_group_class' => 'col-md-6',
]
],
Entity\Settings::BACKUP_EXCLUDE_MEDIA => [
'toggle',
[
'label' => __('Exclude Media from Backups'),
'description' => __('Excluding media from automated backups will save space, but you should make sure to back up your media elsewhere.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
'form_group_class' => 'col-md-6',
]
],
Entity\Settings::BACKUP_KEEP_COPIES => [
'number',
[
'label' => __('Number of Backup Copies to Keep'),
'description' => __('Copies older than the specified number of days will automatically be deleted. Set to zero to disable automatic deletion.'),
'min' => 0,
'max' => 365,
'default' => 0,
'form_group_class' => 'col-md-6',
]
],
],
],
'submit' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'btn btn-lg btn-primary',
]
],
],
],
],
];

View File

@ -0,0 +1,36 @@
<?php
use App\Entity;
return [
'elements' => [
'path' => [
'text',
[
'label' => __('Backup Filename'),
'description' => __('Optional absolute or relative path where the backup file should be located.'),
]
],
'exclude_media' => [
'toggle',
[
'label' => __('Exclude Media from Backup'),
'description' => __('This will produce a significantly smaller backup, but you should make sure to back up your media elsewhere.'),
'selected_text' => __('Yes'),
'deselected_text' => __('No'),
'default' => false,
]
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'btn btn-lg btn-primary',
]
],
],
];

View File

@ -33,6 +33,11 @@ return function(\App\Event\BuildAdminMenu $e) {
'url' => $router->named('admin:logs:index'),
'permission' => Acl::GLOBAL_LOGS,
],
'backups' => [
'label' => __('Backups'),
'url' => $router->named('admin:backups:index'),
'permission' => Acl::GLOBAL_BACKUPS,
]
],
],
'users' => [
@ -73,4 +78,4 @@ return function(\App\Event\BuildAdminMenu $e) {
],
]
]);
};
};

View File

@ -1,8 +1,10 @@
<?php
// An array of message queue types and the DI classes responsible for handling them.
return [
\App\Message\AddNewMedia::class => \App\Sync\Task\Media::class,
\App\Message\ReprocessMedia::class => \App\Sync\Task\Media::class,
\App\Message\AddNewMediaMessage::class => \App\Sync\Task\Media::class,
\App\Message\ReprocessMediaMessage::class => \App\Sync\Task\Media::class,
\App\Message\UpdateNowPlayingMessage::class => \App\Sync\Task\NowPlaying::class,
\App\Message\UpdateNowPlayingMessage::class => \App\Sync\Task\NowPlaying::class,
\App\Message\BackupMessage::class => \App\Sync\Task\Backup::class,
];

View File

@ -39,6 +39,26 @@ return function(App $app)
})->add([Middleware\Permissions::class, Acl::GLOBAL_API_KEYS]);
$this->group('/backups', function() {
/** @var App $this */
$this->get('', Controller\Admin\BackupsController::class)
->setName('admin:backups:index');
$this->map(['GET', 'POST'], '/configure', Controller\Admin\BackupsController::class.':configureAction')
->setName('admin:backups:configure');
$this->map(['GET', 'POST'], '/run', Controller\Admin\BackupsController::class.':runAction')
->setName('admin:backups:run');
$this->get('/delete/{path}', Controller\Admin\BackupsController::class.':downloadAction')
->setName('admin:backups:download');
$this->get('/delete/{path}/{csrf}', Controller\Admin\BackupsController::class.':deleteAction')
->setName('admin:backups:delete');
})->add([Middleware\Permissions::class, Acl::GLOBAL_BACKUPS]);
$this->map(['GET', 'POST'], '/branding', Controller\Admin\BrandingController::class.':indexAction')
->setName('admin:branding:index')
->add([Middleware\Permissions::class, Acl::GLOBAL_SETTINGS]);

View File

@ -1,14 +0,0 @@
version: '2.2'
services:
influxdb:
volumes:
- ./migration/influxdb:/tmp/migration
web:
volumes:
- ../stations:/tmp/migration
mariadb:
volumes:
- ./migration/database.sql:/tmp/database.sql

View File

@ -18,23 +18,11 @@ fi
BASE_DIR=`pwd`
mkdir -p ${BASE_DIR}/migration
mkdir -p ${BASE_DIR}/migration/influxdb
# Create backup from existing installation.
chmod a+x bin/azuracast
./bin/azuracast azuracast:backup --exclude-media ./migration.tar.gz
# Dump MySQL data into fixtures folder
MYSQL_USERNAME=`awk -F "=" '/db_username/ {print $2}' env.ini | tr -d ' '`
MYSQL_PASSWORD=`awk -F "=" '/db_password/ {print $2}' env.ini | tr -d ' '`
mysqldump --add-drop-table -u$MYSQL_USERNAME -p$MYSQL_PASSWORD azuracast > migration/database.sql
read -n 1 -s -r -p "MySQL exported. Press any key to continue (Export InfluxDB)..."
# Dump InfluxDB data
mkdir -p /var/azuracast/migration
influxd backup -database stations ${BASE_DIR}/migration/influxdb
read -n 1 -s -r -p "InfluxDB exported. Press any key to continue (Install Docker)..."
read -n 1 -s -r -p "Database backed up. Press any key to continue (Install Docker)..."
# Install Docker
wget -qO- https://get.docker.com/ | sh
@ -55,30 +43,22 @@ read -n 1 -s -r -p "Uninstall complete. Press any key to continue (Install Azura
# Spin up Docker
docker-compose pull
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml up -d
sleep 5
sleep 15
# Copy media.
docker-compose run --user="azuracast" --rm \
-v /var/azuracast/stations:/tmp/migration \
mv /tmp/migration/* /var/azuracast/stations
# Run Docker AzuraCast-specific installer
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --rm influxdb import_folder /tmp/migration/
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml exec mariadb import_file /tmp/database.sql
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --user="azuracast" --rm web azuracast_migrate_stations /tmp/migration
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml run --user="azuracast" --rm web azuracast_install
docker-compose -f docker-compose.yml -f docker-compose.migrate.yml down
docker-compose up -d
# Docker cleanup
docker-compose rm -f
docker volume prune -f
docker rmi $(docker images | grep "none" | awk '/ / { print $3 }')
# Copy all other settings.
chmod a+x docker.sh
./docker.sh restore ./migration.tar.gz
read -n 1 -s -r -p "Docker is running. Press any key to continue (cleanup)..."
# Codebase cleanup
rm -rf /var/azuracast/stations
find -maxdepth 1 ! -name migration ! -name . ! -name docker-compose.yml \
find -maxdepth 1 ! -name migration.tar.gz ! -name . ! -name docker-compose.yml \
! -name docker.sh ! -name .env ! -name azuracast.env ! -name plugins \
-exec rm -rv {} \;

View File

@ -182,6 +182,7 @@ backup() {
BACKUP_PATH=${1:-"./backup.tar.gz"}
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
BACKUP_FILENAME=`basename "$BACKUP_PATH"`
shift
cd $APP_BASE_DIR
@ -190,25 +191,52 @@ backup() {
curl -L https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/.env > .env
fi
docker-compose down
docker run --rm -v $BACKUP_DIR:/backup \
-v azuracast_db_data:/azuracast/db \
-v azuracast_influx_data:/azuracast/influx \
-v azuracast_station_data:/azuracast/stations \
busybox tar zcvf /backup/$BACKUP_FILENAME /azuracast
docker-compose up -d
docker-compose run --rm --user="azuracast" \
-v $BACKUP_DIR:/backup \
web azuracast_cli azuracast:backup /backup/$BACKUP_FILENAME $*
}
#
# Restore the Docker volumes from a .tar.gz file.
# Restore an AzuraCast backup into Docker.
# Usage:
# ./docker.sh restore [/custom/backup/dir/custombackupname.tar.gz]
#
restore() {
APP_BASE_DIR=$(pwd)
BACKUP_PATH=${1:-"./backup.tar.gz"}
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
BACKUP_FILENAME=`basename "$BACKUP_PATH"`
shift
cd $APP_BASE_DIR
if [ ! -f .env ]; then
echo "Writing default .env file..."
curl -L https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/.env > .env
fi
if [ -f $BACKUP_PATH ]; then
docker-compose run --rm --user="azuracast" \
-v $BACKUP_DIR:/backup \
web azuracast_restore /backup/$BACKUP_FILENAME $*
docker-compose up -d
else
echo "File $BACKUP_PATH does not exist in this directory. Nothing to restore."
exit 1
fi
echo 'test'
}
#
# Restore the Docker volumes from a legacy backup format .tar.gz file.
# Usage:
# ./docker.sh restore [/custom/backup/dir/custombackupname.tar.gz]
#
restore-legacy() {
APP_BASE_DIR=$(pwd)
BACKUP_PATH=${1:-"./backup.tar.gz"}
BACKUP_DIR=$(cd `dirname "$BACKUP_PATH"` && pwd)
BACKUP_FILENAME=`basename "$BACKUP_PATH"`

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ class Acl
public const GLOBAL_PERMISSIONS = 'administer permissions';
public const GLOBAL_STATIONS = 'administer stations';
public const GLOBAL_CUSTOM_FIELDS = 'administer custom fields';
public const GLOBAL_BACKUPS = 'administer backups';
public const STATION_ALL = 'administer all';
public const STATION_VIEW = 'view station management';
@ -188,6 +189,7 @@ class Acl
self::GLOBAL_PERMISSIONS => __('Administer %s', __('Permissions')),
self::GLOBAL_STATIONS => __('Administer %s', __('Stations')),
self::GLOBAL_CUSTOM_FIELDS => __('Administer %s', __('Custom Fields')),
self::GLOBAL_BACKUPS => __('Administer %s', __('Backups')),
],
'station' => [
self::STATION_ALL => __('All Permissions'),

View File

@ -0,0 +1,222 @@
<?php
namespace App\Console\Command;
use App\Entity;
use Azura\Console\Command\CommandAbstract;
use Doctrine\ORM\EntityManager;
use Monolog\Logger;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Process;
class Backup extends CommandAbstract
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->setName('azuracast:backup')
->setDescription(
'Back up the AzuraCast database and statistics (and optionally media).'
)
->addArgument(
'path',
InputArgument::OPTIONAL,
'The absolute (or relative to /var/azuracast/backups) path to generate the backup.',
''
)
->addOption(
'exclude-media',
null,
InputOption::VALUE_NONE,
'Exclude media from the backup.'
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$destination_path = $input->getArgument('path');
if (empty($destination_path)) {
$destination_path = 'manual_backup_'.gmdate('Ymd_Hi').'.tar.gz';
}
if ('/' !== $destination_path[0]) {
$destination_path = \App\Sync\Task\Backup::BASE_DIR.'/'.$destination_path;
}
$include_media = !(bool)$input->getOption('exclude-media');
$files_to_backup = [];
$io = new SymfonyStyle($input, $output);
$io->title('AzuraCast Backup');
$io->writeln('Please wait while a backup is generated...');
// Create temp directories
$io->section('Creating temporary directories...');
$tmp_dir_mariadb = '/tmp/azuracast_backup_mariadb';
if (!mkdir($tmp_dir_mariadb) && !is_dir($tmp_dir_mariadb)) {
$io->error(sprintf('Directory "%s" was not created', $tmp_dir_mariadb));
return 1;
}
$tmp_dir_influxdb = '/tmp/azuracast_backup_influxdb';
if (!mkdir($tmp_dir_influxdb) && !is_dir($tmp_dir_influxdb)) {
$io->error(sprintf('Directory "%s" was not created', $tmp_dir_influxdb));
return 1;
}
$io->newLine();
// Back up MariaDB
$io->section('Backing up MariaDB...');
$path_db_dump = $tmp_dir_mariadb.'/db.sql';
/** @var EntityManager $em */
$em = $this->get(EntityManager::class);
$conn = $em->getConnection();
$process = $this->passThruProcess(
$io,
'mysqldump --host=$DB_HOST --user=$DB_USERNAME --password=$DB_PASSWORD --add-drop-table --default-character-set=UTF8MB4 $DB_DATABASE > $DB_DEST',
$tmp_dir_mariadb,
[
'DB_HOST' => $conn->getHost(),
'DB_DATABASE' => $conn->getDatabase(),
'DB_USERNAME' => $conn->getUsername(),
'DB_PASSWORD' => $conn->getPassword(),
'DB_DEST' => $path_db_dump,
]
);
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with MariaDB.');
return 1;
}
$files_to_backup[] = $path_db_dump;
$io->newLine();
// Back up InfluxDB
$io->section('Backing up InfluxDB...');
/** @var \InfluxDB\Database $influxdb */
$influxdb = $this->get(\InfluxDB\Database::class);
$influxdb_client = $influxdb->getClient();
$process = $this->passThruProcess($io, [
'influxd',
'backup',
'-database', 'stations',
'-portable',
'-host',
$influxdb_client->getHost().':8088',
$tmp_dir_influxdb,
], $tmp_dir_influxdb);
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with InfluxDB.');
return 1;
}
$files_to_backup[] = $tmp_dir_influxdb;
$io->newLine();
// Include station media if specified.
if ($include_media) {
$stations = $em->createQuery(/** @lang DQL */'SELECT s FROM App\Entity\Station s')
->execute();
foreach($stations as $station) {
/** @var Entity\Station $station */
$media_dir = $station->getRadioMediaDir();
if (!in_array($media_dir, $files_to_backup, true)) {
$files_to_backup[] = $media_dir;
}
$art_dir = $station->getRadioAlbumArtDir();
if (!in_array($art_dir, $files_to_backup, true)) {
$files_to_backup[] = $art_dir;
}
}
}
// Compress backup files.
$io->section('Creating backup archive...');
// Strip leading slashes from backup paths.
$files_to_backup = array_map(function($val) {
if (0 === strpos($val, '/')) {
return substr($val, 1);
}
return $val;
}, $files_to_backup);
$process = $this->passThruProcess($io, array_merge([
'tar',
'zcvf',
$destination_path
], $files_to_backup),'/');
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with the archive process.');
return 1;
}
$io->success([
'Backup complete!',
]);
return 0;
}
protected function passThruProcess(SymfonyStyle $io, $cmd, $cwd = null, array $env = []): Process
{
if (is_array($cmd)) {
$process = new Process($cmd, $cwd);
} else {
$process = Process::fromShellCommandline($cmd, $cwd);
}
$stdout = [];
$stderr = [];
$process->run(function($type, $data) use ($process, $io, &$stdout, &$stderr) {
if ($process::ERR === $type) {
$io->getErrorStyle()->write($data);
$stderr[] = $data;
} else {
$io->write($data);
$stdout[] = $data;
}
}, $env);
if (!empty($stderr) || !empty($stdout)) {
/** @var Logger $logger */
$logger = $this->get(Logger::class);
if (!empty($stdout)) {
$logger->debug('Backup process output', [
'cmd' => $cmd,
'output' => $stdout,
]);
}
if (!empty($stderr)) {
$logger->error('Backup process error', [
'cmd' => $cmd,
'output' => $stderr,
]);
}
}
return $process;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Command;
use App\Entity;
use App\Utilities;
use Azura\Console\Command\CommandAbstract;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Process;
class Restore extends CommandAbstract
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this->setName('azuracast:restore')
->setDescription(
'Restore a backup previously generated by AzuraCast.'
)
->addArgument(
'path',
InputArgument::REQUIRED,
'The absolute (or relative to /var/azuracast/backups) path of the backup to restore.'
)
->addOption('restore', null, InputOption::VALUE_NONE, 'Unused.')
->addOption('release', null, InputOption::VALUE_NONE, 'Used for updating only to a tagged release.');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('AzuraCast Restore');
$io->writeln('Please wait while the backup is restored...');
$archive_path = $input->getArgument('path');
if ('/' !== $archive_path[0]) {
$archive_path = \App\Sync\Task\Backup::BASE_DIR.$archive_path;
}
if (!file_exists($archive_path)) {
$io->getErrorStyle()->error(sprintf('Backup path %s not found!', $archive_path));
return 1;
}
// Extract tar.gz archive
$io->section('Extracting backup file...');
$process = $this->passThruProcess($io, [
'tar',
'zxvf',
$archive_path
],'/');
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with the archive extraction.');
return 1;
}
$io->newLine();
// Handle DB dump
$io->section('Importing database...');
$tmp_dir_mariadb = '/tmp/azuracast_backup_mariadb';
$path_db_dump = $tmp_dir_mariadb.'/db.sql';
if (!file_exists($path_db_dump)) {
$io->getErrorStyle()->error('Database backup file not found!');
return 1;
}
/** @var EntityManager $em */
$em = $this->get(EntityManager::class);
$conn = $em->getConnection();
$process = $this->passThruProcess(
$io,
'mysql --host=$DB_HOST --user=$DB_USERNAME --password=$DB_PASSWORD $DB_DATABASE < $DB_DUMP',
$tmp_dir_mariadb,
[
'DB_HOST' => $conn->getHost(),
'DB_DATABASE' => $conn->getDatabase(),
'DB_USERNAME' => $conn->getUsername(),
'DB_PASSWORD' => $conn->getPassword(),
'DB_DUMP' => $path_db_dump,
]
);
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with MariaDB.');
return 1;
}
Utilities::rmdirRecursive($tmp_dir_mariadb);
$io->newLine();
// Handle InfluxDB import
$tmp_dir_influxdb = '/tmp/azuracast_backup_influxdb';
if (!is_dir($tmp_dir_influxdb)) {
$io->getErrorStyle()->error('InfluxDB backup file not found!');
return 1;
}
/** @var \InfluxDB\Database $influxdb */
$influxdb = $this->get(\InfluxDB\Database::class);
$influxdb_client = $influxdb->getClient();
$process = $this->passThruProcess($io, [
'influxd',
'restore',
'-portable',
'-host',
$influxdb_client->getHost().':8088',
$tmp_dir_influxdb,
], $tmp_dir_influxdb);
if (!$process->isSuccessful()) {
$io->getErrorStyle()->error('An error occurred with InfluxDB.');
return 1;
}
Utilities::rmdirRecursive($tmp_dir_influxdb);
$io->newLine();
// Update from current version to latest.
$io->section('Running standard updates...');
$this->runCommand($output, 'azuracast:setup', ['--update' => true]);
$io->success([
'Restore complete!',
]);
return 0;
}
protected function passThruProcess(SymfonyStyle $io, $cmd, $cwd = null, array $env = []): Process
{
if (is_array($cmd)) {
$process = new Process($cmd, $cwd);
} else {
$process = Process::fromShellCommandline($cmd, $cwd);
}
$process->run(function($type, $data) use ($process, $io) {
if ($process::ERR === $type) {
$io->getErrorStyle()->write($data);
} else {
$io->write($data);
}
}, $env);
return $process;
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Repository\SettingsRepository;
use App\Entity\Settings;
use App\Form\Form;
use App\Form\SettingsForm;
use App\Http\Request;
use App\Http\Response;
use App\Sync\Task\Backup;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use Psr\Http\Message\ResponseInterface;
class BackupsController
{
/** @var SettingsForm */
protected $settings_form;
/** @var SettingsRepository */
protected $settings_repo;
/** @var Form */
protected $backup_run_form;
/** @var Backup */
protected $backup_task;
/** @var Filesystem */
protected $backup_fs;
/** @var string */
protected $csrf_namespace = 'admin_backups';
/**
* @param SettingsForm $settings_form
* @param Form $backup_run_form
* @param Backup $backup_task
*
* @see \App\Provider\AdminProvider
*/
public function __construct(
SettingsForm $settings_form,
Form $backup_run_form,
Backup $backup_task
)
{
$this->settings_form = $settings_form;
$this->settings_repo = $settings_form->getEntityRepository();
$this->backup_run_form = $backup_run_form;
$this->backup_task = $backup_task;
$this->backup_fs = new Filesystem(new Local(Backup::BASE_DIR));
}
public function __invoke(Request $request, Response $response): ResponseInterface
{
return $request->getView()->renderToResponse($response, 'admin/backups/index', [
'backups' => $this->backup_fs->listContents('', false),
'is_enabled' => (bool)$this->settings_repo->getSetting(Settings::BACKUP_ENABLED, false),
'last_run' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_RUN, 0),
'last_result' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_RESULT, 0),
'last_output' => $this->settings_repo->getSetting(Settings::BACKUP_LAST_OUTPUT, ''),
'csrf' => $request->getSession()->getCsrf()->generate($this->csrf_namespace),
]);
}
public function configureAction(Request $request, Response $response): ResponseInterface
{
if (false !== $this->settings_form->process($request)) {
$request->getSession()->flash(__('Changes saved.'), 'green');
return $response->withRedirect($request->getRouter()->fromHere('admin:backups:index'));
}
return $request->getView()->renderToResponse($response, 'system/form_page', [
'form' => $this->settings_form,
'render_mode' => 'edit',
'title' => __('Configure Backups'),
]);
}
public function runAction(Request $request, Response $response): ResponseInterface
{
// Handle submission.
if ($request->isPost() && $this->backup_run_form->isValid($request->getParsedBody())) {
$data = $this->backup_run_form->getValues();
[$result_code, $result_output] = $this->backup_task->runBackup($data['path'], $data['exclude_media']);
$is_successful = (0 === $result_code);
return $request->getView()->renderToResponse($response, 'admin/backups/run', [
'title' => __('Run Manual Backup'),
'path' => $data['path'],
'is_successful' => $is_successful,
'output' => $result_output,
]);
}
return $request->getView()->renderToResponse($response, 'system/form_page', [
'form' => $this->backup_run_form,
'render_mode' => 'edit',
'title' => __('Run Manual Backup'),
]);
}
public function downloadAction(Request $request, Response $response, $path): ResponseInterface
{
$path = $this->getFilePath($path);
$fh = $this->backup_fs->readStream($path);
$file_meta = $this->backup_fs->getMetadata($path);
try {
$file_mime = $this->backup_fs->getMimetype($path);
} catch(\Exception $e) {
$file_mime = 'application/octet-stream';
}
return $response
->withNoCache()
->withHeader('Content-Type', $file_mime)
->withHeader('Content-Length', $file_meta['size'])
->withHeader('Content-Disposition', sprintf('attachment; filename=%s',
strpos('MSIE', $_SERVER['HTTP_REFERER']) ? rawurlencode($path) : "\"$path\""))
->withHeader('X-Accel-Buffering', 'no')
->withBody(new \Slim\Http\Stream($fh));
}
public function deleteAction(Request $request, Response $response, $path, $csrf_token): ResponseInterface
{
$request->getSession()->getCsrf()->verify($csrf_token, $this->csrf_namespace);
$path = $this->getFilePath($path);
$this->backup_fs->delete($path);
$request->getSession()->flash('<b>' . __('%s deleted.', __('Backup')) . '</b>', 'green');
return $response->withRedirect($request->getRouter()->named('admin:backups:index'));
}
protected function getFilePath($raw_path)
{
$path = base64_decode($raw_path);
$path = basename($path);
if (!$this->backup_fs->has($path)) {
throw new \App\Exception\NotFound(__('%s not found.', 'Backup'));
}
return $path;
}
}

View File

@ -1,14 +1,35 @@
<?php
namespace App\Controller\Admin;
use App\Form\SettingsForm;
use App\Http\Request;
use App\Http\Response;
use Psr\Http\Message\ResponseInterface;
class BrandingController extends SettingsController
class BrandingController
{
/** @var SettingsForm */
protected $form;
/**
* @param SettingsForm $form
*
* @see \App\Provider\AdminProvider
*/
public function __construct(SettingsForm $form)
{
$this->form = $form;
}
public function indexAction(Request $request, Response $response): ResponseInterface
{
return $this->renderSettingsForm($request, $response, 'admin/branding/index');
if (false !== $this->form->process($request)) {
$request->getSession()->flash(__('Changes saved.'), 'green');
return $response->withRedirect($request->getUri()->getPath());
}
return $request->getView()->renderToResponse($response, 'admin/branding/index', [
'form' => $this->form,
]);
}
}

View File

@ -3,51 +3,35 @@ namespace App\Controller\Admin;
use App\Entity;
use App\Form\Form;
use App\Form\SettingsForm;
use App\Http\Request;
use App\Http\Response;
use Psr\Http\Message\ResponseInterface;
class SettingsController
{
/** @var Entity\Repository\SettingsRepository */
protected $settings_repo;
/** @var array */
protected $form_config;
/** @var SettingsForm */
protected $form;
/**
* @param Entity\Repository\SettingsRepository $settings_repo
* @param array $form_config
* @param SettingsForm $form
*
* @see \App\Provider\AdminProvider
*/
public function __construct(Entity\Repository\SettingsRepository $settings_repo, array $form_config)
public function __construct(SettingsForm $form)
{
$this->settings_repo = $settings_repo;
$this->form_config = $form_config;
$this->form = $form;
}
public function indexAction(Request $request, Response $response): ResponseInterface
{
return $this->renderSettingsForm($request, $response, 'system/form_page');
}
protected function renderSettingsForm(Request $request, Response $response, $form_template): ResponseInterface
{
$existing_settings = $this->settings_repo->fetchArray(false);
$form = new Form($this->form_config, $existing_settings);
if ($request->isPost() && $form->isValid($_POST)) {
$data = $form->getValues();
$this->settings_repo->setSettings($data);
if (false !== $this->form->process($request)) {
$request->getSession()->flash(__('Changes saved.'), 'green');
return $response->withRedirect($request->getUri()->getPath());
}
return $request->getView()->renderToResponse($response, $form_template, [
'form' => $form,
return $request->getView()->renderToResponse($response, 'system/form_page', [
'form' => $this->form,
'render_mode' => 'edit',
'title' => __('System Settings'),
]);

View File

@ -35,6 +35,12 @@ class Settings
public const CUSTOM_JS_PUBLIC = 'custom_js_public';
public const CUSTOM_CSS_INTERNAL = 'custom_css_internal';
// Backup settings
public const BACKUP_ENABLED = 'backup_enabled';
public const BACKUP_TIME = 'backup_time';
public const BACKUP_EXCLUDE_MEDIA = 'backup_exclude_media';
public const BACKUP_KEEP_COPIES = 'backup_keep_copies';
// Internal settings
public const SETUP_COMPLETE = 'setup_complete';
@ -52,6 +58,10 @@ class Settings
public const UPDATE_RESULTS = 'central_update_results';
public const UPDATE_LAST_RUN = 'central_update_last_run';
public const BACKUP_LAST_RUN = 'backup_last_run';
public const BACKUP_LAST_RESULT = 'backup_last_result';
public const BACKUP_LAST_OUTPUT = 'backup_last_output';
/**
* @ORM\Column(name="setting_key", type="string", length=64)
* @ORM\Id

View File

@ -656,25 +656,7 @@ class Station
*/
public function setRadioBaseDir($new_dir): void
{
$new_dir = $this->_truncateString(trim($new_dir));
if (strcmp($this->radio_base_dir, $new_dir) !== 0) {
$this->radio_base_dir = $new_dir;
$radio_dirs = [
$this->radio_base_dir,
$this->getRadioMediaDir(),
$this->getRadioAlbumArtDir(),
$this->getRadioPlaylistsDir(),
$this->getRadioConfigDir(),
$this->getRadioTempDir(),
];
foreach ($radio_dirs as $radio_dir) {
if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $radio_dir));
}
}
}
$this->radio_base_dir = $this->_truncateString(trim($new_dir));
}
/**

67
src/Form/SettingsForm.php Normal file
View File

@ -0,0 +1,67 @@
<?php
namespace App\Form;
use App\Entity;
use App\Http\Request;
use Azura\Doctrine\Repository;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class SettingsForm extends Form
{
/** @var EntityManager */
protected $em;
/** @var Entity\Repository\SettingsRepository */
protected $settings_repo;
/**
* @param EntityManager $em
* @param array $form_config
*/
public function __construct(
EntityManager $em,
array $form_config)
{
parent::__construct($form_config);
$this->em = $em;
$this->settings_repo = $em->getRepository(Entity\Settings::class);
}
/**
* @return EntityManager
*/
public function getEntityManager(): EntityManager
{
return $this->em;
}
/**
* @return Entity\Repository\SettingsRepository
*/
public function getEntityRepository(): Entity\Repository\SettingsRepository
{
return $this->settings_repo;
}
/**
* @param Request $request
* @return bool
*/
public function process(Request $request): bool
{
// Populate the form with existing values (if they exist).
$this->populate($this->settings_repo->fetchArray(false));
// Handle submission.
if ($request->isPost() && $this->isValid($request->getParsedBody())) {
$data = $this->getValues();
$this->settings_repo->setSettings($data);
return true;
}
return false;
}
}

View File

@ -1,7 +1,7 @@
<?php
namespace App\Message;
class AddNewMedia extends AbstractMessage
class AddNewMediaMessage extends AbstractMessage
{
/** @var int The numeric identifier for the station. */
public $station_id;

View File

@ -0,0 +1,11 @@
<?php
namespace App\Message;
class BackupMessage extends AbstractMessage
{
/** @var string|null The absolute or relative path of the backup file. */
public $path;
/** @var bool Whether to exclude media, producing a much more compact backup. */
public $exclude_media = false;
}

View File

@ -1,7 +1,7 @@
<?php
namespace App\Message;
class ReprocessMedia extends AbstractMessage
class ReprocessMediaMessage extends AbstractMessage
{
/** @var int The numeric identifier for the StationMedia record being processed. */
public $media_id;

View File

@ -25,13 +25,34 @@ class AdminProvider implements ServiceProviderInterface
);
};
$di[Admin\BrandingController::class] = function($di) {
$di[Admin\BackupsController::class] = function($di) {
/** @var Azura\Config $config */
$config = $di[Azura\Config::class];
$settings_form = new App\Form\SettingsForm(
$di[EntityManager::class],
$config->get('forms/backup')
);
$backup_run_form = new App\Form\Form(
$config->get('forms/backup_run')
);
return new Admin\BackupsController(
$settings_form,
$backup_run_form,
$di[App\Sync\Task\Backup::class]
);
};
$di[Admin\BrandingController::class] = function($di) {
/** @var \Azura\Config $config */
$config = $di[\Azura\Config::class];
$form_config = $config->get('forms/branding', ['settings' => $di['settings']]);
return new Admin\BrandingController(
$di[Entity\Repository\SettingsRepository::class],
$config->get('forms/branding', ['settings' => $di['settings']])
new App\Form\SettingsForm($di[EntityManager::class], $form_config)
);
};
@ -72,8 +93,7 @@ class AdminProvider implements ServiceProviderInterface
$config = $di[\Azura\Config::class];
return new Admin\SettingsController(
$di[Entity\Repository\SettingsRepository::class],
$config->get('forms/settings')
new App\Form\SettingsForm($di[EntityManager::class], $config->get('forms/settings'))
);
};

View File

@ -22,6 +22,7 @@ class SyncProvider implements ServiceProviderInterface
new \Pimple\ServiceIterator($di, [
// Every minute tasks
Task\RadioRequests::class,
Task\Backup::class,
]),
new \Pimple\ServiceIterator($di, [
// Every 5 minutes tasks
@ -46,6 +47,15 @@ class SyncProvider implements ServiceProviderInterface
);
};
$di[Task\Backup::class] = function($di) {
return new Task\Backup(
$di[\Doctrine\ORM\EntityManager::class],
$di[\Monolog\Logger::class],
$di[\App\MessageQueue::class],
$di[\Azura\Console\Application::class]
);
};
$di[Task\CheckForUpdates::class] = function($di) {
return new Task\CheckForUpdates(
$di[\Doctrine\ORM\EntityManager::class],

View File

@ -35,10 +35,6 @@ class Configuration
* @param Station $station
* @param bool $regen_auth_key
* @param bool $force_restart Always restart this station's supervisor instances, even if nothing changed.
*
* @throws \App\Exception\NotFound
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function writeConfiguration(Station $station, $regen_auth_key = false, $force_restart = false): void
{
@ -76,6 +72,21 @@ class Configuration
return;
}
// Ensure all directories exist.
$radio_dirs = [
$station->getRadioBaseDir(),
$station->getRadioMediaDir(),
$station->getRadioAlbumArtDir(),
$station->getRadioPlaylistsDir(),
$station->getRadioConfigDir(),
$station->getRadioTempDir(),
];
foreach ($radio_dirs as $radio_dir) {
if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $radio_dir));
}
}
// Write config files for both backend and frontend.
$frontend->write($station);
$backend->write($station);

122
src/Sync/Task/Backup.php Normal file
View File

@ -0,0 +1,122 @@
<?php
namespace App\Sync\Task;
use App\MessageQueue;
use App\Message;
use Azura\Console\Application;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManager;
use App\Entity;
use Monolog\Logger;
class Backup extends AbstractTask
{
public const BASE_DIR = '/var/azuracast/backups';
/** @var Entity\Repository\SettingsRepository */
protected $settings_repo;
/** @var MessageQueue */
protected $message_queue;
/** @var Application */
protected $console;
/**
* @param EntityManager $em
* @param Logger $logger
* @param MessageQueue $message_queue
* @param Application $console
*
* @see \App\Provider\SyncProvider
*/
public function __construct(
EntityManager $em,
Logger $logger,
MessageQueue $message_queue,
Application $console
) {
parent::__construct($em, $logger);
$this->settings_repo = $em->getRepository(Entity\Settings::class);
$this->message_queue = $message_queue;
$this->console = $console;
}
/**
* Handle event dispatch.
*
* @param Message\AbstractMessage $message
*/
public function __invoke(Message\AbstractMessage $message)
{
if ($message instanceof Message\BackupMessage) {
[$result_code, $result_output] = $this->runBackup(
$message->path,
$message->exclude_media
);
$this->settings_repo->setSettings([
Entity\Settings::BACKUP_LAST_RUN => time(),
Entity\Settings::BACKUP_LAST_RESULT => $result_code,
Entity\Settings::BACKUP_LAST_OUTPUT => $result_output,
]);
}
}
/**
* @param string|null $path
* @param bool $exclude_media
* @return array [$result_code, $result_output]
*/
public function runBackup($path = null, $exclude_media = false): array
{
$input_params = [];
if (null !== $path) {
$input_params['path'] = $path;
}
if ($exclude_media) {
$input_params['--exclude-media'] = true;
}
return $this->console->runCommand('azuracast:backup', $input_params);
}
/**
* @inheritdoc
*/
public function run($force = false): void
{
$logging_enabled = (bool)$this->settings_repo->getSetting(Entity\Settings::BACKUP_ENABLED, 0);
if (!$logging_enabled) {
$this->logger->debug('Automated backups disabled; skipping...');
return;
}
$now_utc = Chronos::now('UTC');
$threshold = $now_utc->subDay()->getTimestamp();
$last_run = $this->settings_repo->getSetting(Entity\Settings::BACKUP_LAST_RUN, 0);
if ($last_run <= $threshold) {
// Check if the backup time matches (if it's set).
$backup_timecode = (int)$this->settings_repo->getSetting(Entity\Settings::BACKUP_TIME);
if (0 !== $backup_timecode) {
$current_timecode = $now_utc->format('Hi');
if ($backup_timecode !== $current_timecode) {
return;
}
}
// Trigger a new backup.
$message = new Message\BackupMessage;
$message->path = 'automatic_backup.tar.gz';
$message->exclude_media = (bool)$this->settings_repo->getSetting(Entity\Settings::BACKUP_EXCLUDE_MEDIA, 0);
$this->message_queue->produce($message);
}
}
}

View File

@ -52,7 +52,7 @@ class Media extends AbstractTask
$media_repo = $this->em->getRepository(Entity\StationMedia::class);
try {
if ($message instanceof Message\ReprocessMedia) {
if ($message instanceof Message\ReprocessMediaMessage) {
$media_row = $media_repo->find($message->media_id);
if ($media_row instanceof Entity\StationMedia) {
@ -60,7 +60,7 @@ class Media extends AbstractTask
$this->em->flush($media_row);
}
} else if ($message instanceof Message\AddNewMedia) {
} else if ($message instanceof Message\AddNewMediaMessage) {
$station = $this->em->find(Entity\Station::class, $message->station_id);
if ($station instanceof Entity\Station) {
@ -151,7 +151,7 @@ class Media extends AbstractTask
$file_info = $music_files[$path_hash];
if ($force_reprocess || $media_row->needsReprocessing($file_info['timestamp'])) {
$message = new Message\ReprocessMedia;
$message = new Message\ReprocessMediaMessage;
$message->media_id = $media_row->getId();
$message->force = $force_reprocess;
@ -182,7 +182,7 @@ class Media extends AbstractTask
// Create files that do not currently exist.
foreach ($music_files as $path_hash => $new_music_file) {
$message = new Message\AddNewMedia;
$message = new Message\AddNewMediaMessage;
$message->station_id = $station->getId();
$message->path = $new_music_file['path'];

View File

@ -65,6 +65,18 @@ class RotateLogs extends AbstractTask
$rotate->keep(5);
$rotate->size('5MB');
$rotate->run();
// Rotate the automated backups.
/** @var Entity\Repository\SettingsRepository $settings_repo */
$settings_repo = $this->em->getRepository(Entity\Settings::class);
$backups_to_keep = (int)$settings_repo->getSetting(Entity\Settings::BACKUP_KEEP_COPIES, 0);
if ($backups_to_keep > 0) {
$rotate = new Rotate\Rotate(Backup::BASE_DIR . '/automatic_backup.tar.gz');
$rotate->keep($backups_to_keep);
$rotate->run();
}
}
/**

View File

@ -0,0 +1,45 @@
$(function() {
moment.relativeTimeThreshold('ss', 1);
moment.relativeTimeRounding(function (value) {
return Math.round(value * 10) / 10;
});
$('time[data-content]').each(function () {
let tz_display = $(this).data('content');
$(this).text(moment.unix(tz_display).format('lll'));
});
$('time[data-duration]').each(function () {
$(this).text(moment.duration($(this).data('duration'), "seconds").humanize(true));
});
$('span[data-file-size]').each(function() {
let original_size = $(this).data('file-size');
$(this).text(formatFileSize(original_size));
});
function formatFileSize(bytes) {
var s = ['bytes', 'KB','MB','GB','TB','PB','EB'];
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1000);
var d = Math.round(bytes*10);
return pos ? [parseInt(d/10),".",d%10," ",s[pos]].join('') : bytes + ' bytes';
}
var log_modal = $('#modal-log-view');
if (log_modal.length > 0) {
log_modal.modal({
focus: false,
show: false
});
$('#btn-view-log').on('click', function(e) {
e.preventDefault();
log_modal.modal('show');
return false;
});
}
});

View File

@ -0,0 +1,141 @@
<?php
/**
* @var array $backups
* @var \Azura\Assets $assets
*/
$this->layout('main', [
'title' => __('Backups'),
'manual' => true
]);
$assets
->load('moment')
->addInlineJs($this->fetch('admin/backups/index.js'), 99);
?>
<div class="card-deck">
<div class="card mb-3">
<?php if ($is_enabled): ?>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<?=__('Automatic Backups') ?>
<small class="badge badge-pill badge-success"><?=__('Enabled') ?></small>
</h3>
</div>
<div class="card-body">
<p class="card-text">
<?php if ($last_run > 0): ?>
<?=__('Last run: %s', '<time data-duration="'.(0-(time()-$last_run)).'"></time>') ?>
<?php else: ?>
<?=__('Never run') ?>
<?php endif; ?>
</p>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:configure') ?>">
<i class="material-icons" aria-hidden="true">settings</i>
<?=__('Configure') ?>
</a>
<?php if (!empty($last_output)): ?>
<a class="btn btn-outline-secondary" id="btn-view-log" href="#">
<i class="material-icons" aria-hidden="true">assignment</i>
<?=__('Most Recent Backup Log') ?>
</a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<?=__('Automatic Backups') ?>
<small class="badge badge-pill badge-danger"><?=__('Disabled') ?></small>
</h3>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:configure') ?>">
<i class="material-icons" aria-hidden="true">settings</i>
<?=__('Configure') ?>
</a>
</div>
<?php endif; ?>
</div>
<div class="card mb-3">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=__('Restoring Backups') ?></h3>
</div>
<div class="card-body">
<p class="card-text"><?=__('To restore a backup from your host computer, run:') ?></p>
<?php if (APP_INSIDE_DOCKER): ?>
<pre><code>./docker.sh restore path_to_backup.tar.gz</code></pre>
<?php else: ?>
<pre><code>/var/azuracast/www/bin/azuracast azuracast:restore path_to_backup.tar.gz</code></pre>
<?php endif; ?>
<p class="card-text text-warning"><?=__('Note that restoring a backup will clear your existing database. Never restore backup files from untrusted users.') ?></p>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=__('Backups') ?></h3>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" role="button" href="<?=$router->named('admin:backups:run') ?>">
<i class="material-icons" aria-hidden="true">send</i>
<?=__('Run Manual Backup') ?>
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<colgroup>
<col width="20%">
<col width="35%">
<col width="25%">
<col width="20%">
</colgroup>
<thead>
<tr>
<th><?=__('Actions') ?></th>
<th><?=__('Backup') ?></th>
<th><?=__('Last Modified') ?></th>
<th><?=__('Size') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($backups as $row): ?>
<tr class="align-middle">
<td>
<div class="btn-group btn-group-sm">
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('admin:backups:download', ['path' => base64_encode($row['path'])]) ?>"><?=__('Download') ?></a>
<a class="btn btn-sm btn-danger" href="<?=$router->fromHere('admin:backups:delete', ['path' => base64_encode($row['path']), 'csrf' => $csrf]) ?>" data-confirm-title="<?=$this->e(__('Delete backup "%s"?', $row['filename'])) ?>"><?=__('Delete') ?></a>
</div>
</td>
<td>
<big><?=$this->e($row['basename']) ?></big>
</td>
<td><time data-content="<?=$row['timestamp'] ?>"><?=gmdate('Y-m-d', $row['timestamp']) ?></time></td>
<td><span data-file-size="<?=$row['size'] ?>"><?=$row['size'] ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (!empty($last_output)): ?>
<div class="modal fade" id="modal-log-view" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="modal-log-view-label"><?=__('Most Recent Backup Log') ?></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<pre id="modal-log-view-contents"><?=$last_output ?></pre>
</div>
</div>
</div>
</div>
<?php endif; ?>

View File

@ -0,0 +1,34 @@
<?php
/**
* @var array $backups
* @var \Azura\Assets $assets
*/
$this->layout('main', [
'title' => __('Run Manual Backup'),
'manual' => true
]);
?>
<div class="card mb-3">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=__('Run Manual Backup') ?></h3>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->fromHere('admin:backups:index') ?>">
<i class="material-icons" aria-hidden="true">arrow_back</i>
<?=__('Backups Home') ?>
</a>
</div>
<div class="card-body">
<?php if ($is_successful): ?>
<p class="card-text text-success"><?=__('Backup was run successfully.') ?></p>
<?php else: ?>
<p class="card-text text-danger"><?=__('Backup encountered errors when running. Check the log below for details.') ?></p>
<?php endif; ?>
<pre id="modal-log-view-contents">
<?=$this->e($output) ?>
</pre>
</div>
</div>

View File

@ -1,5 +1,9 @@
<?php
/** @var \Azura\Assets $assets */
/**
* @var \App\Form\Form $form
* @var \Azura\Assets $assets
*/
$assets
->load('codemirror_css')
->addInlineJs($this->fetch('admin/branding/index.js'), 99);
@ -7,5 +11,4 @@ $assets
echo $this->fetch('system/form_page', [
'title' => __('Custom Branding'),
'form' => $form,
'render_mode' => 'edit'
]);

View File

@ -8,7 +8,7 @@ $assets
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="modal-log-view-label">Log View</h4>
<h4 class="modal-title" id="modal-log-view-label"><?=__('Log View') ?></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">

View File

@ -417,7 +417,7 @@ $(function() {
function formatFileSize(bytes) {
var s = ['bytes', 'KB','MB','GB','TB','PB','EB'];
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1024);
for(var pos = 0;bytes >= 1000; pos++,bytes /= 1000);
var d = Math.round(bytes*10);
return pos ? [parseInt(d/10),".",d%10," ",s[pos]].join('') : bytes + ' bytes';
}

View File

@ -21,6 +21,7 @@
- "{{ tmp_base }}/proxies"
- "{{ app_base }}/stations"
- "{{ app_base }}/geoip"
- "{{ app_base }}/backups"
- "{{ app_base }}/servers"
- "{{ app_base }}/servers/shoutcast2"
- "{{ app_base }}/servers/icecast2"