diff --git a/app/controllers/katello/api/v2/sync_status_controller.rb b/app/controllers/katello/api/v2/sync_status_controller.rb new file mode 100644 index 00000000000..ffb888eb7fc --- /dev/null +++ b/app/controllers/katello/api/v2/sync_status_controller.rb @@ -0,0 +1,90 @@ +module Katello + class Api::V2::SyncStatusController < Api::V2::ApiController + include SyncManagementHelper::RepoMethods + + before_action :find_optional_organization, :only => [:index, :poll, :sync] + before_action :find_repository, :only => [:destroy] + + api :GET, "/sync_status", N_("Get sync status for all repositories in an organization") + param :organization_id, :number, :desc => N_("ID of an organization"), :required => false + def index + org = @organization || current_organization_object + fail HttpErrors::NotFound, _("Organization required") if org.nil? + + products = org.library.products.readable + redhat_products, custom_products = products.partition(&:redhat?) + redhat_products.sort_by! { |p| p.name.downcase } + custom_products.sort_by! { |p| p.name.downcase } + + sorted_products = redhat_products + custom_products + + @product_tree = collect_repos(sorted_products, org.library, false) + + # Filter out products and intermediate nodes with no repositories + @product_tree = filter_empty_nodes(@product_tree) + + @repo_statuses = collect_all_repo_statuses(sorted_products, org.library) + + respond_for_index(:collection => {:products => @product_tree, :repo_statuses => @repo_statuses}) + end + + api :GET, "/sync_status/poll", N_("Poll sync status for specified repositories") + param :repository_ids, Array, :desc => N_("List of repository IDs to poll"), :required => true + param :organization_id, :number, :desc => N_("ID of an organization"), :required => false + def poll + repos = Repository.where(:id => params[:repository_ids]).readable + statuses = repos.map { |repo| format_sync_progress(repo) } + + render :json => statuses + end + + api :POST, "/sync_status/sync", N_("Synchronize repositories") + param :repository_ids, Array, :desc => N_("List of repository IDs to sync"), :required => true + param :organization_id, :number, :desc => N_("ID of an organization"), :required => false + def sync + collected = [] + repos = Repository.where(:id => params[:repository_ids]).syncable + + repos.each do |repo| + if latest_task(repo).try(:state) != 'running' + ForemanTasks.async_task(::Actions::Katello::Repository::Sync, repo) + end + collected << format_sync_progress(repo) + end + + render :json => collected + end + + api :DELETE, "/sync_status/:id", N_("Cancel repository synchronization") + param :id, :number, :desc => N_("Repository ID"), :required => true + def destroy + @repository.cancel_dynflow_sync + render :json => {:message => _("Sync canceled")} + end + + private + + def find_repository + @repository = Repository.where(:id => params[:id]).syncable.first + fail HttpErrors::NotFound, _("Repository not found or not syncable") if @repository.nil? + end + + def format_sync_progress(repo) + ::Katello::SyncStatusPresenter.new(repo, latest_task(repo)).sync_progress + end + + def latest_task(repo) + repo.latest_dynflow_sync + end + + def collect_all_repo_statuses(products, env) + statuses = {} + products.each do |product| + product.repos(env).each do |repo| + statuses[repo.id] = format_sync_progress(repo) + end + end + statuses + end + end +end diff --git a/app/helpers/katello/sync_management_helper.rb b/app/helpers/katello/sync_management_helper.rb index 9ae32fd04d0..1775f0ecf11 100644 --- a/app/helpers/katello/sync_management_helper.rb +++ b/app/helpers/katello/sync_management_helper.rb @@ -29,26 +29,58 @@ def any_syncable? end module RepoMethods + # Format a repository as a hash for the API + def format_repo(repo) + { + :id => repo.id, + :name => repo.name, + :type => "repo", + } + end + + # Recursively check if a node has any repositories + def repos?(node) + return true if node[:repos].present? && node[:repos].any? + return false if node[:children].blank? + node[:children].any? { |child| repos?(child) } + end + + # Filter out nodes with no repositories + def filter_empty_nodes(nodes) + nodes.select { |node| repos?(node) }.map do |node| + if node[:children].present? + node.merge(:children => filter_empty_nodes(node[:children])) + else + node + end + end + end + # returns all repos in hash representation with minors and arch children included def collect_repos(products, env, include_feedless = true) products.map do |prod| minor_repos, repos_without_minor = collect_minor(prod.repos(env, nil, include_feedless)) - { :name => prod.name, :object => prod, :id => prod.id, :type => "product", :repos => repos_without_minor, - :children => minors(minor_repos), :organization => prod.organization.name } + { :name => prod.name, :object => prod, :id => prod.id, :type => "product", + :repos => repos_without_minor.map { |r| format_repo(r) }, + :children => minors(minor_repos, prod.id), :organization => prod.organization.name } end end # returns all minors in hash representation with arch children included - def minors(minor_repos) + def minors(minor_repos, product_id) minor_repos.map do |minor, repos| - { :name => minor, :id => minor, :type => "minor", :children => arches(repos), :repos => [] } + minor_id = "#{product_id}-#{minor}" + { :name => minor, :id => minor_id, :type => "minor", + :children => arches(repos, minor_id), :repos => [] } end end # returns all archs in hash representation - def arches(arch_repos) + def arches(arch_repos, parent_id) collect_arches(arch_repos).map do |arch, repos| - { :name => arch, :id => arch, :type => "arch", :children => [], :repos => repos } + arch_id = "#{parent_id}-#{arch}" + { :name => arch, :id => arch_id, :type => "arch", :children => [], + :repos => repos.map { |r| format_repo(r) } } end end diff --git a/app/views/katello/api/v2/sync_status/index.json.rabl b/app/views/katello/api/v2/sync_status/index.json.rabl new file mode 100644 index 00000000000..34cde1598e1 --- /dev/null +++ b/app/views/katello/api/v2/sync_status/index.json.rabl @@ -0,0 +1,9 @@ +object false + +node :products do + @product_tree +end + +node :repo_statuses do + @repo_statuses +end diff --git a/app/views/katello/api/v2/sync_status/poll.json.rabl b/app/views/katello/api/v2/sync_status/poll.json.rabl new file mode 100644 index 00000000000..a21fbeb46e3 --- /dev/null +++ b/app/views/katello/api/v2/sync_status/poll.json.rabl @@ -0,0 +1,5 @@ +collection @collection + +attributes :id, :product_id, :progress, :sync_id, :state, :raw_state +attributes :start_time, :finish_time, :duration, :display_size, :size +attributes :is_running, :error_details diff --git a/app/views/katello/api/v2/sync_status/sync.json.rabl b/app/views/katello/api/v2/sync_status/sync.json.rabl new file mode 100644 index 00000000000..f1c17497f67 --- /dev/null +++ b/app/views/katello/api/v2/sync_status/sync.json.rabl @@ -0,0 +1,5 @@ +collection @collection => :results + +node do |item| + item +end diff --git a/config/routes.rb b/config/routes.rb index d7477a3f618..476e746f962 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,20 +2,15 @@ scope :katello, :path => '/katello' do match ':kt_path/auto_complete_search', :action => :auto_complete_search, :controller => :auto_complete_search, :via => :get - resources :sync_management, :only => [:destroy] do - collection do - get :index - get :sync_status - post :sync - end - end - match '/remote_execution' => 'remote_execution#create', :via => [:post] end get '/katello/providers/redhat_provider', to: redirect('/redhat_repositories') match '/redhat_repositories' => 'react#index', :via => [:get] + get '/katello/sync_management', to: redirect('/sync_management') + match '/sync_management' => 'react#index', :via => [:get] + match '/subscriptions' => 'react#index', :via => [:get] match '/subscriptions/*page' => 'react#index', :via => [:get] diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 78867e5eab0..de0a22c6f39 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -516,6 +516,13 @@ class ActionDispatch::Routing::Mapper get :auto_complete_search, :on => :collection put :sync end + + api_resources :sync_status, :only => [:index, :destroy] do + collection do + get :poll + post :sync + end + end end # module v2 end # '/api' namespace end # '/katello' namespace diff --git a/lib/katello/plugin.rb b/lib/katello/plugin.rb index b9401fb8a0f..369dd972499 100644 --- a/lib/katello/plugin.rb +++ b/lib/katello/plugin.rb @@ -64,7 +64,8 @@ menu :top_menu, :sync_status, :caption => N_('Sync Status'), - :url_hash => {:controller => 'katello/sync_management', + :url => '/sync_management', + :url_hash => {:controller => 'katello/api/v2/sync_status', :action => 'index'}, :engine => Katello::Engine, :turbolinks => false diff --git a/test/controllers/api/v2/sync_status_controller_test.rb b/test/controllers/api/v2/sync_status_controller_test.rb new file mode 100644 index 00000000000..36cd42e6a79 --- /dev/null +++ b/test/controllers/api/v2/sync_status_controller_test.rb @@ -0,0 +1,65 @@ +require 'katello_test_helper' + +module Katello + class Api::V2::SyncStatusControllerTest < ActionController::TestCase + def models + @organization = get_organization + @repository = katello_repositories(:fedora_17_x86_64) + @product = katello_products(:fedora) + end + + def permissions + @sync_permission = :sync_products + end + + def build_task_stub + task_attrs = [:id, :label, :pending, :execution_plan, :resumable?, + :username, :started_at, :ended_at, :state, :result, :progress, + :input, :humanized, :cli_example, :errors].inject({}) { |h, k| h.update k => nil } + task_attrs[:output] = {} + stub('task', task_attrs).mimic!(::ForemanTasks::Task) + end + + def setup + setup_controller_defaults_api + login_user(User.find(users(:admin).id)) + models + permissions + ForemanTasks.stubs(:async_task).returns(build_task_stub) + end + + def test_index + @controller.expects(:collect_repos).returns([]) + @controller.expects(:collect_all_repo_statuses).returns({}) + + get :index, params: { :organization_id => @organization.id } + + assert_response :success + end + + def test_poll + @controller.expects(:format_sync_progress).returns({}) + + get :poll, params: { :repository_ids => [@repository.id], :organization_id => @organization.id } + + assert_response :success + end + + def test_sync + @controller.expects(:latest_task).returns(nil) + @controller.expects(:format_sync_progress).returns({}) + + post :sync, params: { :repository_ids => [@repository.id], :organization_id => @organization.id } + + assert_response :success + end + + def test_destroy + Repository.any_instance.expects(:cancel_dynflow_sync) + + delete :destroy, params: { :id => @repository.id } + + assert_response :success + end + end +end diff --git a/webpack/containers/Application/config.js b/webpack/containers/Application/config.js index 94571c06f55..081d66ad405 100644 --- a/webpack/containers/Application/config.js +++ b/webpack/containers/Application/config.js @@ -20,6 +20,7 @@ import ContainerImages from '../../scenes/ContainerImages'; import ManifestDetails from '../../scenes/ContainerImages/Synced/Details'; import FlatpakRemotes from '../../scenes/FlatpakRemotes'; import FlatpakRemoteDetails from '../../scenes/FlatpakRemotes/Details'; +import SyncStatus from '../../scenes/SyncStatus'; // eslint-disable-next-line import/prefer-default-export export const links = [ @@ -27,6 +28,10 @@ export const links = [ path: 'redhat_repositories', component: WithOrganization(withHeader(Repos, { title: __('RH Repos') })), }, + { + path: 'sync_management', + component: WithOrganization(withHeader(SyncStatus, { title: __('Sync Status') })), + }, { path: 'subscriptions', component: WithOrganization(withHeader(Subscriptions, { title: __('Subscriptions') })), diff --git a/webpack/scenes/SyncStatus/SyncStatus.scss b/webpack/scenes/SyncStatus/SyncStatus.scss new file mode 100644 index 00000000000..0d65d1d674c --- /dev/null +++ b/webpack/scenes/SyncStatus/SyncStatus.scss @@ -0,0 +1,17 @@ +// Sticky toolbar and table header coordination +// Target the toolbar by its ouiaId with more specific selector +.pf-v5-c-toolbar.pf-m-sticky[data-ouia-component-id="sync-status-toolbar"] { + position: sticky; + top: 0; + z-index: 400; + box-shadow: none; +} + +// Target the table by its ouiaId and offset the sticky header +.pf-v5-c-table[data-ouia-component-id="sync-status-table"] { + thead { + position: sticky; + top: 70px; + z-index: 300; + } +} diff --git a/webpack/scenes/SyncStatus/SyncStatusActions.js b/webpack/scenes/SyncStatus/SyncStatusActions.js new file mode 100644 index 00000000000..e059ac3506f --- /dev/null +++ b/webpack/scenes/SyncStatus/SyncStatusActions.js @@ -0,0 +1,69 @@ +import { API_OPERATIONS, get, post, APIActions } from 'foremanReact/redux/API'; +import { translate as __ } from 'foremanReact/common/I18n'; +import api, { orgId } from '../../services/api'; +import SYNC_STATUS_KEY, { + SYNC_STATUS_POLL_KEY, + SYNC_REPOSITORIES_KEY, + CANCEL_SYNC_KEY, +} from './SyncStatusConstants'; +import { getResponseErrorMsgs } from '../../utils/helpers'; + +export const syncStatusErrorToast = error => getResponseErrorMsgs(error.response); + +export const getSyncStatus = (extraParams = {}) => get({ + type: API_OPERATIONS.GET, + key: SYNC_STATUS_KEY, + url: api.getApiUrl('/sync_status'), + params: { + organization_id: orgId(), + ...extraParams, + }, + errorToast: error => syncStatusErrorToast(error), +}); + +export const pollSyncStatus = (repositoryIds, extraParams = {}) => get({ + type: API_OPERATIONS.GET, + key: SYNC_STATUS_POLL_KEY, + url: api.getApiUrl('/sync_status/poll'), + params: { + repository_ids: repositoryIds, + organization_id: orgId(), + ...extraParams, + }, + errorToast: error => syncStatusErrorToast(error), +}); + +export const syncRepositories = (repositoryIds, handleSuccess, handleError) => post({ + type: API_OPERATIONS.POST, + key: SYNC_REPOSITORIES_KEY, + url: api.getApiUrl('/sync_status/sync'), + params: { + repository_ids: repositoryIds, + organization_id: orgId(), + }, + handleSuccess: (response) => { + if (handleSuccess) { + handleSuccess(response); + } + // The API returns an array of sync status objects + // Just show a simple success message + return __('Repository synchronization started'); + }, + handleError, + successToast: () => __('Repository synchronization started'), + errorToast: (error) => { + const message = getResponseErrorMsgs(error?.response); + return message || __('Failed to start repository synchronization'); + }, +}); + +export const cancelSync = (repositoryId, handleSuccess) => APIActions.delete({ + type: API_OPERATIONS.DELETE, + key: CANCEL_SYNC_KEY, + url: api.getApiUrl(`/sync_status/${repositoryId}`), + handleSuccess, + successToast: () => __('Sync canceled'), + errorToast: error => syncStatusErrorToast(error), +}); + +export default getSyncStatus; diff --git a/webpack/scenes/SyncStatus/SyncStatusConstants.js b/webpack/scenes/SyncStatus/SyncStatusConstants.js new file mode 100644 index 00000000000..5bf6d14b68a --- /dev/null +++ b/webpack/scenes/SyncStatus/SyncStatusConstants.js @@ -0,0 +1,24 @@ +import { translate as __ } from 'foremanReact/common/I18n'; + +const SYNC_STATUS_KEY = 'SYNC_STATUS'; +export const SYNC_STATUS_POLL_KEY = 'SYNC_STATUS_POLL'; +export const SYNC_REPOSITORIES_KEY = 'SYNC_REPOSITORIES'; +export const CANCEL_SYNC_KEY = 'CANCEL_SYNC'; + +export const SYNC_STATE_STOPPED = 'stopped'; +export const SYNC_STATE_ERROR = 'error'; +export const SYNC_STATE_NEVER_SYNCED = 'never_synced'; +export const SYNC_STATE_RUNNING = 'running'; +export const SYNC_STATE_CANCELED = 'canceled'; +export const SYNC_STATE_PAUSED = 'paused'; + +export const SYNC_STATE_LABELS = { + [SYNC_STATE_STOPPED]: __('Syncing complete'), + [SYNC_STATE_ERROR]: __('Sync incomplete'), + [SYNC_STATE_NEVER_SYNCED]: __('Never synced'), + [SYNC_STATE_RUNNING]: __('Running'), + [SYNC_STATE_CANCELED]: __('Canceled'), + [SYNC_STATE_PAUSED]: __('Paused'), +}; + +export default SYNC_STATUS_KEY; diff --git a/webpack/scenes/SyncStatus/SyncStatusPage.js b/webpack/scenes/SyncStatus/SyncStatusPage.js new file mode 100644 index 00000000000..f94cefaa1ce --- /dev/null +++ b/webpack/scenes/SyncStatus/SyncStatusPage.js @@ -0,0 +1,384 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { STATUS } from 'foremanReact/constants'; +import Loading from 'foremanReact/components/Loading'; +import { useSelectionSet } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; +import { + getSyncStatus, + pollSyncStatus, + syncRepositories, + cancelSync, +} from './SyncStatusActions'; +import { + selectSyncStatus, + selectSyncStatusStatus, + selectSyncStatusPoll, +} from './SyncStatusSelectors'; +import SyncStatusToolbar from './components/SyncStatusToolbar'; +import SyncStatusTable from './components/SyncStatusTable'; +import './SyncStatus.scss'; + +const POLL_INTERVAL = 1000; // Poll every 1 second + +const SyncStatusPage = () => { + const dispatch = useDispatch(); + const syncStatusData = useSelector(selectSyncStatus); + const syncStatusStatus = useSelector(selectSyncStatusStatus); + const pollData = useSelector(selectSyncStatusPoll); + + const [expandedNodeIds, setExpandedNodeIds] = useState([]); + const [showActiveOnly, setShowActiveOnly] = useState(false); + const [repoStatuses, setRepoStatuses] = useState({}); + const [isSyncing, setIsSyncing] = useState(false); + + // Flatten tree to get all repos for selection + const allRepos = useMemo(() => { + const repos = []; + const traverse = (nodes) => { + nodes.forEach((node) => { + if (node.type === 'repo') { + repos.push(node); + } + if (node.children) traverse(node.children); + if (node.repos) traverse(node.repos); + }); + }; + if (syncStatusData?.products) { + traverse(syncStatusData.products); + } + return repos; + }, [syncStatusData]); + + // Use selection hook from Foreman + const { + selectOne, + selectPage, + selectNone, + selectedCount, + areAllRowsSelected, + isSelected, + selectionSet, + } = useSelectionSet({ + results: allRepos, + metadata: { selectable: allRepos.length }, + isSelectable: () => true, + }); + + // Get selected repo IDs from selectionSet + const selectedRepoIds = Array.from(selectionSet); + + // Use refs for mutable values that don't need to trigger re-renders + const hasAutoExpandedRef = React.useRef(false); + const pollTimerRef = React.useRef(null); + + // Load initial data + useEffect(() => { + dispatch(getSyncStatus()); + }, [dispatch]); + + // Update repo statuses when initial data loads + useEffect(() => { + if (syncStatusData?.repo_statuses) { + setRepoStatuses(syncStatusData.repo_statuses); + } + }, [syncStatusData]); + + // Update repo statuses from poll data + useEffect(() => { + if (pollData && Array.isArray(pollData)) { + setRepoStatuses((prev) => { + const updated = { ...prev }; + pollData.forEach((status) => { + if (status.id) { + updated[status.id] = status; + } + }); + return updated; + }); + } + }, [pollData]); + + // Auto-expand tree to show syncing repos on initial load (only once) + useEffect(() => { + // Only run once, and only if we haven't auto-expanded yet + if (hasAutoExpandedRef.current) { + return; + } + + if (!syncStatusData?.products || !repoStatuses || Object.keys(repoStatuses).length === 0) { + return; + } + + // Mark that we've attempted auto-expand (even if no syncing repos found) + hasAutoExpandedRef.current = true; + + // Find all syncing repo IDs + const syncingRepoIds = Object.entries(repoStatuses) + .filter(([_, status]) => status?.is_running === true) + .map(([id]) => parseInt(id, 10)); + + if (syncingRepoIds.length === 0) { + return; + } + + // Traverse tree and collect ancestor node IDs for syncing repos + const ancestorNodeIds = new Set(); + + const findAncestors = (nodes, ancestors = []) => { + nodes.forEach((node) => { + const currentAncestors = [...ancestors]; + const nodeId = `${node.type}-${node.id}`; + + // If this is a syncing repo, add all ancestors + if (node.type === 'repo' && syncingRepoIds.includes(node.id)) { + currentAncestors.forEach(ancestorId => ancestorNodeIds.add(ancestorId)); + } + + // Add current node to ancestors if it has children + if (node.children || node.repos) { + currentAncestors.push(nodeId); + } + + // Recursively check children + if (node.children) { + findAncestors(node.children, currentAncestors); + } + if (node.repos) { + findAncestors(node.repos, currentAncestors); + } + }); + }; + + findAncestors(syncStatusData.products); + + // Only update if we found ancestors to expand + if (ancestorNodeIds.size > 0) { + setExpandedNodeIds(Array.from(ancestorNodeIds)); + } + }, [syncStatusData, repoStatuses]); + + + // Start/stop polling based on whether there are active syncs + useEffect(() => { + const syncingIds = Object.entries(repoStatuses) + .filter(([_, status]) => status?.is_running === true) + .map(([id]) => parseInt(id, 10)); + + if (syncingIds.length > 0 && !pollTimerRef.current) { + // Start polling + pollTimerRef.current = setInterval(() => { + const currentSyncingIds = Object.entries(repoStatuses) + .filter(([_, status]) => status?.is_running === true) + .map(([id]) => parseInt(id, 10)); + + if (currentSyncingIds.length > 0) { + dispatch(pollSyncStatus(currentSyncingIds)); + } + }, POLL_INTERVAL); + } else if (syncingIds.length === 0 && pollTimerRef.current) { + // Stop polling + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + + // Cleanup on unmount + return () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + }, [repoStatuses, dispatch]); + + const handleSelectRepo = (repoId, repoData) => { + const isCurrentlySelected = isSelected(repoId); + selectOne(!isCurrentlySelected, repoId, repoData); + }; + + // Product selection handler - selects/deselects all child repos + const handleSelectProduct = (product) => { + // Get all child repo IDs and all descendant node IDs + const childRepos = []; + const descendantNodeIds = []; + const getChildRepos = (node) => { + const nodeId = `${node.type}-${node.id}`; + // Track all nodes with children (products, minors, archs) + if (node.children || node.repos) { + descendantNodeIds.push(nodeId); + } + if (node.type === 'repo') { + childRepos.push(node); + } + if (node.children) { + node.children.forEach(getChildRepos); + } + if (node.repos) { + node.repos.forEach(getChildRepos); + } + }; + getChildRepos(product); + + // Check if all children are selected + const allChildrenSelected = childRepos.length > 0 && + childRepos.every(repo => isSelected(repo.id)); + + if (allChildrenSelected) { + // Deselect all children + childRepos.forEach(repo => selectOne(false, repo.id, repo)); + } else { + // Select all children and expand all descendant nodes to show selected children + childRepos.forEach(repo => selectOne(true, repo.id, repo)); + // Auto-expand all descendant nodes + setExpandedNodeIds((prev) => { + const newExpanded = [...prev]; + descendantNodeIds.forEach((nodeId) => { + if (!newExpanded.includes(nodeId)) { + newExpanded.push(nodeId); + } + }); + return newExpanded; + }); + } + }; + + const handleExpandAll = () => { + const allNodeIds = []; + const traverse = (nodes) => { + nodes.forEach((node) => { + if (node.children || node.repos) { + allNodeIds.push(`${node.type}-${node.id}`); + } + if (node.children) traverse(node.children); + }); + }; + if (syncStatusData?.products) { + traverse(syncStatusData.products); + } + setExpandedNodeIds(allNodeIds); + }; + + const handleCollapseAll = () => { + setExpandedNodeIds([]); + }; + + const handleSyncNow = () => { + if (selectedRepoIds.length > 0) { + setIsSyncing(true); + dispatch(syncRepositories( + selectedRepoIds, + (response) => { + setIsSyncing(false); + // Update repo statuses immediately from sync response + if (response?.data && Array.isArray(response.data)) { + setRepoStatuses((prev) => { + const updated = { ...prev }; + response.data.forEach((status) => { + if (status.id) { + updated[status.id] = status; + } + }); + return updated; + }); + } + // Also poll immediately to get latest status + dispatch(pollSyncStatus(selectedRepoIds)); + }, + () => { + // Error handler - reset syncing state + setIsSyncing(false); + }, + )); + } + }; + + const handleCancelSync = (repoId) => { + dispatch(cancelSync(repoId, () => { + // Refresh status after cancel + dispatch(pollSyncStatus([repoId])); + })); + }; + + const handleToggleActiveOnly = () => { + setShowActiveOnly(prev => !prev); + }; + + // Single repository sync handler + const handleSyncRepo = (repoId) => { + setIsSyncing(true); + dispatch(syncRepositories( + [repoId], + (response) => { + setIsSyncing(false); + // Update repo statuses immediately from sync response + if (response?.data && Array.isArray(response.data)) { + setRepoStatuses((prev) => { + const updated = { ...prev }; + response.data.forEach((status) => { + if (status.id) { + updated[status.id] = status; + } + }); + return updated; + }); + } + // Also poll immediately to get latest status + dispatch(pollSyncStatus([repoId])); + }, + () => { + // Error handler - reset syncing state + setIsSyncing(false); + }, + )); + }; + + if (syncStatusStatus === STATUS.PENDING) { + return ; + } + + return ( + <> + + {__('Sync Status')} + + + + + + + ); +}; + +export default SyncStatusPage; diff --git a/webpack/scenes/SyncStatus/SyncStatusSelectors.js b/webpack/scenes/SyncStatus/SyncStatusSelectors.js new file mode 100644 index 00000000000..6d708cc288b --- /dev/null +++ b/webpack/scenes/SyncStatus/SyncStatusSelectors.js @@ -0,0 +1,43 @@ +import { selectAPIError, selectAPIResponse, selectAPIStatus } from 'foremanReact/redux/API/APISelectors'; +import { STATUS } from 'foremanReact/constants'; +import SYNC_STATUS_KEY, { + SYNC_STATUS_POLL_KEY, + SYNC_REPOSITORIES_KEY, + CANCEL_SYNC_KEY, +} from './SyncStatusConstants'; + +export const selectSyncStatus = state => + selectAPIResponse(state, SYNC_STATUS_KEY) || {}; + +export const selectSyncStatusStatus = state => + selectAPIStatus(state, SYNC_STATUS_KEY) || STATUS.PENDING; + +export const selectSyncStatusError = state => + selectAPIError(state, SYNC_STATUS_KEY); + +export const selectSyncStatusPoll = state => + selectAPIResponse(state, SYNC_STATUS_POLL_KEY) || []; + +export const selectSyncStatusPollStatus = state => + selectAPIStatus(state, SYNC_STATUS_POLL_KEY) || STATUS.PENDING; + +export const selectSyncStatusPollError = state => + selectAPIError(state, SYNC_STATUS_POLL_KEY); + +export const selectSyncRepositories = state => + selectAPIResponse(state, SYNC_REPOSITORIES_KEY) || []; + +export const selectSyncRepositoriesStatus = state => + selectAPIStatus(state, SYNC_REPOSITORIES_KEY) || STATUS.PENDING; + +export const selectSyncRepositoriesError = state => + selectAPIError(state, SYNC_REPOSITORIES_KEY); + +export const selectCancelSync = state => + selectAPIResponse(state, CANCEL_SYNC_KEY) || {}; + +export const selectCancelSyncStatus = state => + selectAPIStatus(state, CANCEL_SYNC_KEY) || STATUS.PENDING; + +export const selectCancelSyncError = state => + selectAPIError(state, CANCEL_SYNC_KEY); diff --git a/webpack/scenes/SyncStatus/__tests__/SyncStatusPage.test.js b/webpack/scenes/SyncStatus/__tests__/SyncStatusPage.test.js new file mode 100644 index 00000000000..bff91e6459e --- /dev/null +++ b/webpack/scenes/SyncStatus/__tests__/SyncStatusPage.test.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { renderWithRedux, patientlyWaitFor } from 'react-testing-lib-wrapper'; +import { nockInstance, assertNockRequest } from '../../../test-utils/nockWrapper'; +import SyncStatusPage from '../SyncStatusPage'; +import SYNC_STATUS_KEY from '../SyncStatusConstants'; +import api from '../../../services/api'; + +const syncStatusPath = api.getApiUrl('/sync_status'); +const renderOptions = { apiNamespace: SYNC_STATUS_KEY }; + +const mockSyncStatusData = { + products: [ + { + id: 1, + type: 'product', + name: 'Test Product', + children: [ + { + id: 1, + type: 'repo', + name: 'Test Repository', + label: 'test-repo', + product_id: 1, + }, + ], + }, + ], + repo_statuses: { + 1: { + id: 1, + is_running: false, + last_sync_words: 'Never synced', + }, + }, +}; + +test('Can call API for sync status and show on screen on page load', async () => { + const scope = nockInstance + .get(syncStatusPath) + .query(true) + .reply(200, mockSyncStatusData); + + const { queryByText } = renderWithRedux(, renderOptions); + + // Initially shouldn't show the product + expect(queryByText('Test Product')).toBeNull(); + + // Wait for data to load + await patientlyWaitFor(() => { + expect(queryByText('Sync Status')).toBeInTheDocument(); + expect(queryByText('Test Product')).toBeInTheDocument(); + }); + + assertNockRequest(scope); +}); + +test('Displays toolbar with action buttons', async () => { + const scope = nockInstance + .get(syncStatusPath) + .query(true) + .reply(200, mockSyncStatusData); + + const { getAllByText, queryByText } = renderWithRedux(, renderOptions); + + await patientlyWaitFor(() => { + // Switch renders label twice (on/off states), so use getAllByText + expect(getAllByText('Show syncing only').length).toBeGreaterThan(0); + expect(queryByText('Synchronize')).toBeInTheDocument(); + }); + + assertNockRequest(scope); +}); + +test('Displays empty state when no products exist', async () => { + const emptyData = { + products: [], + repo_statuses: {}, + }; + + const scope = nockInstance + .get(syncStatusPath) + .query(true) + .reply(200, emptyData); + + const { queryByText } = renderWithRedux(, renderOptions); + + await patientlyWaitFor(() => { + expect(queryByText('Sync Status')).toBeInTheDocument(); + }); + + assertNockRequest(scope); +}); + +test('Handles API error (500) gracefully', async () => { + const scope = nockInstance + .get(syncStatusPath) + .query(true) + .reply(500, { error: 'Internal Server Error' }); + + const { queryByText } = renderWithRedux(, renderOptions); + + await patientlyWaitFor(() => { + // Page should still render the header even with error + expect(queryByText('Sync Status')).toBeInTheDocument(); + // Products won't be shown since API failed + expect(queryByText('Test Product')).toBeNull(); + }); + + assertNockRequest(scope); +}); diff --git a/webpack/scenes/SyncStatus/components/SyncProgressCell.js b/webpack/scenes/SyncStatus/components/SyncProgressCell.js new file mode 100644 index 00000000000..4737ebaf9b5 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/SyncProgressCell.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Progress, Button, Flex, FlexItem } from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { propsToCamelCase } from 'foremanReact/common/helpers'; +import { SYNC_STATE_RUNNING } from '../SyncStatusConstants'; + +const SyncProgressCell = ({ repo, onCancelSync }) => { + const { + isRunning, progress, rawState, id, + } = propsToCamelCase(repo); + + if (!isRunning || rawState !== SYNC_STATE_RUNNING) { + return null; + } + + const progressValue = Math.max(0, Math.min(100, progress?.progress || 0)); + + return ( + + + + + + + + + ); +}; + +SyncProgressCell.propTypes = { + repo: PropTypes.shape({ + id: PropTypes.number, + is_running: PropTypes.bool, + progress: PropTypes.shape({ + progress: PropTypes.number, + }), + raw_state: PropTypes.string, + }).isRequired, + onCancelSync: PropTypes.func.isRequired, +}; + +export default SyncProgressCell; diff --git a/webpack/scenes/SyncStatus/components/SyncResultCell.js b/webpack/scenes/SyncStatus/components/SyncResultCell.js new file mode 100644 index 00000000000..ae87fbce3af --- /dev/null +++ b/webpack/scenes/SyncStatus/components/SyncResultCell.js @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip } from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + BanIcon, + PauseCircleIcon, +} from '@patternfly/react-icons'; +import { foremanUrl, propsToCamelCase } from 'foremanReact/common/helpers'; +import { + SYNC_STATE_STOPPED, + SYNC_STATE_ERROR, + SYNC_STATE_NEVER_SYNCED, + SYNC_STATE_CANCELED, + SYNC_STATE_PAUSED, + SYNC_STATE_LABELS, +} from '../SyncStatusConstants'; + +const SyncResultCell = ({ repo }) => { + const { + rawState, state, syncId, errorDetails, + } = propsToCamelCase(repo); + + const getIcon = () => { + switch (rawState) { + case SYNC_STATE_STOPPED: + return ; + case SYNC_STATE_ERROR: + return ; + case SYNC_STATE_CANCELED: + return ; + case SYNC_STATE_PAUSED: + return ; + case SYNC_STATE_NEVER_SYNCED: + return ; + default: + return null; + } + }; + + const icon = getIcon(); + const label = SYNC_STATE_LABELS[rawState] || state; + + const taskUrl = syncId ? foremanUrl(`/foreman_tasks/tasks/${syncId}`) : null; + + const content = ( + <> + {icon} {taskUrl ? ( + + {label} + + ) : ( + label + )} + + ); + + if (errorDetails) { + let errorText; + if (Array.isArray(errorDetails)) { + errorText = errorDetails.join('\n'); + } else if (typeof errorDetails === 'object') { + errorText = JSON.stringify(errorDetails, null, 2); + } else { + errorText = String(errorDetails); + } + + if (errorText && errorText.length > 0) { + return ( + + {content} + + ); + } + } + + return content; +}; + +SyncResultCell.propTypes = { + repo: PropTypes.shape({ + raw_state: PropTypes.string, + state: PropTypes.string, + start_time: PropTypes.string, + sync_id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + // error_details can be string, array, or object from API + error_details: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + PropTypes.object, + ]), + }).isRequired, +}; + +export default SyncResultCell; diff --git a/webpack/scenes/SyncStatus/components/SyncStatusTable.js b/webpack/scenes/SyncStatus/components/SyncStatusTable.js new file mode 100644 index 00000000000..8f1f65e8aa2 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/SyncStatusTable.js @@ -0,0 +1,367 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TreeRowWrapper, + ActionsColumn, +} from '@patternfly/react-table'; +import { Checkbox } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import SyncProgressCell from './SyncProgressCell'; +import SyncResultCell from './SyncResultCell'; + +const SyncStatusTable = ({ + products, + repoStatuses, + onSelectRepo, + onSelectProduct, + onSyncRepo, + onCancelSync, + expandedNodeIds, + setExpandedNodeIds, + showActiveOnly, + isSelected, + onExpandAll, + onCollapseAll, +}) => { + // Helper to get all child repos of a product/node + const getChildRepos = (node) => { + const repos = []; + const traverse = (n) => { + if (n.type === 'repo') { + repos.push(n); + } + if (n.children) n.children.forEach(traverse); + if (n.repos) n.repos.forEach(traverse); + }; + traverse(node); + return repos; + }; + + // Calculate node checkbox state (works for product, minor, arch) + const getNodeCheckboxState = (node) => { + const childRepos = getChildRepos(node); + if (childRepos.length === 0) { + return { isChecked: false, isIndeterminate: false }; + } + + const selectedCount = childRepos.filter(repo => isSelected(repo.id)).length; + + if (selectedCount === 0) { + return { isChecked: false, isIndeterminate: false }; + } + if (selectedCount === childRepos.length) { + return { isChecked: true, isIndeterminate: false }; + } + return { isChecked: false, isIndeterminate: true }; + }; + + // Calculate total expandable nodes + const totalExpandableNodes = useMemo(() => { + let count = 0; + const traverse = (nodes) => { + nodes.forEach((node) => { + if ((node.children && node.children.length > 0) || + (node.repos && node.repos.length > 0)) { + count += 1; + } + if (node.children) traverse(node.children); + }); + }; + traverse(products); + return count; + }, [products]); + + const allNodesExpanded = expandedNodeIds.length === totalExpandableNodes && + totalExpandableNodes > 0; + // Build flat list of all tree nodes with their hierarchy info + const buildTreeRows = useMemo(() => { + const rows = []; + + // Helper to check if a node will be visible (for setsize calculation) + const isNodeVisible = (node) => { + if (!showActiveOnly) return true; + if (node.type === 'repo') { + const status = repoStatuses[node.id]; + return status?.is_running; + } + // For non-repo nodes, they're visible if shown + return true; + }; + + const addRow = (node, level, parent, posinset, ariaSetSize, isHidden) => { + const nodeId = `${node.type}-${node.id}`; + const hasChildren = (node.children && node.children.length > 0) || + (node.repos && node.repos.length > 0); + const isExpanded = expandedNodeIds.includes(nodeId); + + rows.push({ + ...node, + nodeId, + level, + parent, + posinset, + ariaSetSize, + isHidden, + hasChildren, + isExpanded, + }); + + if (hasChildren && !isHidden) { + const childrenToRender = node.children || []; + const reposToRender = node.repos || []; + const allChildren = [...childrenToRender, ...reposToRender]; + + // Calculate visible siblings for aria-setsize + const visibleChildren = allChildren.filter(isNodeVisible); + const visibleCount = visibleChildren.length; + + allChildren.forEach((child, idx) => { + // Use position among all children for posinset, but visible count for setsize + addRow(child, level + 1, nodeId, idx + 1, visibleCount, !isExpanded || isHidden); + }); + } + }; + + // For root products, calculate visible products + const visibleProducts = products.filter(isNodeVisible); + products.forEach((product, idx) => { + addRow(product, 1, null, idx + 1, visibleProducts.length, false); + }); + + return rows; + }, [products, expandedNodeIds, showActiveOnly, repoStatuses]); + + // Filter rows based on active only setting + const visibleRows = useMemo(() => { + if (!showActiveOnly) return buildTreeRows; + + // Build parent->children map + const parentToChildren = {}; + buildTreeRows.forEach((row) => { + if (row.parent) { + if (!parentToChildren[row.parent]) parentToChildren[row.parent] = []; + parentToChildren[row.parent].push(row); + } + }); + + // Recursive helper functions for visibility checking + // hasVisibleChildren must be defined before isRowVisible due to ESLint rules + function hasVisibleChildren(row) { + const children = parentToChildren[row.nodeId] || []; + // eslint-disable-next-line no-use-before-define + return children.some(child => isRowVisible(child)); + } + + // Check if a row should be visible + function isRowVisible(row) { + if (row.type === 'repo') { + const status = repoStatuses[row.id]; + return status?.is_running; + } + // For non-repo nodes (product, minor, arch), visible if has visible children + return hasVisibleChildren(row); + } + + return buildTreeRows.filter(row => isRowVisible(row)); + }, [buildTreeRows, showActiveOnly, repoStatuses]); + + const toggleExpand = (nodeId) => { + setExpandedNodeIds((prev) => { + if (prev.includes(nodeId)) { + return prev.filter(id => id !== nodeId); + } + return [...prev, nodeId]; + }); + }; + + const renderRow = (row) => { + const isRepo = row.type === 'repo'; + const isProduct = row.type === 'product'; + const isMinor = row.type === 'minor'; + const isArch = row.type === 'arch'; + const isSelectableNode = isProduct || isMinor || isArch; + const status = isRepo ? repoStatuses[row.id] : null; + const isRepoSelected = isRepo && isSelected(row.id); + + // Get checkbox state for selectable nodes + const nodeCheckboxState = isSelectableNode ? getNodeCheckboxState(row) : null; + + // Build treeRow props - minimal for leaf nodes, full for expandable nodes + const treeRow = row.hasChildren ? { + onCollapse: () => toggleExpand(row.nodeId), + props: { + 'aria-level': row.level, + 'aria-posinset': row.posinset, + 'aria-setsize': row.ariaSetSize || 0, + isExpanded: row.isExpanded, + isHidden: row.isHidden, + }, + } : { + props: { + 'aria-level': row.level, + 'aria-posinset': row.posinset, + 'aria-setsize': 0, // MUST be 0 for leaf nodes to hide expand button + isHidden: row.isHidden, + }, + }; + + return ( + + + {isRepo && ( + onSelectRepo(row.id, row)} + aria-label={__('Select repository')} + ouiaId={`checkbox-${row.id}`} + /> + )} + {isSelectableNode && ( + onSelectProduct(row)} + aria-label={__('Select node')} + ouiaId={`checkbox-${row.type}-${row.id}`} + /> + )} + { + if (isRepo && !status?.is_running) { + onSelectRepo(row.id, row); + } else if (isSelectableNode) { + onSelectProduct(row); + } + }} + onKeyPress={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (isRepo && !status?.is_running) { + onSelectRepo(row.id, row); + } else if (isSelectableNode) { + onSelectProduct(row); + } + } + }} + style={{ + cursor: (isRepo && status?.is_running) ? 'default' : 'pointer', + marginLeft: (isRepo || isSelectableNode) ? '0.5rem' : '0', + }} + > + {row.name} + + {row.organization && ` (${row.organization})`} + + + {isRepo && status?.start_time && ( + <> + {status.start_time} + {!status.is_running && status.duration && ` - (${status.duration})`} + + )} + + + {isRepo && status?.display_size} + + + {isRepo && status && ( + <> + {status.is_running && ( + + )} + {!status.is_running && ( + + )} + + )} + + {isRepo ? ( + + onSyncRepo(row.id), + isDisabled: status?.is_running, + }, + ]} + /> + + ) : ( + + )} + + ); + }; + + return ( + + + + + + + + + + + {visibleRows.map(row => renderRow(row))} + +
{ + if (allNodesExpanded) { + onCollapseAll(); + } else { + onExpandAll(); + } + }, + }} + > + {__('Product | Repository')} + {__('Started at')}{__('Details')}{__('Progress / Result')} +
+ ); +}; + +SyncStatusTable.propTypes = { + products: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + repoStatuses: PropTypes.objectOf(PropTypes.shape({})).isRequired, + onSelectRepo: PropTypes.func.isRequired, + onSelectProduct: PropTypes.func.isRequired, + onSyncRepo: PropTypes.func.isRequired, + onCancelSync: PropTypes.func.isRequired, + expandedNodeIds: PropTypes.arrayOf(PropTypes.string).isRequired, + setExpandedNodeIds: PropTypes.func.isRequired, + showActiveOnly: PropTypes.bool.isRequired, + isSelected: PropTypes.func.isRequired, + onExpandAll: PropTypes.func.isRequired, + onCollapseAll: PropTypes.func.isRequired, +}; + +export default SyncStatusTable; diff --git a/webpack/scenes/SyncStatus/components/SyncStatusToolbar.js b/webpack/scenes/SyncStatus/components/SyncStatusToolbar.js new file mode 100644 index 00000000000..aeafe15057a --- /dev/null +++ b/webpack/scenes/SyncStatus/components/SyncStatusToolbar.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Toolbar, + ToolbarContent, + ToolbarItem, + Button, + Switch, +} from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import TreeSelectAllCheckbox from './TreeSelectAllCheckbox'; + +const SyncStatusToolbar = ({ + selectedRepoIds, + onSyncNow, + showActiveOnly, + onToggleActiveOnly, + isSyncDisabled, + selectAllCheckboxProps, +}) => ( + + + + + + + + + + + + + +); + +SyncStatusToolbar.propTypes = { + selectedRepoIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onSyncNow: PropTypes.func.isRequired, + showActiveOnly: PropTypes.bool.isRequired, + onToggleActiveOnly: PropTypes.func.isRequired, + isSyncDisabled: PropTypes.bool, + selectAllCheckboxProps: PropTypes.shape({ + selectNone: PropTypes.func.isRequired, + selectAll: PropTypes.func.isRequired, + selectedCount: PropTypes.number.isRequired, + totalCount: PropTypes.number.isRequired, + areAllRowsSelected: PropTypes.bool.isRequired, + }).isRequired, +}; + +SyncStatusToolbar.defaultProps = { + isSyncDisabled: false, +}; + +export default SyncStatusToolbar; diff --git a/webpack/scenes/SyncStatus/components/TreeSelectAllCheckbox.js b/webpack/scenes/SyncStatus/components/TreeSelectAllCheckbox.js new file mode 100644 index 00000000000..f80202c0d54 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/TreeSelectAllCheckbox.js @@ -0,0 +1,118 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Dropdown, + DropdownToggle, + DropdownToggleCheckbox, + DropdownItem, +} from '@patternfly/react-core/deprecated'; +import { translate as __ } from 'foremanReact/common/I18n'; + +const TreeSelectAllCheckbox = ({ + selectNone, + selectAll, + selectedCount, + totalCount, + areAllRowsSelected, +}) => { + const [isSelectAllDropdownOpen, setSelectAllDropdownOpen] = useState(false); + const [selectionToggle, setSelectionToggle] = useState(false); + + // Checkbox states: false = unchecked, null = partially-checked, true = checked + // Flow: All are selected -> click -> none are selected + // Some are selected -> click -> none are selected + // None are selected -> click -> all are selected + const onSelectAllCheckboxChange = (checked) => { + if (checked && selectionToggle !== null) { + selectAll(); + } else { + selectNone(); + } + }; + + const onSelectAllDropdownToggle = () => setSelectAllDropdownOpen(isOpen => !isOpen); + + const handleSelectAll = () => { + setSelectAllDropdownOpen(false); + setSelectionToggle(true); + selectAll(); + }; + + const handleSelectNone = () => { + setSelectAllDropdownOpen(false); + setSelectionToggle(false); + selectNone(); + }; + + useEffect(() => { + let newCheckedState = null; // null is partially-checked state + + if (areAllRowsSelected) { + newCheckedState = true; + } else if (selectedCount === 0) { + newCheckedState = false; + } + setSelectionToggle(newCheckedState); + }, [selectedCount, areAllRowsSelected]); + + const selectAllDropdownItems = [ + + {`${__('Select none')} (0)`} + , + + {`${__('Select all')} (${totalCount})`} + , + ]; + + return ( + onSelectAllCheckboxChange(checked)} + isChecked={selectionToggle} + isDisabled={totalCount === 0 && selectedCount === 0} + > + {selectedCount > 0 ? `${selectedCount} selected` : '\u00A0'} + , + ]} + /> + } + isOpen={isSelectAllDropdownOpen} + dropdownItems={selectAllDropdownItems} + id="tree-selection-checkbox" + ouiaId="tree-selection-checkbox" + /> + ); +}; + +TreeSelectAllCheckbox.propTypes = { + selectedCount: PropTypes.number.isRequired, + selectNone: PropTypes.func.isRequired, + selectAll: PropTypes.func.isRequired, + totalCount: PropTypes.number.isRequired, + areAllRowsSelected: PropTypes.bool.isRequired, +}; + +export default TreeSelectAllCheckbox; diff --git a/webpack/scenes/SyncStatus/components/__tests__/SyncProgressCell.test.js b/webpack/scenes/SyncStatus/components/__tests__/SyncProgressCell.test.js new file mode 100644 index 00000000000..c933c5dee1f --- /dev/null +++ b/webpack/scenes/SyncStatus/components/__tests__/SyncProgressCell.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SyncProgressCell from '../SyncProgressCell'; +import { SYNC_STATE_RUNNING } from '../../SyncStatusConstants'; + +describe('SyncProgressCell', () => { + const mockOnCancelSync = jest.fn(); + + const runningRepo = { + id: 1, + is_running: true, + progress: { progress: 50 }, + raw_state: SYNC_STATE_RUNNING, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders progress bar when syncing', () => { + render(); + expect(screen.getByText('Syncing')).toBeInTheDocument(); + }); + + it('renders cancel button when syncing', () => { + render(); + const cancelButton = screen.getByLabelText('Cancel sync'); + expect(cancelButton).toBeInTheDocument(); + }); + + it('calls onCancelSync when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByLabelText('Cancel sync'); + fireEvent.click(cancelButton); + + expect(mockOnCancelSync).toHaveBeenCalledWith(1); + }); + + it('does not render when not syncing', () => { + const notRunningRepo = { + ...runningRepo, + is_running: false, + }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/webpack/scenes/SyncStatus/components/__tests__/SyncResultCell.test.js b/webpack/scenes/SyncStatus/components/__tests__/SyncResultCell.test.js new file mode 100644 index 00000000000..9a6e04f50c4 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/__tests__/SyncResultCell.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import SyncResultCell from '../SyncResultCell'; +import { + SYNC_STATE_STOPPED, + SYNC_STATE_ERROR, + SYNC_STATE_NEVER_SYNCED, +} from '../../SyncStatusConstants'; + +describe('SyncResultCell', () => { + it('renders completed state correctly', () => { + const repo = { + raw_state: SYNC_STATE_STOPPED, + state: 'Syncing complete', + }; + render(); + expect(screen.getByText(/Syncing complete/i)).toBeInTheDocument(); + }); + + it('renders error state correctly', () => { + const repo = { + raw_state: SYNC_STATE_ERROR, + state: 'Sync incomplete', + error_details: ['Connection timeout'], + }; + render(); + expect(screen.getByText(/Sync incomplete/i)).toBeInTheDocument(); + }); + + it('renders never synced state correctly', () => { + const repo = { + raw_state: SYNC_STATE_NEVER_SYNCED, + state: 'Never synced', + }; + render(); + expect(screen.getByText(/Never synced/i)).toBeInTheDocument(); + }); + + it('renders task link when sync_id is present', () => { + const repo = { + raw_state: SYNC_STATE_STOPPED, + state: 'Syncing complete', + sync_id: '12345', + }; + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', expect.stringContaining('12345')); + }); +}); diff --git a/webpack/scenes/SyncStatus/components/__tests__/SyncStatusTable.test.js b/webpack/scenes/SyncStatus/components/__tests__/SyncStatusTable.test.js new file mode 100644 index 00000000000..42aa7ad4c6d --- /dev/null +++ b/webpack/scenes/SyncStatus/components/__tests__/SyncStatusTable.test.js @@ -0,0 +1,179 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SyncStatusTable from '../SyncStatusTable'; + +const mockProducts = [ + { + id: 1, + type: 'product', + name: 'Test Product', + children: [ + { + id: 101, + type: 'product_content', + name: 'Test Content', + repos: [ + { + id: 1, + type: 'repo', + name: 'Test Repository', + label: 'test-repo', + product_id: 1, + }, + ], + }, + ], + }, +]; + +const mockRepoStatuses = { + 1: { + id: 1, + is_running: false, + last_sync_words: 'Never synced', + state: null, + }, +}; + +describe('SyncStatusTable', () => { + const mockProps = { + products: mockProducts, + repoStatuses: mockRepoStatuses, + onSelectRepo: jest.fn(), + onSelectProduct: jest.fn(), + onSyncRepo: jest.fn(), + onCancelSync: jest.fn(), + expandedNodeIds: [], + setExpandedNodeIds: jest.fn(), + showActiveOnly: false, + isSelected: jest.fn(() => false), + onExpandAll: jest.fn(), + onCollapseAll: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table with column headers', () => { + render(); + + expect(screen.getByText('Product | Repository')).toBeInTheDocument(); + expect(screen.getByText('Started at')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('Progress / Result')).toBeInTheDocument(); + }); + + it('renders product rows', () => { + render(); + + expect(screen.getByText('Test Product')).toBeInTheDocument(); + }); + + it('expands rows when clicked', () => { + render(); + + const expandButtons = screen.getAllByRole('button', { name: /expand row/i }); + fireEvent.click(expandButtons[0]); + + expect(mockProps.setExpandedNodeIds).toHaveBeenCalled(); + }); + + it('calls onSelectRepo when checkbox is clicked', () => { + const propsWithExpandedNodes = { + ...mockProps, + expandedNodeIds: ['product-1', 'product_content-101'], + }; + + render(); + + // Find the checkbox for the repository + const checkboxes = screen.getAllByRole('checkbox'); + // eslint-disable-next-line promise/prefer-await-to-callbacks + const repoCheckbox = checkboxes.find(cb => + cb.getAttribute('aria-label')?.includes('Select repository')); + + if (repoCheckbox) { + fireEvent.click(repoCheckbox); + expect(mockProps.onSelectRepo).toHaveBeenCalled(); + } + }); + + it('shows repos with active syncs when showActiveOnly is true', () => { + const activeRepoStatuses = { + ...mockRepoStatuses, + 1: { + id: 1, + is_running: true, + last_sync_words: 'Syncing...', + state: 'running', + }, + }; + + const propsWithActiveOnly = { + ...mockProps, + repoStatuses: activeRepoStatuses, + showActiveOnly: true, + expandedNodeIds: ['product-1', 'product_content-101'], + }; + + render(); + + // Should show the product and repo since it's active + expect(screen.getByText('Test Product')).toBeInTheDocument(); + expect(screen.getByText('Test Repository')).toBeInTheDocument(); + }); + + it('hides product when all child repos are not active with showActiveOnly', () => { + const propsWithActiveOnly = { + ...mockProps, + showActiveOnly: true, + }; + + render(); + + // Filter hides parent nodes when all child repos are not running + expect(screen.queryByText('Test Product')).not.toBeInTheDocument(); + expect(screen.queryByText('Test Repository')).not.toBeInTheDocument(); + }); + + it('collapses expanded row when expand button is clicked again', () => { + const propsWithExpanded = { + ...mockProps, + expandedNodeIds: ['product-1'], + }; + + render(); + + // Find and click the expand button to collapse + const expandButtons = screen.getAllByRole('button', { name: /collapse row/i }); + fireEvent.click(expandButtons[0]); + + // Should call setExpandedNodeIds to collapse + expect(mockProps.setExpandedNodeIds).toHaveBeenCalled(); + const setExpandedCall = mockProps.setExpandedNodeIds.mock.calls[0][0]; + const newExpandedIds = setExpandedCall(['product-1']); + expect(newExpandedIds).toEqual([]); + }); + + it('selects multiple repositories', () => { + const propsWithExpandedNodes = { + ...mockProps, + expandedNodeIds: ['product-1', 'product_content-101'], + }; + + render(); + + // Find all checkboxes + const checkboxes = screen.getAllByRole('checkbox'); + // eslint-disable-next-line promise/prefer-await-to-callbacks + const repoCheckboxes = checkboxes.filter(cb => + cb.getAttribute('aria-label')?.includes('Select repository')); + + // Click the first repo checkbox + if (repoCheckboxes[0]) { + fireEvent.click(repoCheckboxes[0]); + expect(mockProps.onSelectRepo).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/webpack/scenes/SyncStatus/components/__tests__/SyncStatusToolbar.test.js b/webpack/scenes/SyncStatus/components/__tests__/SyncStatusToolbar.test.js new file mode 100644 index 00000000000..fbedf6effd5 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/__tests__/SyncStatusToolbar.test.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SyncStatusToolbar from '../SyncStatusToolbar'; + +describe('SyncStatusToolbar', () => { + const mockProps = { + selectedRepoIds: [1, 2], + onSyncNow: jest.fn(), + showActiveOnly: false, + onToggleActiveOnly: jest.fn(), + selectAllCheckboxProps: { + selectNone: jest.fn(), + selectAll: jest.fn(), + selectedCount: 2, + totalCount: 10, + areAllRowsSelected: false, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders toolbar with selection checkbox and sync button', () => { + render(); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByText('Synchronize')).toBeInTheDocument(); + // Switch renders label twice (on/off states), so use getAllByText + expect(screen.getAllByText('Show syncing only').length).toBeGreaterThan(0); + }); + + it('calls onSyncNow when Synchronize is clicked', () => { + render(); + + const syncButton = screen.getByText('Synchronize'); + fireEvent.click(syncButton); + + expect(mockProps.onSyncNow).toHaveBeenCalled(); + }); + + it('disables Synchronize when no repos selected', () => { + const props = { + ...mockProps, + selectedRepoIds: [], + selectAllCheckboxProps: { + ...mockProps.selectAllCheckboxProps, + selectedCount: 0, + }, + }; + render(); + + const syncButton = screen.getByText('Synchronize'); + expect(syncButton).toBeDisabled(); + }); + + it('toggles show syncing only switch', () => { + render(); + + const showSyncingSwitch = screen.getByLabelText('Show syncing only'); + fireEvent.click(showSyncingSwitch); + + expect(mockProps.onToggleActiveOnly).toHaveBeenCalled(); + }); +}); diff --git a/webpack/scenes/SyncStatus/components/__tests__/TreeSelectAllCheckbox.test.js b/webpack/scenes/SyncStatus/components/__tests__/TreeSelectAllCheckbox.test.js new file mode 100644 index 00000000000..c5442f48203 --- /dev/null +++ b/webpack/scenes/SyncStatus/components/__tests__/TreeSelectAllCheckbox.test.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TreeSelectAllCheckbox from '../TreeSelectAllCheckbox'; + +describe('TreeSelectAllCheckbox', () => { + const mockProps = { + selectNone: jest.fn(), + selectAll: jest.fn(), + selectedCount: 2, + totalCount: 10, + areAllRowsSelected: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with selected count', () => { + render(); + + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + it('calls selectAll when select all is clicked', () => { + render(); + + // Click the dropdown toggle (aria-label is "Select") + const dropdownToggle = screen.getByRole('button', { name: 'Select' }); + fireEvent.click(dropdownToggle); + + // Click select all option + const selectAllOption = screen.getByText('Select all (10)'); + fireEvent.click(selectAllOption); + + expect(mockProps.selectAll).toHaveBeenCalled(); + }); + + it('calls selectNone when select none is clicked', () => { + render(); + + // Click the dropdown toggle (aria-label is "Select") + const dropdownToggle = screen.getByRole('button', { name: 'Select' }); + fireEvent.click(dropdownToggle); + + // Click select none option + const selectNoneOption = screen.getByText('Select none (0)'); + fireEvent.click(selectNoneOption); + + expect(mockProps.selectNone).toHaveBeenCalled(); + }); + + it('shows indeterminate state when some items selected', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeInTheDocument(); + }); + + it('disables select all when all rows are selected', () => { + const propsAllSelected = { + ...mockProps, + selectedCount: 10, + areAllRowsSelected: true, + }; + + render(); + + // Click the dropdown toggle + const dropdownToggle = screen.getByRole('button', { name: 'Select' }); + fireEvent.click(dropdownToggle); + + // Check that select all has aria-disabled + const selectAllOption = screen.getByText('Select all (10)'); + expect(selectAllOption.closest('button')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('disables select none when no items are selected', () => { + const propsNoneSelected = { + ...mockProps, + selectedCount: 0, + }; + + render(); + + // Click the dropdown toggle + const dropdownToggle = screen.getByRole('button', { name: 'Select' }); + fireEvent.click(dropdownToggle); + + // Check that select none has aria-disabled + const selectNoneOption = screen.getByText('Select none (0)'); + expect(selectNoneOption.closest('button')).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/webpack/scenes/SyncStatus/index.js b/webpack/scenes/SyncStatus/index.js new file mode 100644 index 00000000000..94ed4b50ab8 --- /dev/null +++ b/webpack/scenes/SyncStatus/index.js @@ -0,0 +1 @@ +export { default } from './SyncStatusPage';