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 (
+
+
+
+ | {
+ if (allNodesExpanded) {
+ onCollapseAll();
+ } else {
+ onExpandAll();
+ }
+ },
+ }}
+ >
+ {__('Product | Repository')}
+ |
+ {__('Started at')} |
+ {__('Details')} |
+ {__('Progress / Result')} |
+ |
+
+
+
+ {visibleRows.map(row => renderRow(row))}
+
+
+ );
+};
+
+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';