From 3d951f6db8bac4f89f0086748feaa5b5c4d12ffe Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 2 Mar 2025 12:30:39 -0800 Subject: [PATCH] Convert Updates to React Query --- frontend/src/App/AppUpdatedModalContent.tsx | 11 ++- frontend/src/App/State/SystemAppState.ts | 3 - .../src/Settings/General/useUpdateSettings.ts | 17 +++++ frontend/src/System/Updates/Updates.tsx | 62 +++++++--------- frontend/src/System/Updates/useUpdates.ts | 15 ++++ .../Settings/UpdateSettingsController.cs | 50 +++++++++++++ .../Settings/UpdateSettingsResource.cs | 12 ++++ src/Sonarr.Api.V5/Update/UpdateController.cs | 71 +++++++++++++++++++ src/Sonarr.Api.V5/Update/UpdateResource.cs | 48 +++++++++++++ 9 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 frontend/src/Settings/General/useUpdateSettings.ts create mode 100644 frontend/src/System/Updates/useUpdates.ts create mode 100644 src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs create mode 100644 src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs create mode 100644 src/Sonarr.Api.V5/Update/UpdateController.cs create mode 100644 src/Sonarr.Api.V5/Update/UpdateResource.cs diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx index 6553d6270..d1d9ca965 100644 --- a/frontend/src/App/AppUpdatedModalContent.tsx +++ b/frontend/src/App/AppUpdatedModalContent.tsx @@ -11,6 +11,7 @@ import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; import { fetchUpdates } from 'Store/Actions/systemActions'; import UpdateChanges from 'System/Updates/UpdateChanges'; +import useUpdates from 'System/Updates/useUpdates'; import Update from 'typings/Update'; import translate from 'Utilities/String/translate'; import AppState from './State/AppState'; @@ -65,14 +66,12 @@ interface AppUpdatedModalContentProps { function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { const dispatch = useDispatch(); const { version, prevVersion } = useSelector((state: AppState) => state.app); - const { isPopulated, error, items } = useSelector( - (state: AppState) => state.system.updates - ); + const { isFetched, error, data } = useUpdates(); const previousVersion = usePrevious(version); const { onModalClose } = props; - const update = mergeUpdates(items, version, prevVersion); + const update = mergeUpdates(data, version, prevVersion); const handleSeeChangesPress = useCallback(() => { window.location.href = `${window.Sonarr.urlBase}/system/updates`; @@ -100,7 +99,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { /> - {isPopulated && !error && !!update ? ( + {isFetched && !error && !!update ? (
{update.changes ? (
@@ -126,7 +125,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
) : null} - {!isPopulated && !error ? : null} + {!isFetched && !error ? : null} diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index 0c07849f8..9c44f2402 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -3,7 +3,6 @@ import Health from 'typings/Health'; import LogFile from 'typings/LogFile'; import SystemStatus from 'typings/SystemStatus'; import Task from 'typings/Task'; -import Update from 'typings/Update'; import AppSectionState, { AppSectionItemState } from './AppSectionState'; import BackupAppState from './BackupAppState'; @@ -12,7 +11,6 @@ export type HealthAppState = AppSectionState; export type SystemStatusAppState = AppSectionItemState; export type TaskAppState = AppSectionState; export type LogFilesAppState = AppSectionState; -export type UpdateAppState = AppSectionState; interface SystemAppState { backups: BackupAppState; @@ -22,7 +20,6 @@ interface SystemAppState { status: SystemStatusAppState; tasks: TaskAppState; updateLogFiles: LogFilesAppState; - updates: UpdateAppState; } export default SystemAppState; diff --git a/frontend/src/Settings/General/useUpdateSettings.ts b/frontend/src/Settings/General/useUpdateSettings.ts new file mode 100644 index 000000000..d2e648e2c --- /dev/null +++ b/frontend/src/Settings/General/useUpdateSettings.ts @@ -0,0 +1,17 @@ +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import { UpdateMechanism } from 'typings/Settings/General'; + +interface UpdateSettings { + branch: string; + updateAutomatically: boolean; + updateMechanism: UpdateMechanism; + updateScriptPath: string; +} + +const useUpdateSettings = () => { + return useApiQuery({ + path: '/settings/update', + }); +}; + +export default useUpdateSettings; diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx index 452d6ba56..34ef64659 100644 --- a/frontend/src/System/Updates/Updates.tsx +++ b/frontend/src/System/Updates/Updates.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import * as commandNames from 'Commands/commandNames'; import Alert from 'Components/Alert'; @@ -13,6 +12,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; +import useUpdateSettings from 'Settings/General/useUpdateSettings'; import { executeCommand } from 'Store/Actions/commandActions'; import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; import { fetchUpdates } from 'Store/Actions/systemActions'; @@ -24,32 +24,11 @@ import formatDate from 'Utilities/Date/formatDate'; import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; import UpdateChanges from './UpdateChanges'; +import useUpdates from './useUpdates'; import styles from './Updates.css'; const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i; -function createUpdatesSelector() { - return createSelector( - (state: AppState) => state.system.updates, - (state: AppState) => state.settings.general, - (updates, generalSettings) => { - const { error: updatesError, items } = updates; - - const isFetching = updates.isFetching || generalSettings.isFetching; - const isPopulated = updates.isPopulated && generalSettings.isPopulated; - - return { - isFetching, - isPopulated, - updatesError, - generalSettingsError: generalSettings.error, - items, - updateMechanism: generalSettings.item.updateMechanism, - }; - } - ); -} - function Updates() { const currentVersion = useSelector((state: AppState) => state.app.version); const { packageUpdateMechanismMessage } = useSelector( @@ -63,19 +42,26 @@ function Updates() { ); const { - isFetching, - isPopulated, - updatesError, - generalSettingsError, - items, - updateMechanism, - } = useSelector(createUpdatesSelector()); + data: updates, + isFetched: isUpdatesFetched, + isLoading: isLoadingUpdates, + error: updatesError, + } = useUpdates(); + const { + data: updateSettings, + isFetched: isSettingsFetched, + isLoading: isLoadingSettings, + error: settingsError, + } = useUpdateSettings(); const dispatch = useDispatch(); const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); - const hasError = !!(updatesError || generalSettingsError); - const hasUpdates = isPopulated && !hasError && items.length > 0; - const noUpdates = isPopulated && !hasError && !items.length; + const isFetching = isLoadingUpdates || isLoadingSettings; + const isPopulated = isUpdatesFetched && isSettingsFetched; + const updateMechanism = updateSettings?.updateMechanism ?? 'builtIn'; + const hasError = !!(updatesError || settingsError); + const hasUpdates = isPopulated && !hasError && updates.length > 0; + const noUpdates = isPopulated && !hasError && !updates.length; const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError'); const externalUpdaterMessages: Partial> = { @@ -89,18 +75,18 @@ function Updates() { currentVersion.match(VERSION_REGEX)?.[0] ?? '0' ); - const latestVersion = items[0]?.version; + const latestVersion = updates[0]?.version; const latestMajorVersion = parseInt( latestVersion?.match(VERSION_REGEX)?.[0] ?? '0' ); return { isMajorUpdate: latestMajorVersion > majorVersion, - hasUpdateToInstall: items.some( + hasUpdateToInstall: updates.some( (update) => update.installable && update.latest ), }; - }, [currentVersion, items]); + }, [currentVersion, updates]); const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; @@ -191,7 +177,7 @@ function Updates() { {hasUpdates && (
- {items.map((update) => { + {updates.map((update) => { return (
@@ -268,7 +254,7 @@ function Updates() { ) : null} - {generalSettingsError ? ( + {settingsError ? ( {translate('FailedToFetchSettings')} diff --git a/frontend/src/System/Updates/useUpdates.ts b/frontend/src/System/Updates/useUpdates.ts new file mode 100644 index 000000000..ec376596f --- /dev/null +++ b/frontend/src/System/Updates/useUpdates.ts @@ -0,0 +1,15 @@ +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import Update from 'typings/Update'; + +const useUpdates = () => { + const result = useApiQuery({ + path: '/update', + }); + + return { + ...result, + data: result.data ?? [], + }; +}; + +export default useUpdates; diff --git a/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs b/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs new file mode 100644 index 000000000..b44942bba --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Update; +using NzbDrone.Core.Validation.Paths; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Settings; + +[V5ApiController("settings/update")] +public class UpdateSettingsController : RestController +{ + private readonly IConfigFileProvider _configFileProvider; + + public UpdateSettingsController(IConfigFileProvider configFileProvider) + { + _configFileProvider = configFileProvider; + SharedValidator.RuleFor(c => c.UpdateScriptPath) + .IsValidPath() + .When(c => c.UpdateMechanism == UpdateMechanism.Script); + } + + [HttpGet] + public UpdateSettingsResource GetUpdateSettings() + { + var resource = new UpdateSettingsResource + { + Branch = _configFileProvider.Branch, + UpdateAutomatically = _configFileProvider.UpdateAutomatically, + UpdateMechanism = _configFileProvider.UpdateMechanism, + UpdateScriptPath = _configFileProvider.UpdateScriptPath + }; + + return resource; + } + + [HttpPut] + public ActionResult SaveUpdateSettings([FromBody] UpdateSettingsResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configFileProvider.SaveConfigDictionary(dictionary); + + return Accepted(resource); + } +} diff --git a/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs b/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs new file mode 100644 index 000000000..d17f2f12e --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Update; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Settings; + +public class UpdateSettingsResource : RestResource +{ + public string? Branch { get; set; } + public bool UpdateAutomatically { get; set; } + public UpdateMechanism UpdateMechanism { get; set; } + public string? UpdateScriptPath { get; set; } +} diff --git a/src/Sonarr.Api.V5/Update/UpdateController.cs b/src/Sonarr.Api.V5/Update/UpdateController.cs new file mode 100644 index 000000000..a2d0cbabe --- /dev/null +++ b/src/Sonarr.Api.V5/Update/UpdateController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Update; +using NzbDrone.Core.Update.History; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Update +{ + [V5ApiController] + public class UpdateController : Controller + { + private readonly IRecentUpdateProvider _recentUpdateProvider; + private readonly IUpdateHistoryService _updateHistoryService; + private readonly IConfigFileProvider _configFileProvider; + + public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHistoryService updateHistoryService, IConfigFileProvider configFileProvider) + { + _recentUpdateProvider = recentUpdateProvider; + _updateHistoryService = updateHistoryService; + _configFileProvider = configFileProvider; + } + + [HttpGet] + [Produces("application/json")] + public List GetRecentUpdates() + { + var resources = _recentUpdateProvider.GetRecentUpdatePackages() + .OrderByDescending(u => u.Version) + .ToResource(); + + if (resources.Any()) + { + var first = resources.First(); + first.Latest = true; + + if (first.Version > BuildInfo.Version) + { + first.Installable = true; + } + + var installed = resources.SingleOrDefault(r => r.Version == BuildInfo.Version); + + if (installed != null) + { + installed.Installed = true; + } + + if (!_configFileProvider.LogDbEnabled) + { + return resources; + } + + var updateHistory = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate); + var installDates = updateHistory + .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/Sonarr.Api.V5/Update/UpdateResource.cs b/src/Sonarr.Api.V5/Update/UpdateResource.cs new file mode 100644 index 000000000..48db70aae --- /dev/null +++ b/src/Sonarr.Api.V5/Update/UpdateResource.cs @@ -0,0 +1,48 @@ +using NzbDrone.Core.Update; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Update +{ + public class UpdateResource : RestResource + { + public required Version Version { get; set; } + + public required string Branch { get; set; } + public DateTime ReleaseDate { get; set; } + public required string FileName { get; set; } + public required 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 required UpdateChanges Changes { get; set; } + public required string Hash { get; set; } + } + + public static class UpdateResourceMapper + { + public static UpdateResource ToResource(this UpdatePackage model) + { + return new UpdateResource + { + Version = model.Version, + + Branch = model.Branch, + ReleaseDate = model.ReleaseDate, + FileName = model.FileName, + Url = model.Url, + + // Installed + // Installable + // Latest + Changes = model.Changes, + Hash = model.Hash, + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +}