1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-12-25 02:30:20 +02:00
New: Backup drone's database and configuration from the UI
New: Download Backup files from the UI

Fixed: Run a database backup before upgrade
This commit is contained in:
Mark McDowall 2014-05-30 10:34:38 -07:00
parent d74e461aea
commit c5bd8b27fb
32 changed files with 552 additions and 27 deletions

1
.gitignore vendored
View File

@ -84,7 +84,6 @@ Generated_Code #added for RIA/Silverlight projects
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
# SQL Server files

View File

@ -0,0 +1,31 @@
using System.IO;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Api.Frontend.Mappers
{
public class BackupFileMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
}
protected override string Map(string resourceUrl)
{
var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(_appFolderInfo.GetBackupFolder(), path);
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("nzbdrone_backup_") && resourceUrl.EndsWith(".zip");
}
}
}

View File

@ -131,6 +131,7 @@
<Compile Include="Extensions\RequestExtensions.cs" />
<Compile Include="Frontend\IsCacheableSpecification.cs" />
<Compile Include="Frontend\Mappers\UpdateLogFileMapper.cs" />
<Compile Include="Frontend\Mappers\BackupFileMapper.cs" />
<Compile Include="Frontend\Mappers\FaviconMapper.cs" />
<Compile Include="Frontend\Mappers\IndexHtmlMapper.cs" />
<Compile Include="Frontend\Mappers\LogFileMapper.cs" />
@ -165,6 +166,8 @@
<Compile Include="Mapping\ResourceMappingException.cs" />
<Compile Include="Mapping\ValueInjectorExtensions.cs" />
<Compile Include="Series\AlternateTitleResource.cs" />
<Compile Include="System\Backup\BackupModule.cs" />
<Compile Include="System\Backup\BackupResource.cs" />
<Compile Include="Update\UpdateResource.cs" />
<Compile Include="Wanted\CutoffModule.cs" />
<Compile Include="Wanted\LegacyMissingModule.cs" />

View File

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NzbDrone.Core.Backup;
namespace NzbDrone.Api.System.Backup
{
public class BackupModule : NzbDroneRestModule<BackupResource>
{
private readonly IBackupService _backupService;
public BackupModule(IBackupService backupService) : base("system/backup")
{
_backupService = backupService;
GetResourceAll = GetBackupFiles;
}
public List<BackupResource> GetBackupFiles()
{
var backups = _backupService.GetBackups();
return backups.Select(b => new BackupResource
{
Id = b.Path.GetHashCode(),
Name = Path.GetFileName(b.Path),
Path = b.Path,
Type = b.Type,
Time = b.Time
}).ToList();
}
}
}

View File

@ -0,0 +1,14 @@
using System;
using NzbDrone.Api.REST;
using NzbDrone.Core.Backup;
namespace NzbDrone.Api.System.Backup
{
public class BackupResource : RestResource
{
public String Name { get; set; }
public String Path { get; set; }
public BackupType Type { get; set; }
public DateTime Time { get; set; }
}
}

View File

@ -172,7 +172,7 @@ public void should_be_able_to_delete_directory_with_read_only_file()
public void empty_folder_should_return_folder_modified_date()
{
var tempfolder = new DirectoryInfo(TempFolder);
Subject.FolderGetLastWrite(TempFolder).Should().Be(tempfolder.LastWriteTimeUtc);
Subject.FolderGetLastWriteUtc(TempFolder).Should().Be(tempfolder.LastWriteTimeUtc);
}
[Test]
@ -189,8 +189,8 @@ public void folder_should_return_correct_value_for_last_write()
Subject.WriteAllText(testFile, "Test");
Subject.FolderGetLastWrite(TempFolder).Should().BeOnOrAfter(DateTime.UtcNow.AddMinutes(-1));
Subject.FolderGetLastWrite(TempFolder).Should().BeBefore(DateTime.UtcNow.AddMinutes(1));
Subject.FolderGetLastWriteUtc(TempFolder).Should().BeOnOrAfter(DateTime.UtcNow.AddMinutes(-1));
Subject.FolderGetLastWriteUtc(TempFolder).Should().BeBefore(DateTime.UtcNow.AddMinutes(1));
}
[Test]
@ -238,7 +238,7 @@ public void should_be_able_to_set_permission_from_parrent()
[Explicit]
public void check_last_write()
{
Console.WriteLine(Subject.FolderGetLastWrite(GetFilledTempFolder().FullName));
Console.WriteLine(Subject.FolderGetLastWriteUtc(GetFilledTempFolder().FullName));
Console.WriteLine(GetFilledTempFolder().LastWriteTimeUtc);
}

View File

@ -12,6 +12,8 @@ namespace NzbDrone.Common
public interface IArchiveService
{
void Extract(string compressedFile, string destination);
void ExtractZip(string compressedFile, string destination);
void CreateZip(string path, params string[] files);
}
public class ArchiveService : IArchiveService
@ -40,7 +42,22 @@ public void Extract(string compressedFile, string destination)
_logger.Debug("Extraction complete.");
}
private void ExtractZip(string compressedFile, string destination)
public void CreateZip(string path, params string[] files)
{
using (var zipFile = ZipFile.Create(path))
{
zipFile.BeginUpdate();
foreach (var file in files)
{
zipFile.Add(file, Path.GetFileName(file));
}
zipFile.CommitUpdate();
}
}
public void ExtractZip(string compressedFile, string destination)
{
using (var fileStream = File.OpenRead(compressedFile))
{

View File

@ -33,7 +33,7 @@ public DateTime FolderGetCreationTimeUtc(string path)
return new DirectoryInfo(path).CreationTimeUtc;
}
public DateTime FolderGetLastWrite(string path)
public DateTime FolderGetLastWriteUtc(string path)
{
CheckFolderExists(path);

View File

@ -12,7 +12,7 @@ public interface IDiskProvider
void SetPermissions(string path, string mask, string user, string group);
long? GetTotalSize(string path);
DateTime FolderGetCreationTimeUtc(string path);
DateTime FolderGetLastWrite(string path);
DateTime FolderGetLastWriteUtc(string path);
DateTime FileGetCreationTimeUtc(string path);
DateTime FileGetLastWrite(string path);
DateTime FileGetLastWriteUtc(string path);

View File

@ -59,8 +59,8 @@
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="ArchiveProvider.cs" />
<Compile Include="ConvertBase32.cs" />
<Compile Include="ArchiveService.cs" />
<Compile Include="Cache\Cached.cs" />
<Compile Include="Cache\CacheManager.cs" />
<Compile Include="Cache\ICached.cs" />

View File

@ -11,9 +11,9 @@ public static class PathExtensions
private const string APP_CONFIG_FILE = "config.xml";
private const string NZBDRONE_DB = "nzbdrone.db";
private const string NZBDRONE_LOG_DB = "logs.db";
private const string BACKUP_ZIP_FILE = "NzbDrone_Backup.zip";
private const string NLOG_CONFIG_FILE = "nlog.config";
private const string UPDATE_CLIENT_EXE = "NzbDrone.Update.exe";
private const string BACKUP_FOLDER = "Backups";
private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "nzbdrone_update" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "NzbDrone" + Path.DirectorySeparatorChar;
@ -226,9 +226,9 @@ public static string GetUpdateClientExePath(this IAppFolderInfo appFolderInfo)
return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE);
}
public static string GetConfigBackupFile(this IAppFolderInfo appFolderInfo)
public static string GetBackupFolder(this IAppFolderInfo appFolderInfo)
{
return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_ZIP_FILE);
return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER);
}
public static string GetNzbDroneDatabase(this IAppFolderInfo appFolderInfo)

View File

@ -18,7 +18,7 @@ public class CleanupFixture : CoreTest
private void WithExpired()
{
Mocker.GetMock<IDiskProvider>().Setup(s => s.FolderGetLastWrite(It.IsAny<String>()))
Mocker.GetMock<IDiskProvider>().Setup(s => s.FolderGetLastWriteUtc(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-10));
Mocker.GetMock<IDiskProvider>().Setup(s => s.FileGetLastWriteUtc(It.IsAny<String>()))
@ -27,7 +27,7 @@ private void WithExpired()
private void WithNonExpired()
{
Mocker.GetMock<IDiskProvider>().Setup(s => s.FolderGetLastWrite(It.IsAny<String>()))
Mocker.GetMock<IDiskProvider>().Setup(s => s.FolderGetLastWriteUtc(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-3));
Mocker.GetMock<IDiskProvider>().Setup(s => s.FileGetLastWriteUtc(It.IsAny<String>()))

View File

@ -0,0 +1,11 @@
using System;
namespace NzbDrone.Core.Backup
{
public class Backup
{
public String Path { get; set; }
public BackupType Type { get; set; }
public DateTime Time { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.Backup
{
public class BackupCommand : Command
{
public BackupType Type { get; set; }
public override bool SendUpdatesToClient
{
get
{
return true;
}
}
}
public enum BackupType
{
Scheduled = 0 ,
Manual = 1,
Update = 2
}
}

View File

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Marr.Data;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Instrumentation.Extensions;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.Backup
{
public interface IBackupService
{
void Backup(BackupType backupType);
List<Backup> GetBackups();
}
public class BackupService : IBackupService, IExecute<BackupCommand>
{
private readonly IDatabase _maindDb;
private readonly IDiskProvider _diskProvider;
private readonly IAppFolderInfo _appFolderInfo;
private readonly IArchiveService _archiveService;
private readonly Logger _logger;
private string _backupTempFolder;
private static readonly Regex BackupFileRegex = new Regex(@"nzbdrone_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public BackupService(IDatabase maindDb,
IDiskProvider diskProvider,
IAppFolderInfo appFolderInfo,
IArchiveService archiveService,
Logger logger)
{
_maindDb = maindDb;
_diskProvider = diskProvider;
_appFolderInfo = appFolderInfo;
_archiveService = archiveService;
_logger = logger;
_backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "nzbdrone_backup");
}
public void Backup(BackupType backupType)
{
_logger.ProgressInfo("Starting Backup");
var backupFilename = String.Format("nzbdrone_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now);
var backupPath = Path.Combine(GetBackupFolder(backupType), backupFilename);
Cleanup();
if (backupType != BackupType.Manual)
{
CleanupOldBackups(backupType);
}
_diskProvider.EnsureFolder(_backupTempFolder);
_diskProvider.EnsureFolder(_appFolderInfo.GetBackupFolder());
BackupConfigFile();
BackupDatabase();
_logger.ProgressDebug("Creating backup zip");
_archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly));
_logger.ProgressDebug("Backup zip created");
}
public List<Backup> GetBackups()
{
var backups = new List<Backup>();
foreach (var backupType in Enum.GetValues(typeof(BackupType)).Cast<BackupType>())
{
var folder = GetBackupFolder(backupType);
if (_diskProvider.FolderExists(folder))
{
backups.AddRange(GetBackupFiles(folder).Select(b => new Backup
{
Path = Path.GetFileName(b),
Type = backupType,
Time = _diskProvider.FileGetLastWriteUtc(b)
}));
}
}
return backups;
}
private void Cleanup()
{
if (_diskProvider.FolderExists(_backupTempFolder))
{
_diskProvider.EmptyFolder(_backupTempFolder);
}
}
private void BackupDatabase()
{
_logger.ProgressDebug("Backing up database");
using (var unitOfWork = new UnitOfWork(() => _maindDb.GetDataMapper()))
{
unitOfWork.BeginTransaction();
var databaseFile = _appFolderInfo.GetNzbDroneDatabase();
var tempDatabaseFile = Path.Combine(_backupTempFolder, Path.GetFileName(databaseFile));
_diskProvider.CopyFile(databaseFile, tempDatabaseFile, true);
unitOfWork.Commit();
}
}
private void BackupConfigFile()
{
_logger.ProgressDebug("Backing up config.xml");
var configFile = _appFolderInfo.GetConfigPath();
var tempConfigFile = Path.Combine(_backupTempFolder, Path.GetFileName(configFile));
_diskProvider.CopyFile(configFile, tempConfigFile, true);
}
private void CleanupOldBackups(BackupType backupType)
{
_logger.Debug("Cleaning up old backup files");
var files = GetBackupFiles(GetBackupFolder(backupType));
foreach (var file in files)
{
var lastWriteTime = _diskProvider.FileGetLastWriteUtc(file);
if (lastWriteTime.AddDays(28) < DateTime.UtcNow)
{
_logger.Debug("Deleting old backup file: {0}", file);
_diskProvider.DeleteFile(file);
}
}
_logger.Debug("Finished cleaning up old backup files");
}
private String GetBackupFolder(BackupType backupType)
{
return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower());
}
private IEnumerable<String> GetBackupFiles(String path)
{
var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly);
return files.Where(f => BackupFileRegex.IsMatch(f));
}
public void Execute(BackupCommand message)
{
Backup(message.Type);
}
}
}

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.Backup;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.DataAugmentation.Scene;
@ -56,6 +57,7 @@ public void Handle(ApplicationStartedEvent message)
new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName},
new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshSeriesCommand).FullName},
new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName},
new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName},
new ScheduledTask
{

View File

@ -128,7 +128,7 @@ public void Cleanup()
foreach (var folder in _diskProvider.GetDirectories(_configService.RecycleBin))
{
if (_diskProvider.FolderGetLastWrite(folder).AddDays(7) > DateTime.UtcNow)
if (_diskProvider.FolderGetLastWriteUtc(folder).AddDays(7) > DateTime.UtcNow)
{
logger.Debug("Folder hasn't expired yet, skipping: {0}", folder);
continue;

View File

@ -104,6 +104,9 @@
<Link>Properties\SharedAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Annotations\FieldDefinitionAttribute.cs" />
<Compile Include="Backup\Backup.cs" />
<Compile Include="Backup\BackupCommand.cs" />
<Compile Include="Backup\BackupService.cs" />
<Compile Include="Blacklisting\Blacklist.cs" />
<Compile Include="Blacklisting\BlacklistRepository.cs" />
<Compile Include="Blacklisting\BlacklistService.cs" />

View File

@ -168,3 +168,7 @@ td.delete-episode-file-cell {
.episode-number-cell {
cursor : default;
}
.backup-type-cell {
width : 20px;
}

View File

@ -95,6 +95,8 @@
}
th {
cursor : default;
&.sortable {
&:hover {
background : @table-bg-hover;

View File

@ -12,7 +12,6 @@ define(
'Release/ReleaseLayout',
'System/SystemLayout',
'SeasonPass/SeasonPassLayout',
'System/Update/UpdateLayout',
'Series/Editor/SeriesEditorLayout'
], function (NzbDroneController,
AppLayout,
@ -25,7 +24,6 @@ define(
ReleaseLayout,
SystemLayout,
SeasonPassLayout,
UpdateLayout,
SeriesEditorLayout) {
return NzbDroneController.extend({
@ -71,11 +69,6 @@ define(
this.showMainRegion(new SeasonPassLayout());
},
update: function () {
this.setTitle('Updates');
this.showMainRegion(new UpdateLayout());
},
seriesEditor: function () {
this.setTitle('Series Editor');
this.showMainRegion(new SeriesEditorLayout());

View File

@ -0,0 +1,19 @@
'use strict';
define(
[
'backbone.pageable',
'System/Backup/BackupModel'
], function (PageableCollection, BackupModel) {
return PageableCollection.extend({
url : window.NzbDrone.ApiRoot + '/system/backup',
model: BackupModel,
state: {
sortKey : 'time',
order : 1,
pageSize : 100000
},
mode: 'client'
});
});

View File

@ -0,0 +1,10 @@
'use strict';
define(
[
'marionette'
], function (Marionette) {
return Marionette.ItemView.extend({
template: 'System/Backup/BackupEmptyViewTemplate'
});
});

View File

@ -0,0 +1 @@
<div>No backups are available</div>

View File

@ -0,0 +1,12 @@
'use strict';
define(
[
'Cells/TemplatedCell'
], function (TemplatedCell) {
return TemplatedCell.extend({
className: 'series-title',
template : 'System/Backup/BackupFilenameCellTemplate'
});
});

View File

@ -0,0 +1,2 @@
<a href="{{urlBack}}/backup/{{type}}/{{name}}" class="no-router">{{name}}</a>

View File

@ -0,0 +1,106 @@
'use strict';
define(
[
'vent',
'marionette',
'backgrid',
'System/Backup/BackupCollection',
'Cells/RelativeDateCell',
'System/Backup/BackupFilenameCell',
'System/Backup/BackupTypeCell',
'System/Backup/BackupEmptyView',
'Shared/LoadingView',
'Shared/Toolbar/ToolbarLayout'
], function (vent, Marionette, Backgrid, BackupCollection, RelativeDateCell, BackupFilenameCell, BackupTypeCell, EmptyView, LoadingView, ToolbarLayout) {
return Marionette.Layout.extend({
template: 'System/Backup/BackupLayoutTemplate',
regions: {
backups : '#x-backups',
toolbar : '#x-backup-toolbar'
},
columns: [
{
name : 'type',
label : '',
sortable : false,
cell : BackupTypeCell
},
{
name : 'this',
label : 'Name',
sortable : false,
cell : BackupFilenameCell
},
{
name : 'time',
label : 'Time',
sortable : false,
cell : RelativeDateCell
}
],
leftSideButtons: {
type : 'default',
storeState: false,
collapse : false,
items :
[
{
title : 'Backup',
icon : 'icon-file-text',
command : 'backup',
properties : { type: 'manual' },
successMessage: 'Database and settings were backed up successfully',
errorMessage : 'Backup Failed!'
}
]
},
initialize: function () {
this.backupCollection = new BackupCollection();
this.listenTo(this.backupCollection, 'sync', this._showBackups);
this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete);
},
onRender: function () {
this._showToolbar();
this.backups.show(new LoadingView());
this.backupCollection.fetch();
},
_showBackups: function () {
if (this.backupCollection.length === 0) {
this.backups.show(new EmptyView());
}
else {
this.backups.show(new Backgrid.Grid({
columns : this.columns,
collection: this.backupCollection,
className : 'table table-hover'
}));
}
},
_showToolbar : function () {
this.toolbar.show(new ToolbarLayout({
left :
[
this.leftSideButtons
],
context: this
}));
},
_commandComplete: function (options) {
if (options.command.get('name') === 'backup') {
this.backupCollection.fetch();
}
}
});
});

View File

@ -0,0 +1,10 @@
<div class="row">
<div class="col-md-12">
<div id="x-backup-toolbar"/>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div id="x-backups" class="table-responsive"/>
</div>
</div>

View File

@ -0,0 +1,9 @@
'use strict';
define(
[
'backbone'
], function (Backbone) {
return Backbone.Model.extend({
});
});

View File

@ -0,0 +1,33 @@
'use strict';
define(
[
'Cells/NzbDroneCell'
], function (NzbDroneCell) {
return NzbDroneCell.extend({
className: 'backup-type-cell',
render: function () {
this.$el.empty();
var icon = 'icon-time';
var title = 'Scheduled';
var type = this.model.get(this.column.get('name'));
if (type === 'manual') {
icon = 'icon-book';
title = 'Manual';
}
else if (type === 'update') {
icon = 'icon-retweet';
title = 'Before update';
}
this.$el.html('<i class="{0}" title="{1}"></i>'.format(icon, title));
return this;
}
});
});

View File

@ -7,6 +7,7 @@ define(
'System/Info/SystemInfoLayout',
'System/Logs/LogsLayout',
'System/Update/UpdateLayout',
'System/Backup/BackupLayout',
'Shared/Messenger'
], function ($,
Backbone,
@ -14,26 +15,30 @@ define(
SystemInfoLayout,
LogsLayout,
UpdateLayout,
BackupLayout,
Messenger) {
return Marionette.Layout.extend({
template: 'System/SystemLayoutTemplate',
regions: {
info : '#info',
info : '#info',
logs : '#logs',
updates: '#updates'
updates : '#updates',
backup : '#backup'
},
ui: {
infoTab : '.x-info-tab',
logsTab : '.x-logs-tab',
updatesTab: '.x-updates-tab'
infoTab : '.x-info-tab',
logsTab : '.x-logs-tab',
updatesTab : '.x-updates-tab',
backupTab : '.x-backup-tab'
},
events: {
'click .x-info-tab' : '_showInfo',
'click .x-logs-tab' : '_showLogs',
'click .x-updates-tab': '_showUpdates',
'click .x-backup-tab': '_showBackup',
'click .x-shutdown' : '_shutdown',
'click .x-restart' : '_restart'
},
@ -52,6 +57,9 @@ define(
case 'updates':
this._showUpdates();
break;
case 'backup':
this._showBackup();
break;
default:
this._showInfo();
}
@ -91,6 +99,16 @@ define(
this._navigate('system/updates');
},
_showBackup: function (e) {
if (e) {
e.preventDefault();
}
this.backup.show(new BackupLayout());
this.ui.backupTab.tab('show');
this._navigate('system/backup');
},
_shutdown: function () {
$.ajax({
url: window.NzbDrone.ApiRoot + '/system/shutdown',

View File

@ -2,6 +2,7 @@
<li><a href="#info" class="x-info-tab no-router">Info</a></li>
<li><a href="#logs" class="x-logs-tab no-router">Logs</a></li>
<li><a href="#updates" class="x-updates-tab no-router">Updates</a></li>
<li><a href="#backup" class="x-backup-tab no-router">Backup</a></li>
<li class="lifecycle-controls pull-right">
<div class="btn-group">
<button class="btn btn-default btn-icon-only x-shutdown" title="Shutdown" data-container="body">
@ -18,4 +19,5 @@
<div class="tab-pane" id="info"></div>
<div class="tab-pane" id="logs"></div>
<div class="tab-pane" id="updates"></div>
<div class="tab-pane" id="backup"></div>
</div>