From f5847e9e5bf16b932d762978983bcc1b72a67ca6 Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 24 Jan 2021 23:33:54 -0500 Subject: [PATCH] New: Show previously installed version in Updates UI Closes #308 Closes #309 Closes #313 Closes #319 Closes #460 Closes #608 Co-Authored-By: Taloth --- frontend/src/App/AppUpdatedModalContent.js | 41 ++++++- .../App/AppUpdatedModalContentConnector.js | 4 +- frontend/src/Store/Actions/appActions.js | 3 + frontend/src/System/Updates/Updates.js | 25 ++++- .../src/System/Updates/UpdatesConnector.js | 4 +- .../UpdatePackageProviderFixture.cs | 3 +- .../Converters/SystemVersionConverter.cs | 24 +++++ .../Migration/024_add_update_history.cs | 20 ++++ src/NzbDrone.Core/Datastore/TableMapping.cs | 4 + .../Update/Events/UpdateInstalledEvent.cs | 17 +++ .../Update/History/UpdateHistory.cs | 12 +++ .../Update/History/UpdateHistoryEventType.cs | 9 ++ .../Update/History/UpdateHistoryRepository.cs | 55 ++++++++++ .../Update/History/UpdateHistoryService.cs | 100 ++++++++++++++++++ .../Update/RecentUpdateProvider.cs | 11 +- .../Update/UpdatePackageProvider.cs | 9 +- src/Readarr.Api.V1/Update/UpdateController.cs | 17 ++- src/Readarr.Api.V1/Update/UpdateResource.cs | 1 + 18 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/024_add_update_history.cs create mode 100644 src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs create mode 100644 src/NzbDrone.Core/Update/History/UpdateHistory.cs create mode 100644 src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs create mode 100644 src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs create mode 100644 src/NzbDrone.Core/Update/History/UpdateHistoryService.cs diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js index 443c78f66..3a6eda94f 100644 --- a/frontend/src/App/AppUpdatedModalContent.js +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -11,9 +11,47 @@ import UpdateChanges from 'System/Updates/UpdateChanges'; import translate from 'Utilities/String/translate'; import styles from './AppUpdatedModalContent.css'; +function mergeUpdates(items, version, prevVersion) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.length) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges = { new: [], fixed: [] }; + appliedUpdates.forEach((u) => { + if (u.changes) { + appliedChanges.new.push(... u.changes.new); + appliedChanges.fixed.push(... u.changes.fixed); + } + }); + + const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); + + if (!appliedChanges.new.length && !appliedChanges.fixed.length) { + mergedUpdate.changes = null; + } + + return mergedUpdate; +} + function AppUpdatedModalContent(props) { const { version, + prevVersion, isPopulated, error, items, @@ -21,7 +59,7 @@ function AppUpdatedModalContent(props) { onModalClose } = props; - const update = items[0]; + const update = mergeUpdates(items, version, prevVersion); return ( @@ -91,6 +129,7 @@ function AppUpdatedModalContent(props) { AppUpdatedModalContent.propTypes = { version: PropTypes.string.isRequired, + prevVersion: PropTypes.string, isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js index 5a91e1f0e..35ba6bb62 100644 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -8,8 +8,9 @@ import AppUpdatedModalContent from './AppUpdatedModalContent'; function createMapStateToProps() { return createSelector( (state) => state.app.version, + (state) => state.app.prevVersion, (state) => state.system.updates, - (version, updates) => { + (version, prevVersion, updates) => { const { isPopulated, error, @@ -18,6 +19,7 @@ function createMapStateToProps() { return { version, + prevVersion, isPopulated, error, items diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js index 8baf1b6ca..30ad5e01f 100644 --- a/frontend/src/Store/Actions/appActions.js +++ b/frontend/src/Store/Actions/appActions.js @@ -187,6 +187,9 @@ export const reducers = createHandleActions({ }; if (state.version !== version) { + if (!state.prevVersion) { + newState.prevVersion = state.version; + } newState.isUpdated = true; } diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js index 31c57db8b..f819668ec 100644 --- a/frontend/src/System/Updates/Updates.js +++ b/frontend/src/System/Updates/Updates.js @@ -10,6 +10,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; import UpdateChanges from './UpdateChanges'; import styles from './Updates.css'; @@ -32,6 +33,8 @@ class Updates extends Component { isDocker, updateMechanismMessage, shortDateFormat, + longDateFormat, + timeFormat, onInstallLatestPress } = this.props; @@ -138,7 +141,12 @@ class Updates extends Component {
{update.version}
-
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {formatDate(update.releaseDate, shortDateFormat)} +
{ update.branch === 'master' ? @@ -155,11 +163,24 @@ class Updates extends Component { : null } + + { + update.version !== currentVersion && update.installedOn ? + : + null + }
{ @@ -222,6 +243,8 @@ Updates.propTypes = { updateMechanism: PropTypes.string, updateMechanismMessage: PropTypes.string, shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, onInstallLatestPress: PropTypes.func.isRequired }; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js index 7b5318991..343106d24 100644 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -48,7 +48,9 @@ function createMapStateToProps() { isDocker: systemStatus.isDocker, updateMechanism: generalSettings.item.updateMechanism, updateMechanismMessage: status.packageUpdateMechanismMessage, - shortDateFormat: uiSettings.shortDateFormat + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat }; } ); diff --git a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs index 2f17ceacb..72e073df4 100644 --- a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs +++ b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs @@ -44,7 +44,8 @@ namespace NzbDrone.Core.Test.UpdateTests { const string branch = "nightly"; UseRealHttp(); - var recent = Subject.GetRecentUpdates(branch, new Version(0, 1)); + var recent = Subject.GetRecentUpdates(branch, new Version(0, 1), null); + var recentWithChanges = recent.Where(c => c.Changes != null); recent.Should().NotBeEmpty(); recent.Should().OnlyContain(c => c.Hash.IsNotNullOrWhiteSpace()); diff --git a/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs b/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs new file mode 100644 index 000000000..d532df430 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Data; +using Dapper; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class SystemVersionConverter : SqlMapper.TypeHandler + { + public override Version Parse(object value) + { + if (value is string version) + { + return Version.Parse((string)value); + } + + return null; + } + + public override void SetValue(IDbDataParameter parameter, Version value) + { + parameter.Value = value.ToString(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/024_add_update_history.cs b/src/NzbDrone.Core/Datastore/Migration/024_add_update_history.cs new file mode 100644 index 000000000..479db333c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/024_add_update_history.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(024)] + public class add_update_history : NzbDroneMigrationBase + { + protected override void LogDbUpgrade() + { + Create.TableForModel("UpdateHistory") + .WithColumn("Date").AsDateTime().NotNullable().Indexed() + .WithColumn("Version").AsString().NotNullable() + .WithColumn("EventType").AsInt32().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 555d4fab1..2012f04d6 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -36,6 +36,7 @@ using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Update.History; using static Dapper.SqlMapper; namespace NzbDrone.Core.Datastore @@ -204,6 +205,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("HttpResponse").RegisterModel(); Mapper.Entity("DownloadHistory").RegisterModel(); + + Mapper.Entity("UpdateHistory").RegisterModel(); } private static void RegisterMappers() @@ -232,6 +235,7 @@ namespace NzbDrone.Core.Datastore SqlMapper.RemoveTypeMap(typeof(Guid?)); SqlMapper.AddTypeHandler(new GuidConverter()); SqlMapper.AddTypeHandler(new CommandConverter()); + SqlMapper.AddTypeHandler(new SystemVersionConverter()); } private static void RegisterProviderSettingConverter() diff --git a/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs b/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs new file mode 100644 index 000000000..29290b17f --- /dev/null +++ b/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs @@ -0,0 +1,17 @@ +using System; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Update.History.Events +{ + public class UpdateInstalledEvent : IEvent + { + public Version PreviousVerison { get; set; } + public Version NewVersion { get; set; } + + public UpdateInstalledEvent(Version previousVersion, Version newVersion) + { + PreviousVerison = previousVersion; + NewVersion = newVersion; + } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistory.cs b/src/NzbDrone.Core/Update/History/UpdateHistory.cs new file mode 100644 index 000000000..62a3a6fc0 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistory.cs @@ -0,0 +1,12 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Update.History +{ + public class UpdateHistory : ModelBase + { + public DateTime Date { get; set; } + public Version Version { get; set; } + public UpdateHistoryEventType EventType { get; set; } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs new file mode 100644 index 000000000..2ad99d624 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Update.History +{ + public enum UpdateHistoryEventType + { + Unknown = 0, + Initiated = 1, + Installed = 2 + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs new file mode 100644 index 000000000..40bd68963 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Update.History +{ + public interface IUpdateHistoryRepository : IBasicRepository + { + UpdateHistory LastInstalled(); + UpdateHistory PreviouslyInstalled(); + List InstalledSince(DateTime dateTime); + } + + public class UpdateHistoryRepository : BasicRepository, IUpdateHistoryRepository + { + public UpdateHistoryRepository(ILogDatabase logDatabase, IEventAggregator eventAggregator) + : base(logDatabase, eventAggregator) + { + } + + public UpdateHistory LastInstalled() + { + var history = Query(x => x.EventType == UpdateHistoryEventType.Installed) + .OrderByDescending(v => v.Date) + .Take(1) + .FirstOrDefault(); + + return history; + } + + public UpdateHistory PreviouslyInstalled() + { + var history = Query(x => x.EventType == UpdateHistoryEventType.Installed) + .OrderByDescending(v => v.Date) + .Skip(1) + .Take(1) + .FirstOrDefault(); + + return history; + } + + public List InstalledSince(DateTime dateTime) + { + var history = Query(v => v.EventType == UpdateHistoryEventType.Installed && v.Date >= dateTime) + .OrderBy(v => v.Date) + .ToList(); + + return history; + } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs new file mode 100644 index 000000000..09cf70602 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Update.History.Events; + +namespace NzbDrone.Core.Update.History +{ + public interface IUpdateHistoryService + { + Version PreviouslyInstalled(); + List InstalledSince(DateTime dateTime); + } + + public class UpdateHistoryService : IUpdateHistoryService, IHandle, IHandleAsync + { + private readonly IUpdateHistoryRepository _repository; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + private Version _prevVersion; + + public UpdateHistoryService(IUpdateHistoryRepository repository, IEventAggregator eventAggregator, Logger logger) + { + _repository = repository; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public Version PreviouslyInstalled() + { + try + { + var history = _repository.PreviouslyInstalled(); + + return history?.Version; + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to determine previously installed version"); + return null; + } + } + + public List InstalledSince(DateTime dateTime) + { + try + { + return _repository.InstalledSince(dateTime); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to get list of previously installed versions"); + return new List(); + } + } + + public void Handle(ApplicationStartedEvent message) + { + if (BuildInfo.Version.Major == 10) + { + // Don't save dev versions, they change constantly + return; + } + + UpdateHistory history; + try + { + history = _repository.LastInstalled(); + } + catch (Exception ex) + { + _logger.Warn(ex, "Cleaning corrupted update history"); + _repository.Purge(); + history = null; + } + + if (history == null || history.Version != BuildInfo.Version) + { + _prevVersion = history?.Version; + + _repository.Insert(new UpdateHistory + { + Date = DateTime.UtcNow, + Version = BuildInfo.Version, + EventType = UpdateHistoryEventType.Installed + }); + } + } + + public void HandleAsync(ApplicationStartedEvent message) + { + if (_prevVersion != null) + { + _eventAggregator.PublishEvent(new UpdateInstalledEvent(_prevVersion, BuildInfo.Version)); + } + } + } +} diff --git a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs index 73bc38119..68cda9aa6 100644 --- a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs +++ b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Update.History; namespace NzbDrone.Core.Update { @@ -13,18 +15,23 @@ namespace NzbDrone.Core.Update { private readonly IConfigFileProvider _configFileProvider; private readonly IUpdatePackageProvider _updatePackageProvider; + private readonly IUpdateHistoryService _updateHistoryService; public RecentUpdateProvider(IConfigFileProvider configFileProvider, - IUpdatePackageProvider updatePackageProvider) + IUpdatePackageProvider updatePackageProvider, + IUpdateHistoryService updateHistoryService) { _configFileProvider = configFileProvider; _updatePackageProvider = updatePackageProvider; + _updateHistoryService = updateHistoryService; } public List GetRecentUpdatePackages() { var branch = _configFileProvider.Branch; - return _updatePackageProvider.GetRecentUpdates(branch, BuildInfo.Version); + var version = BuildInfo.Version; + var prevVersion = _updateHistoryService.PreviouslyInstalled(); + return _updatePackageProvider.GetRecentUpdates(branch, version, prevVersion); } } } diff --git a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs index 4946424b8..604499bdb 100644 --- a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs +++ b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Update public interface IUpdatePackageProvider { UpdatePackage GetLatestUpdate(string branch, Version currentVersion); - List GetRecentUpdates(string branch, Version currentVersion); + List GetRecentUpdates(string branch, Version currentVersion, Version previousVersion = null); } public class UpdatePackageProvider : IUpdatePackageProvider @@ -61,7 +61,7 @@ namespace NzbDrone.Core.Update return update.UpdatePackage; } - public List GetRecentUpdates(string branch, Version currentVersion) + public List GetRecentUpdates(string branch, Version currentVersion, Version previousVersion) { var request = _requestBuilder.Create() .Resource("/update/{branch}/changes") @@ -72,6 +72,11 @@ namespace NzbDrone.Core.Update .AddQueryParam("runtimeVer", _platformInfo.Version) .SetSegment("branch", branch); + if (previousVersion != null && previousVersion != currentVersion) + { + request.AddQueryParam("prevVersion", previousVersion); + } + if (_analyticsService.IsEnabled) { // Send if the system is active so we know which versions to deprecate/ignore diff --git a/src/Readarr.Api.V1/Update/UpdateController.cs b/src/Readarr.Api.V1/Update/UpdateController.cs index 6463f6cec..f97a4ea45 100644 --- a/src/Readarr.Api.V1/Update/UpdateController.cs +++ b/src/Readarr.Api.V1/Update/UpdateController.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Update; +using NzbDrone.Core.Update.History; using Readarr.Http; namespace Readarr.Api.V1.Update @@ -11,10 +12,12 @@ namespace Readarr.Api.V1.Update public class UpdateController : Controller { private readonly IRecentUpdateProvider _recentUpdateProvider; + private readonly IUpdateHistoryService _updateHistoryService; - public UpdateController(IRecentUpdateProvider recentUpdateProvider) + public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHistoryService updateHistoryService) { _recentUpdateProvider = recentUpdateProvider; + _updateHistoryService = updateHistoryService; } [HttpGet] @@ -40,6 +43,18 @@ namespace Readarr.Api.V1.Update { installed.Installed = true; } + + var installDates = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate) + .DistinctBy(v => v.Version) + .ToDictionary(v => v.Version); + + foreach (var resource in resources) + { + if (installDates.TryGetValue(resource.Version, out var installDate)) + { + resource.InstalledOn = installDate.Date; + } + } } return resources; diff --git a/src/Readarr.Api.V1/Update/UpdateResource.cs b/src/Readarr.Api.V1/Update/UpdateResource.cs index c4d0a7f66..df63fe75b 100644 --- a/src/Readarr.Api.V1/Update/UpdateResource.cs +++ b/src/Readarr.Api.V1/Update/UpdateResource.cs @@ -15,6 +15,7 @@ namespace Readarr.Api.V1.Update public string FileName { get; set; } public string Url { get; set; } public bool Installed { get; set; } + public DateTime? InstalledOn { get; set; } public bool Installable { get; set; } public bool Latest { get; set; } public UpdateChanges Changes { get; set; }