diff --git a/src/NzbDrone.Api/Health/HealthModule.cs b/src/NzbDrone.Api/Health/HealthModule.cs new file mode 100644 index 000000000..e658a9a0b --- /dev/null +++ b/src/NzbDrone.Api/Health/HealthModule.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.HealthCheck; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Api.Health +{ + public class HealthModule : NzbDroneRestModuleWithSignalR, + IHandle + { + private readonly IHealthCheckService _healthCheckService; + + public HealthModule(ICommandExecutor commandExecutor, IHealthCheckService healthCheckService) + : base(commandExecutor) + { + _healthCheckService = healthCheckService; + GetResourceAll = GetHealth; + } + + private List GetHealth() + { + return ToListResource(_healthCheckService.PerformHealthCheck); + } + + public void Handle(TriggerHealthCheckEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Health/HistoryResource.cs b/src/NzbDrone.Api/Health/HistoryResource.cs new file mode 100644 index 000000000..ab490b449 --- /dev/null +++ b/src/NzbDrone.Api/Health/HistoryResource.cs @@ -0,0 +1,13 @@ +using System; +using NzbDrone.Api.REST; +using NzbDrone.Core.HealthCheck; + + +namespace NzbDrone.Api.Health +{ + public class HealthResource : RestResource + { + public HealthCheckResultType Type { get; set; } + public String Message { get; set; } + } +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index c71505e86..177d606df 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -132,6 +132,8 @@ + + diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs index 6ba89f5ea..032d14426 100644 --- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs +++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs @@ -92,7 +92,7 @@ namespace NzbDrone.Automation.Test.PageModel { get { - return Find(By.LinkText("System")); + return Find(By.PartialLinkText("System")); } } diff --git a/src/NzbDrone.Common/StringExtensions.cs b/src/NzbDrone.Common/StringExtensions.cs index 1c4e7b54f..77ad367cb 100644 --- a/src/NzbDrone.Common/StringExtensions.cs +++ b/src/NzbDrone.Common/StringExtensions.cs @@ -52,5 +52,10 @@ namespace NzbDrone.Common { return CollapseSpace.Replace(text, " ").Trim(); } + + public static bool IsNullOrWhiteSpace(this string text) + { + return String.IsNullOrWhiteSpace(text); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs new file mode 100644 index 000000000..6d0b613df --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class DownloadClientCheckFixture : CoreTest + { + [Test] + public void should_return_warning_when_download_client_has_not_been_configured() + { + Mocker.GetMock() + .Setup(s => s.GetDownloadClient()) + .Returns((IDownloadClient)null); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_error_when_download_client_throws() + { + var downloadClient = Mocker.GetMock(); + + downloadClient.Setup(s => s.GetQueue()) + .Throws(); + + Mocker.GetMock() + .Setup(s => s.GetDownloadClient()) + .Returns(downloadClient.Object); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_null_when_download_client_returns() + { + var downloadClient = Mocker.GetMock(); + + downloadClient.Setup(s => s.GetQueue()) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.GetDownloadClient()) + .Returns(downloadClient.Object); + + Subject.Check().Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DroneFactoryCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DroneFactoryCheckFixture.cs new file mode 100644 index 000000000..6fead9ed1 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DroneFactoryCheckFixture.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class DroneFactoryCheckFixture : CoreTest + { + private const string DRONE_FACTORY_FOLDER = @"C:\Test\Unsorted"; + + private void GivenDroneFactoryFolder(bool exists = false) + { + Mocker.GetMock() + .SetupGet(s => s.DownloadedEpisodesFolder) + .Returns(DRONE_FACTORY_FOLDER); + + Mocker.GetMock() + .Setup(s => s.FolderExists(DRONE_FACTORY_FOLDER)) + .Returns(exists); + } + + [Test] + public void should_return_warning_when_drone_factory_folder_is_not_configured() + { + Mocker.GetMock() + .SetupGet(s => s.DownloadedEpisodesFolder) + .Returns(""); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_error_when_drone_factory_folder_does_not_exist() + { + GivenDroneFactoryFolder(); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_error_when_unable_to_write_to_drone_factory_folder() + { + GivenDroneFactoryFolder(true); + + Mocker.GetMock() + .Setup(s => s.WriteAllText(It.IsAny(), It.IsAny())) + .Throws(); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_null_when_no_issues_found() + { + GivenDroneFactoryFolder(true); + + Subject.Check().Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs new file mode 100644 index 000000000..3b8b358c8 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs @@ -0,0 +1,18 @@ +using FluentAssertions; +using NzbDrone.Core.HealthCheck; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + public static class HealthCheckFixtureExtensions + { + public static void ShouldBeWarning(this Core.HealthCheck.HealthCheck result) + { + result.Type.Should().Be(HealthCheckResultType.Warning); + } + + public static void ShouldBeError(this Core.HealthCheck.HealthCheck result) + { + result.Type.Should().Be(HealthCheckResultType.Error); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs new file mode 100644 index 000000000..6ad1b7471 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class IndexerCheckFixture : CoreTest + { + [Test] + public void should_return_error_when_not_indexers_are_enabled() + { + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List()); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_warning_when_only_enabled_indexer_is_wombles() + { + var indexer = Mocker.GetMock(); + indexer.SetupGet(s => s.SupportsSearching).Returns(false); + + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List{indexer.Object}); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_null_when_multiple_multiple_indexers_are_enabled() + { + var indexers = new List{Mocker.GetMock().Object, Mocker.GetMock().Object}; + + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(indexers); + + Subject.Check().Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs new file mode 100644 index 000000000..ed48675e4 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs @@ -0,0 +1,30 @@ +using System; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class UpdateCheckFixture : CoreTest + { + [Test] + public void should_return_error_when_app_folder_is_write_protected() + { + WindowsOnly(); + + Mocker.GetMock() + .Setup(s => s.StartUpFolder) + .Returns(@"C:\NzbDrone"); + + Mocker.GetMock() + .Setup(s => s.WriteAllText(It.IsAny(), It.IsAny())) + .Throws(); + + Subject.Check().ShouldBeError(); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 92025e73f..fda4bcc49 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -125,6 +125,11 @@ + + + + + diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs index 8b5ff0242..c183378fc 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs @@ -58,11 +58,21 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { } - public void Execute(TestBlackholeCommand message) + public override void Test() { - var testPath = Path.Combine(message.Folder, "drone_test.txt"); + PerformTest(Settings.Folder); + } + + private void PerformTest(string folder) + { + var testPath = Path.Combine(folder, "drone_test.txt"); _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); _diskProvider.DeleteFile(testPath); } + + public void Execute(TestBlackholeCommand message) + { + PerformTest(message.Folder); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index aa172e449..8af1726e2 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -80,7 +80,12 @@ namespace NzbDrone.Core.Download.Clients.Nzbget throw new NotImplementedException(); } - public VersionResponse GetVersion(string host = null, int port = 0, string username = null, string password = null) + public override void Test() + { + _proxy.GetVersion(Settings); + } + + private VersionResponse GetVersion(string host = null, int port = 0, string username = null, string password = null) { return _proxy.GetVersion(Settings); } diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index 3c875b9c8..2e93023bc 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -80,11 +80,21 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic { } - public void Execute(TestPneumaticCommand message) + public override void Test() { - var testPath = Path.Combine(message.Folder, "drone_test.txt"); + PerformTest(Settings.Folder); + } + + private void PerformTest(string folder) + { + var testPath = Path.Combine(folder, "drone_test.txt"); _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); _diskProvider.DeleteFile(testPath); } + + public void Execute(TestPneumaticCommand message) + { + PerformTest(message.Folder); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 69601663f..f0ccf188d 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -122,6 +122,11 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd _sabnzbdProxy.RemoveFrom("history", id, Settings); } + public override void Test() + { + _sabnzbdProxy.GetCategories(Settings); + } + public void Execute(TestSabnzbdCommand message) { var settings = new SabnzbdSettings(); diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 157b1e855..ceaf945a7 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -44,5 +44,6 @@ namespace NzbDrone.Core.Download public abstract IEnumerable GetHistory(int start = 0, int limit = 10); public abstract void RemoveFromQueue(string id); public abstract void RemoveFromHistory(string id); + public abstract void Test(); } } diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs index 07c56096e..25b4ee1c8 100644 --- a/src/NzbDrone.Core/Download/DownloadClientFactory.cs +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Download @@ -15,8 +16,8 @@ namespace NzbDrone.Core.Download { private readonly IDownloadClientRepository _providerRepository; - public DownloadClientFactory(IDownloadClientRepository providerRepository, IEnumerable providers, IContainer container, Logger logger) - : base(providerRepository, providers, container, logger) + public DownloadClientFactory(IDownloadClientRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) { _providerRepository = providerRepository; } diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 8a220d8b0..8fae72188 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -1,9 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.Clients; -using NzbDrone.Core.Download.Clients.Nzbget; -using NzbDrone.Core.Download.Clients.Sabnzbd; +using System.Linq; namespace NzbDrone.Core.Download { diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index d13fe243e..b0e3e4734 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -11,5 +11,6 @@ namespace NzbDrone.Core.Download IEnumerable GetHistory(int start = 0, int limit = 0); void RemoveFromQueue(string id); void RemoveFromHistory(string id); + void Test(); } } diff --git a/src/NzbDrone.Core/HealthCheck/CheckHealthCommand.cs b/src/NzbDrone.Core/HealthCheck/CheckHealthCommand.cs new file mode 100644 index 000000000..4a20c621f --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/CheckHealthCommand.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.HealthCheck +{ + public class CheckHealthCommand : Command + { + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs new file mode 100644 index 000000000..d92673851 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs @@ -0,0 +1,36 @@ +using System; +using NzbDrone.Core.Download; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class DownloadClientCheck : IProvideHealthCheck + { + private readonly IProvideDownloadClient _downloadClientProvider; + + public DownloadClientCheck(IProvideDownloadClient downloadClientProvider) + { + _downloadClientProvider = downloadClientProvider; + } + + public HealthCheck Check() + { + var downloadClient = _downloadClientProvider.GetDownloadClient(); + + if (downloadClient == null) + { + return new HealthCheck(HealthCheckResultType.Warning, "No download client is available"); + } + + try + { + downloadClient.GetQueue(); + } + catch (Exception) + { + return new HealthCheck(HealthCheckResultType.Error, "Unable to communicate with download client"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs new file mode 100644 index 000000000..3100b9f80 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class DroneFactoryCheck : IProvideHealthCheck + { + private readonly IConfigService _configService; + private readonly IDiskProvider _diskProvider; + + public DroneFactoryCheck(IConfigService configService, IDiskProvider diskProvider) + { + _configService = configService; + _diskProvider = diskProvider; + } + + public HealthCheck Check() + { + var droneFactoryFolder = _configService.DownloadedEpisodesFolder; + + if (droneFactoryFolder.IsNullOrWhiteSpace()) + { + return new HealthCheck(HealthCheckResultType.Warning, "Drone factory folder is not configured"); + } + + if (!_diskProvider.FolderExists(droneFactoryFolder)) + { + return new HealthCheck(HealthCheckResultType.Error, "Drone factory folder does not exist"); + } + + try + { + var testPath = Path.Combine(droneFactoryFolder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); + } + catch (Exception) + { + return new HealthCheck(HealthCheckResultType.Error, "Unable to write to drone factory folder"); + } + + //Todo: Unable to import one or more files/folders from + + return null; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs new file mode 100644 index 000000000..b61f3c8ac --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs @@ -0,0 +1,34 @@ +using System.Linq; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class IndexerCheck : IProvideHealthCheck + { + private readonly IIndexerFactory _indexerFactory; + + public IndexerCheck(IIndexerFactory indexerFactory) + { + _indexerFactory = indexerFactory; + } + + public HealthCheck Check() + { + var enabled = _indexerFactory.GetAvailableProviders(); + + if (!enabled.Any()) + { + return new HealthCheck(HealthCheckResultType.Error, "No indexers are enabled"); + } + + + + if (enabled.All(i => i.SupportsSearching == false)) + { + return new HealthCheck(HealthCheckResultType.Warning, "Enabled indexers do not support searching"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs new file mode 100644 index 000000000..c79bbf3fa --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Update; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class UpdateCheck : IProvideHealthCheck + { + private readonly IDiskProvider _diskProvider; + private readonly IAppFolderInfo _appFolderInfo; + private readonly ICheckUpdateService _checkUpdateService; + + public UpdateCheck(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, ICheckUpdateService checkUpdateService) + { + _diskProvider = diskProvider; + _appFolderInfo = appFolderInfo; + _checkUpdateService = checkUpdateService; + } + + + public HealthCheck Check() + { + if (OsInfo.IsWindows) + { + try + { + var testPath = Path.Combine(_appFolderInfo.StartUpFolder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); + } + catch (Exception) + { + return new HealthCheck(HealthCheckResultType.Error, + "Unable to update, running from write-protected folder"); + } + } + + if (BuildInfo.BuildDateTime < DateTime.UtcNow.AddDays(-14)) + { + if (_checkUpdateService.AvailableUpdate() != null) + { + return new HealthCheck(HealthCheckResultType.Warning, "New update is available"); + } + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheck.cs b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs new file mode 100644 index 000000000..bc543a292 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.HealthCheck +{ + public class HealthCheck : ModelBase + { + public HealthCheckResultType Type { get; set; } + public String Message { get; set; } + + public HealthCheck(HealthCheckResultType type, string message) + { + Type = type; + Message = message; + } + } + + public enum HealthCheckResultType + { + Warning = 1, + Error = 2 + } +} diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs new file mode 100644 index 000000000..6edfe72ca --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck +{ + public interface IHealthCheckService + { + List PerformHealthCheck(); + } + + public class HealthCheckService : IHealthCheckService, + IExecute, + IHandleAsync, + IHandleAsync>, + IHandleAsync> + { + private readonly IEnumerable _healthChecks; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public HealthCheckService(IEnumerable healthChecks, IEventAggregator eventAggregator, Logger logger) + { + _healthChecks = healthChecks; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List PerformHealthCheck() + { + _logger.Trace("Checking health"); + var result = _healthChecks.Select(c => c.Check()).Where(c => c != null).ToList(); + + return result; + } + + public void Execute(CheckHealthCommand message) + { + //Until we have stored health checks we should just trigger the complete event + //and let the clients check in + //Multiple connected clients means we're going to compute the health check multiple times + //Multiple checks feels a bit ugly, but means the most up to date information goes to the client + _eventAggregator.PublishEvent(new TriggerHealthCheckEvent()); + } + + public void HandleAsync(ConfigSavedEvent message) + { + _eventAggregator.PublishEvent(new TriggerHealthCheckEvent()); + } + + public void HandleAsync(ProviderUpdatedEvent message) + { + _eventAggregator.PublishEvent(new TriggerHealthCheckEvent()); + } + + public void HandleAsync(ProviderUpdatedEvent message) + { + _eventAggregator.PublishEvent(new TriggerHealthCheckEvent()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs new file mode 100644 index 000000000..0c7838fb3 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.HealthCheck +{ + public interface IProvideHealthCheck + { + HealthCheck Check(); + } +} diff --git a/src/NzbDrone.Core/HealthCheck/TriggerHealthCheckEvent.cs b/src/NzbDrone.Core/HealthCheck/TriggerHealthCheckEvent.cs new file mode 100644 index 000000000..d9b3e3838 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/TriggerHealthCheckEvent.cs @@ -0,0 +1,8 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.HealthCheck +{ + public class TriggerHealthCheckEvent : IEvent + { + } +} diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index a4a9f1c6c..141145e9f 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Indexers IParseFeed Parser { get; } DownloadProtocol Protocol { get; } Boolean SupportsPaging { get; } + Boolean SupportsSearching { get; } IEnumerable RecentFeed { get; } IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 9c6527a17..96fe2b837 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -35,6 +35,7 @@ namespace NzbDrone.Core.Indexers public abstract DownloadProtocol Protocol { get; } public abstract bool SupportsPaging { get; } + public virtual bool SupportsSearching { get { return true; } } protected TSettings Settings { diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index 7e92fd89d..02837f490 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers @@ -16,8 +17,8 @@ namespace NzbDrone.Core.Indexers private readonly IIndexerRepository _providerRepository; private readonly INewznabTestService _newznabTestService; - public IndexerFactory(IIndexerRepository providerRepository, IEnumerable providers, IContainer container, INewznabTestService newznabTestService, Logger logger) - : base(providerRepository, providers, container, logger) + public IndexerFactory(IIndexerRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, INewznabTestService newznabTestService, Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) { _providerRepository = providerRepository; _newznabTestService = newznabTestService; diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 9b7ade53f..f61c7ffba 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -22,6 +22,14 @@ namespace NzbDrone.Core.Indexers.Wombles } } + public override bool SupportsSearching + { + get + { + return false; + } + } + public override IParseFeed Parser { get diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index e40e08f8e..e775abb5f 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DataAugmentation.Xem; using NzbDrone.Core.Download; +using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Housekeeping; using NzbDrone.Core.Indexers; using NzbDrone.Core.Instrumentation.Commands; @@ -50,6 +51,7 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 1, TypeName = typeof(DownloadedEpisodesScanCommand).FullName}, new ScheduledTask{ Interval = 1, TypeName = typeof(TrackedCommandCleanupCommand).FullName}, new ScheduledTask{ Interval = 1, TypeName = typeof(CheckForFailedDownloadCommand).FullName}, + new ScheduledTask{ Interval = 5, TypeName = typeof(CheckHealthCommand).FullName}, new ScheduledTask{ Interval = 1*60, TypeName = typeof(ApplicationUpdateCommand).FullName}, new ScheduledTask{ Interval = 1*60, TypeName = typeof(TrimLogCommand).FullName}, new ScheduledTask{ Interval = 3*60, TypeName = typeof(UpdateSceneMappingCommand).FullName}, diff --git a/src/NzbDrone.Core/MetaData/MetadataFactory.cs b/src/NzbDrone.Core/MetaData/MetadataFactory.cs index b9b1bd20b..74d837c6a 100644 --- a/src/NzbDrone.Core/MetaData/MetadataFactory.cs +++ b/src/NzbDrone.Core/MetaData/MetadataFactory.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Metadata.Consumers.Fake; using NzbDrone.Core.ThingiProvider; @@ -17,8 +18,8 @@ namespace NzbDrone.Core.Metadata { private readonly IMetadataRepository _providerRepository; - public MetadataFactory(IMetadataRepository providerRepository, IEnumerable providers, IContainer container, Logger logger) - : base(providerRepository, providers, container, logger) + public MetadataFactory(IMetadataRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) { _providerRepository = providerRepository; } diff --git a/src/NzbDrone.Core/Notifications/NotificationFactory.cs b/src/NzbDrone.Core/Notifications/NotificationFactory.cs index 4a4fc9bc2..4fd14d6f5 100644 --- a/src/NzbDrone.Core/Notifications/NotificationFactory.cs +++ b/src/NzbDrone.Core/Notifications/NotificationFactory.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Notifications @@ -15,8 +16,8 @@ namespace NzbDrone.Core.Notifications public class NotificationFactory : ProviderFactory, INotificationFactory { - public NotificationFactory(INotificationRepository providerRepository, IEnumerable providers, IContainer container, Logger logger) - : base(providerRepository, providers, container, logger) + public NotificationFactory(INotificationRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) { } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 623c584eb..86265aeb0 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -264,6 +264,13 @@ + + + + + + + @@ -342,6 +349,8 @@ + + @@ -503,6 +512,7 @@ + diff --git a/src/NzbDrone.Core/ThingiProvider/Events/ProviderUpdatedEvent.cs b/src/NzbDrone.Core/ThingiProvider/Events/ProviderUpdatedEvent.cs new file mode 100644 index 000000000..aa9f7f984 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Events/ProviderUpdatedEvent.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.ThingiProvider.Events +{ + public class ProviderUpdatedEvent : IEvent + { + } +} diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index bdb30d12b..bdce9edd9 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -5,6 +5,7 @@ using NLog; using NzbDrone.Common.Composition; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.ThingiProvider { @@ -14,6 +15,7 @@ namespace NzbDrone.Core.ThingiProvider { private readonly IProviderRepository _providerRepository; private readonly IContainer _container; + private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; protected readonly List _providers; @@ -21,10 +23,12 @@ namespace NzbDrone.Core.ThingiProvider protected ProviderFactory(IProviderRepository providerRepository, IEnumerable providers, IContainer container, + IEventAggregator eventAggregator, Logger logger) { _providerRepository = providerRepository; _container = container; + _eventAggregator = eventAggregator; _providers = providers.ToList(); _logger = logger; } @@ -62,6 +66,7 @@ namespace NzbDrone.Core.ThingiProvider public virtual void Update(TProviderDefinition definition) { _providerRepository.Update(definition); + _eventAggregator.PublishEvent(new ProviderUpdatedEvent()); } public void Delete(int id) diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index 6b1d51aca..843e6db10 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -48,7 +48,7 @@ .icon-nd-warning:before { .icon(@warning-sign); - color : #f89406; + color : @orange; } .icon-nd-edit:before { @@ -79,7 +79,7 @@ .icon-nd-form-warning:before { .icon(@warning-sign); - color: #f89406; + color: @orange; } .icon-nd-form-danger:before { @@ -175,4 +175,14 @@ .icon-nd-restart:before { .icon(@repeat); +} + +.icon-nd-health-warning:before { + .icon(@exclamation-sign); + color : @orange +} + +.icon-nd-health-error:before { + .icon(@exclamation-sign); + color : @errorText } \ No newline at end of file diff --git a/src/UI/Content/menu.less b/src/UI/Content/menu.less index ce423051a..27c5cd7b2 100644 --- a/src/UI/Content/menu.less +++ b/src/UI/Content/menu.less @@ -22,6 +22,8 @@ li { list-style-type : none; display : inline-block; + position : relative; + a { &:focus { @@ -38,21 +40,20 @@ font-weight : 100; } span.label.pull-right { - position : relative; - top : 24px; - right : 14px; + position : absolute; + top : 28px; + right : 18px; } } } -.backdrop #nav-region { - background-color : #000000; - .opacity(0.85); -} +.backdrop { + #nav-region { + background-color : #000000; + .opacity(0.85); + } + -#nav-region li a:hover, #in-sub-nav li a.active { - background-color : #555555; - text-decoration : none; } #nav-region { @@ -62,6 +63,19 @@ .span12 { margin-left : 0px; } + + li { + a { + &:hover { + background-color : #555555; + text-decoration : none; + } + + .label { + cursor: pointer; + } + } + } } .search { diff --git a/src/UI/Health/HealthCollection.js b/src/UI/Health/HealthCollection.js new file mode 100644 index 000000000..577eb0af3 --- /dev/null +++ b/src/UI/Health/HealthCollection.js @@ -0,0 +1,16 @@ +'use strict'; +define( + [ + 'backbone', + 'Health/HealthModel', + 'Mixins/backbone.signalr.mixin' + ], function (Backbone, HealthModel) { + var Collection = Backbone.Collection.extend({ + url : window.NzbDrone.ApiRoot + '/health', + model: HealthModel + }); + + var collection = new Collection().bindSignalR(); + collection.fetch(); + return collection; + }); diff --git a/src/UI/Health/HealthModel.js b/src/UI/Health/HealthModel.js new file mode 100644 index 000000000..530a080c6 --- /dev/null +++ b/src/UI/Health/HealthModel.js @@ -0,0 +1,9 @@ +'use strict'; +define( + [ + 'backbone' + ], function (Backbone) { + return Backbone.Model.extend({ + + }); + }); diff --git a/src/UI/Health/HealthView.js b/src/UI/Health/HealthView.js new file mode 100644 index 000000000..77060366e --- /dev/null +++ b/src/UI/Health/HealthView.js @@ -0,0 +1,39 @@ +'use strict'; +define( + [ + 'underscore', + 'marionette', + 'Health/HealthCollection' + ], function (_, Marionette, HealthCollection) { + return Marionette.ItemView.extend({ + initialize: function () { + this.listenTo(HealthCollection, 'sync', this._healthSync); + HealthCollection.fetch(); + }, + + render: function () { + this.$el.empty(); + + if (HealthCollection.length === 0) { + return this; + } + + var count = HealthCollection.length; + var label = 'label-warning'; + var errors = HealthCollection.some(function (model) { + return model.get('type') === 'error'; + }); + + if (errors) { + label = 'label-important'; + } + + this.$el.html('{1}'.format(label, count)); + return this; + }, + + _healthSync: function () { + this.render(); + } + }); + }); diff --git a/src/UI/Navbar/NavbarTemplate.html b/src/UI/Navbar/NavbarLayoutTemplate.html similarity index 97% rename from src/UI/Navbar/NavbarTemplate.html rename to src/UI/Navbar/NavbarLayoutTemplate.html index fdc1128b7..c040f15b6 100644 --- a/src/UI/Navbar/NavbarTemplate.html +++ b/src/UI/Navbar/NavbarLayoutTemplate.html @@ -47,6 +47,7 @@
System +
  • diff --git a/src/UI/Navbar/NavbarView.js b/src/UI/Navbar/NavbarView.js index 4242c07bb..3cdb8e2f1 100644 --- a/src/UI/Navbar/NavbarView.js +++ b/src/UI/Navbar/NavbarView.js @@ -3,10 +3,15 @@ define( [ 'marionette', 'jquery', + 'Health/HealthView', 'Navbar/Search' - ], function (Marionette, $) { - return Marionette.ItemView.extend({ - template: 'Navbar/NavbarTemplate', + ], function (Marionette, $, HealthView) { + return Marionette.Layout.extend({ + template: 'Navbar/NavbarLayoutTemplate', + + regions: { + health: '#x-health' + }, ui: { search: '.x-series-search' @@ -18,6 +23,7 @@ define( onRender: function () { this.ui.search.bindSearch(); + this.health.show(new HealthView()); }, onClick: function (event) { @@ -30,9 +36,9 @@ define( var href = event.target.getAttribute('href'); //if couldn't find it look up' - if (!href && target.parent('a') && target.parent('a')[0]) { + if (!href && target.closest('a') && target.closest('a')[0]) { - var linkElement = target.parent('a')[0]; + var linkElement = target.closest('a')[0]; href = linkElement.getAttribute('href'); this.setActive(linkElement); diff --git a/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js b/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js index ce8717d76..23120d94f 100644 --- a/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js +++ b/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js @@ -1,7 +1,7 @@ 'use strict'; define([ - 'vent', - 'marionette', + 'vent', + 'marionette', 'backgrid', 'System/Info/DiskSpace/DiskSpaceCollection', 'Shared/LoadingView', @@ -14,6 +14,7 @@ define([ regions: { grid: '#x-grid' }, + columns: [ { @@ -37,6 +38,7 @@ define([ this.collection = new DiskSpaceCollection(); this.listenTo(this.collection, 'sync', this._showTable); }, + onRender : function() { this.grid.show(new LoadingView()); }, @@ -44,6 +46,7 @@ define([ onShow: function() { this.collection.fetch(); }, + _showTable: function() { this.grid.show(new Backgrid.Grid({ row: Backgrid.Row, diff --git a/src/UI/System/Info/Health/HealthCell.js b/src/UI/System/Info/Health/HealthCell.js new file mode 100644 index 000000000..06dd5c3d9 --- /dev/null +++ b/src/UI/System/Info/Health/HealthCell.js @@ -0,0 +1,18 @@ +'use strict'; +define( + [ + 'Cells/NzbDroneCell' + ], function (NzbDroneCell) { + return NzbDroneCell.extend({ + + className: 'log-level-cell', + + render: function () { + + var level = this._getValue(); + this.$el.html(''.format(this._getValue().toLowerCase(), level)); + + return this; + } + }); + }); diff --git a/src/UI/System/Info/Health/HealthLayout.js b/src/UI/System/Info/Health/HealthLayout.js new file mode 100644 index 000000000..2a0a44461 --- /dev/null +++ b/src/UI/System/Info/Health/HealthLayout.js @@ -0,0 +1,55 @@ +'use strict'; +define( + [ + 'marionette', + 'backgrid', + 'Health/HealthCollection', + 'System/Info/Health/HealthCell', + 'System/Info/Health/HealthOkView' + ], function (Marionette, Backgrid, HealthCollection, HealthCell, HealthOkView) { + return Marionette.Layout.extend({ + template: 'System/Info/Health/HealthLayoutTemplate', + + regions: { + grid: '#x-health-grid' + }, + + columns: + [ + { + name: 'type', + label: '', + cell: HealthCell + }, + { + name: 'message', + label: 'Message', + cell: 'string' + } + ], + + initialize: function () { + this.listenTo(HealthCollection, 'sync', this.render); + HealthCollection.fetch(); + }, + + onRender : function() { + if (HealthCollection.length === 0) { + this.grid.show(new HealthOkView()); + } + + else { + this._showTable(); + } + }, + + _showTable: function() { + this.grid.show(new Backgrid.Grid({ + row: Backgrid.Row, + columns: this.columns, + collection: HealthCollection, + className:'table table-hover' + })); + } + }); + }); diff --git a/src/UI/System/Info/Health/HealthLayoutTemplate.html b/src/UI/System/Info/Health/HealthLayoutTemplate.html new file mode 100644 index 000000000..911c3fdf5 --- /dev/null +++ b/src/UI/System/Info/Health/HealthLayoutTemplate.html @@ -0,0 +1,6 @@ +
    + Health + +
    +
    + diff --git a/src/UI/System/Info/Health/HealthOkView.js b/src/UI/System/Info/Health/HealthOkView.js new file mode 100644 index 000000000..3a2db1b99 --- /dev/null +++ b/src/UI/System/Info/Health/HealthOkView.js @@ -0,0 +1,9 @@ +'use strict'; +define( + [ + 'marionette' + ], function (Marionette) { + return Marionette.ItemView.extend({ + template: 'System/Info/Health/HealthOkViewTemplate' + }); + }); diff --git a/src/UI/System/Info/Health/HealthOkViewTemplate.html b/src/UI/System/Info/Health/HealthOkViewTemplate.html new file mode 100644 index 000000000..ca48e74c5 --- /dev/null +++ b/src/UI/System/Info/Health/HealthOkViewTemplate.html @@ -0,0 +1,2 @@ +No issues with your configuration + diff --git a/src/UI/System/Info/SystemInfoLayout.js b/src/UI/System/Info/SystemInfoLayout.js index e415c6d8d..33ec107b1 100644 --- a/src/UI/System/Info/SystemInfoLayout.js +++ b/src/UI/System/Info/SystemInfoLayout.js @@ -4,22 +4,26 @@ define( 'backbone', 'marionette', 'System/Info/About/AboutView', - 'System/Info/DiskSpace/DiskSpaceLayout' + 'System/Info/DiskSpace/DiskSpaceLayout', + 'System/Info/Health/HealthLayout' ], function (Backbone, Marionette, AboutView, - DiskSpaceLayout) { + DiskSpaceLayout, + HealthLayout) { return Marionette.Layout.extend({ template: 'System/Info/SystemInfoLayoutTemplate', regions: { about : '#about', - diskSpace: '#diskspace' + diskSpace: '#diskspace', + health : '#health' }, onRender: function () { this.about.show(new AboutView()); this.diskSpace.show(new DiskSpaceLayout()); + this.health.show(new HealthLayout()); } }); }); diff --git a/src/UI/System/Info/SystemInfoLayoutTemplate.html b/src/UI/System/Info/SystemInfoLayoutTemplate.html index 74ba09bf2..2a60a3487 100644 --- a/src/UI/System/Info/SystemInfoLayoutTemplate.html +++ b/src/UI/System/Info/SystemInfoLayoutTemplate.html @@ -1,4 +1,8 @@ 
    +
    +
    + +
    diff --git a/src/UI/jQuery/RouteBinder.js b/src/UI/jQuery/RouteBinder.js index c5ba44245..4c4bf1cca 100644 --- a/src/UI/jQuery/RouteBinder.js +++ b/src/UI/jQuery/RouteBinder.js @@ -33,9 +33,9 @@ define( var href = event.target.getAttribute('href'); - if (!href && $target.parent('a') && $target.parent('a')[0]) { + if (!href && $target.closest('a') && $target.closest('a')[0]) { - var linkElement = $target.parent('a')[0]; + var linkElement = $target.closest('a')[0]; href = linkElement.getAttribute('href'); }