diff --git a/inc/Abilities/Job/GetJobsAbility.php b/inc/Abilities/Job/GetJobsAbility.php index 3f979f49..08f53d79 100644 --- a/inc/Abilities/Job/GetJobsAbility.php +++ b/inc/Abilities/Job/GetJobsAbility.php @@ -220,6 +220,16 @@ public function execute( array $input ): array { $filters_applied['since'] = $args['since']; } + if ( isset( $input['parent_job_id'] ) ) { + $args['parent_job_id'] = (int) $input['parent_job_id']; + $filters_applied['parent_job_id'] = $args['parent_job_id']; + } + + if ( ! empty( $input['hide_children'] ) ) { + $args['hide_children'] = true; + $filters_applied['hide_children'] = true; + } + $jobs = $this->db_jobs->get_jobs_for_list_table( $args ); $total = $this->db_jobs->get_jobs_count( $args ); diff --git a/inc/Api/Jobs.php b/inc/Api/Jobs.php index 199b7e66..ac531eb4 100644 --- a/inc/Api/Jobs.php +++ b/inc/Api/Jobs.php @@ -98,13 +98,24 @@ public static function register_routes() { 'type' => 'string', 'description' => __( 'Filter by job status', 'data-machine' ), ), - 'user_id' => array( - 'required' => false, - 'type' => 'integer', - 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), - 'sanitize_callback' => 'absint', - ), + 'user_id' => array( + 'required' => false, + 'type' => 'integer', + 'description' => __( 'Filter by user ID (admin only, non-admins always see own data)', 'data-machine' ), + 'sanitize_callback' => 'absint', + ), + 'parent_job_id' => array( + 'required' => false, + 'type' => 'integer', + 'description' => __( 'Filter by parent job ID (for batch child jobs)', 'data-machine' ), + ), + 'hide_children' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Hide child jobs from top-level list', 'data-machine' ), ), + ), ) ); @@ -198,6 +209,12 @@ public static function handle_get_jobs( $request ) { if ( $request->get_param( 'status' ) ) { $input['status'] = sanitize_text_field( $request->get_param( 'status' ) ); } + if ( $request->get_param( 'parent_job_id' ) ) { + $input['parent_job_id'] = (int) $request->get_param( 'parent_job_id' ); + } + if ( $request->get_param( 'hide_children' ) ) { + $input['hide_children'] = true; + } $result = self::getAbilities()->executeGetJobs( $input ); diff --git a/inc/Core/Admin/Pages/Jobs/assets/css/jobs-page.css b/inc/Core/Admin/Pages/Jobs/assets/css/jobs-page.css index c5481d58..b5d4c41f 100644 --- a/inc/Core/Admin/Pages/Jobs/assets/css/jobs-page.css +++ b/inc/Core/Admin/Pages/Jobs/assets/css/jobs-page.css @@ -176,6 +176,95 @@ margin: 16px 0 0; } +/* ======================================================================== + BATCH HIERARCHY + ======================================================================== */ + +/* Batch parent row */ +.datamachine-batch-parent { + background: #f9f9f9; +} + +.datamachine-batch-parent:hover { + background: #f0f0f1; +} + +.datamachine-batch-parent.is-expanded { + background: #eef3f8; + border-bottom: none; +} + +/* Expand/collapse arrow */ +.datamachine-batch-arrow { + display: inline-block; + width: 16px; + font-size: 10px; + color: #646970; + margin-right: 4px; +} + +/* Batch progress badge */ +.datamachine-batch-badge { + display: inline-block; + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 3px; + margin-left: 8px; + vertical-align: middle; + background: #f0f0f1; + color: #646970; +} + +.datamachine-batch-badge--complete { + background: #d1f5d3; + color: #00a32a; +} + +.datamachine-batch-badge--warning { + background: #fce4e4; + color: #d63638; +} + +.datamachine-batch-badge--progress { + background: #e7f3ff; + color: #0073aa; +} + +/* Child rows */ +.datamachine-child-row { + background: #f6f7f7 !important; +} + +.datamachine-child-row td { + border-top: 1px solid #eee; +} + +.datamachine-child-id { + padding-left: 24px !important; +} + +.datamachine-child-indicator { + color: #a0a5aa; + margin-right: 6px; + font-size: 14px; +} + +.datamachine-child-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + color: #646970; + font-size: 13px; +} + +.datamachine-child-empty { + color: #646970; + font-style: italic; + padding: 8px 24px !important; +} + /* Responsive */ @media screen and (max-width: 782px) { .datamachine-jobs-header { @@ -188,4 +277,10 @@ .datamachine-col-completed { display: none; } + + .datamachine-batch-badge { + display: block; + margin-left: 0; + margin-top: 4px; + } } diff --git a/inc/Core/Admin/Pages/Jobs/assets/react/api/jobs.js b/inc/Core/Admin/Pages/Jobs/assets/react/api/jobs.js index 9b0cc1b1..15947df1 100644 --- a/inc/Core/Admin/Pages/Jobs/assets/react/api/jobs.js +++ b/inc/Core/Admin/Pages/Jobs/assets/react/api/jobs.js @@ -20,13 +20,19 @@ import { client } from '@shared/utils/api'; * @param {string} params.status Optional status filter * @return {Promise} Jobs list response */ -export const fetchJobs = ( { page = 1, perPage = 50, status } = {} ) => { +export const fetchJobs = ( { + page = 1, + perPage = 50, + status, + hideChildren = true, +} = {} ) => { const offset = ( page - 1 ) * perPage; const params = { orderby: 'job_id', order: 'DESC', per_page: perPage, offset, + hide_children: hideChildren ? 1 : 0, }; if ( status && status !== 'all' ) { @@ -36,6 +42,22 @@ export const fetchJobs = ( { page = 1, perPage = 50, status } = {} ) => { return client.get( '/jobs', params ); }; +/** + * Fetch child jobs for a batch parent + * + * @param {number} parentJobId Parent job ID + * @return {Promise} Child jobs list response + */ +export const fetchChildJobs = ( parentJobId ) => { + return client.get( '/jobs', { + parent_job_id: parentJobId, + orderby: 'job_id', + order: 'ASC', + per_page: 100, + offset: 0, + } ); +}; + /** * Clear jobs * diff --git a/inc/Core/Admin/Pages/Jobs/assets/react/components/JobsTable.jsx b/inc/Core/Admin/Pages/Jobs/assets/react/components/JobsTable.jsx index 47d68cc6..3df73a69 100644 --- a/inc/Core/Admin/Pages/Jobs/assets/react/components/JobsTable.jsx +++ b/inc/Core/Admin/Pages/Jobs/assets/react/components/JobsTable.jsx @@ -1,15 +1,25 @@ /** * JobsTable Component * - * Displays the jobs list in a table format with loading and empty states. + * Displays the jobs list with batch parent/child hierarchy. + * Parent jobs show a batch progress badge and expand to reveal child jobs. + * Child jobs are hidden from the top-level list and lazy-loaded on expand. + * + * @since 0.44.2 */ /** * WordPress dependencies */ +import { useState, useCallback } from '@wordpress/element'; import { Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { useChildJobs } from '../queries/jobs'; + const getStatusClass = ( status ) => { if ( ! status ) { return 'datamachine-status--neutral'; @@ -34,12 +44,229 @@ const formatStatus = ( status ) => { ); }; +/** + * Determine if a job is a batch parent from engine_data. + */ +const isBatchParent = ( job ) => { + if ( ! job.engine_data ) { + return false; + } + const ed = + typeof job.engine_data === 'string' + ? JSON.parse( job.engine_data ) + : job.engine_data; + return !! ed.batch; +}; + +/** + * Extract batch results from a parent job's engine_data. + */ +const getBatchResults = ( job ) => { + if ( ! job.engine_data ) { + return null; + } + const ed = + typeof job.engine_data === 'string' + ? JSON.parse( job.engine_data ) + : job.engine_data; + return ed.batch_results || null; +}; + +/** + * Extract batch total (scheduled count) from engine_data. + */ +const getBatchTotal = ( job ) => { + if ( ! job.engine_data ) { + return 0; + } + const ed = + typeof job.engine_data === 'string' + ? JSON.parse( job.engine_data ) + : job.engine_data; + return ed.batch_total || ed.batch_scheduled || 0; +}; + +/** + * Batch progress badge for parent jobs. + */ +const BatchBadge = ( { job } ) => { + const results = getBatchResults( job ); + const total = getBatchTotal( job ); + + if ( ! total ) { + return ( + + { __( 'Batch', 'data-machine' ) } + + ); + } + + if ( results ) { + const parts = []; + if ( results.completed > 0 ) { + parts.push( + `${ results.completed }/${ results.total } ${ __( + 'completed', + 'data-machine' + ) }` + ); + } + if ( results.failed > 0 ) { + parts.push( + `${ results.failed } ${ __( 'failed', 'data-machine' ) }` + ); + } + if ( results.skipped > 0 ) { + parts.push( + `${ results.skipped } ${ __( 'skipped', 'data-machine' ) }` + ); + } + + const hasFailures = results.failed > 0; + + return ( + + { parts.join( ', ' ) } + + ); + } + + // Batch in progress — no results yet. + return ( + + { `${ __( 'Batch:', 'data-machine' ) } ${ total } ${ __( + 'items', + 'data-machine' + ) }` } + + ); +}; + +/** + * Expandable child rows for a batch parent. + */ +const ChildRows = ( { parentJobId } ) => { + const { data: children, isLoading, isError } = useChildJobs( parentJobId ); + + if ( isLoading ) { + return ( + + +
+ + + { __( 'Loading child jobs\u2026', 'data-machine' ) } + +
+ + + ); + } + + if ( isError || ! children || children.length === 0 ) { + return ( + + + { __( 'No child jobs found.', 'data-machine' ) } + + + ); + } + + return children.map( ( child ) => ( + + + + { '\u21b3' } + + { child.job_id } + + + { child.display_label || + child.label || + __( 'Child job', 'data-machine' ) } + + + + { formatStatus( child.status ) } + + + { child.created_at_display || '' } + { child.completed_at_display || '' } + + ) ); +}; + +/** + * Single job row — handles expand/collapse for batch parents. + */ +const JobRow = ( { job, isExpanded, onToggle } ) => { + const isBatch = isBatchParent( job ); + + return ( + <> + + + + { isBatch && ( + + ) } + { job.job_id } + + + + { job.display_label || + job.label || + ( job.pipeline_name && job.flow_name + ? `${ job.pipeline_name } \u2192 ${ job.flow_name }` + : __( 'Unknown', 'data-machine' ) ) } + { isBatch && } + + + + { formatStatus( job.status ) } + + + { job.created_at_display || '' } + { job.completed_at_display || '' } + + { isExpanded && } + + ); +}; + const JobsTable = ( { jobs, isLoading, isError, error } ) => { + const [ expandedJobs, setExpandedJobs ] = useState( {} ); + + const toggleExpand = useCallback( ( jobId ) => { + setExpandedJobs( ( prev ) => ( { + ...prev, + [ jobId ]: ! prev[ jobId ], + } ) ); + }, [] ); + if ( isLoading ) { return (
- { __( 'Loading jobs…', 'data-machine' ) } + { __( 'Loading jobs\u2026', 'data-machine' ) }
); } @@ -88,27 +315,12 @@ const JobsTable = ( { jobs, isLoading, isError, error } ) => { { jobs.map( ( job ) => ( - - - { job.job_id } - - - { job.display_label || - job.label || - ( job.pipeline_name && job.flow_name - ? `${ job.pipeline_name } → ${ job.flow_name }` - : __( 'Unknown', 'data-machine' ) ) } - - - - { formatStatus( job.status ) } - - - { job.created_at_display || '' } - { job.completed_at_display || '' } - + toggleExpand( job.job_id ) } + /> ) ) } diff --git a/inc/Core/Admin/Pages/Jobs/assets/react/queries/jobs.js b/inc/Core/Admin/Pages/Jobs/assets/react/queries/jobs.js index ce7229c8..22b7a919 100644 --- a/inc/Core/Admin/Pages/Jobs/assets/react/queries/jobs.js +++ b/inc/Core/Admin/Pages/Jobs/assets/react/queries/jobs.js @@ -19,6 +19,7 @@ import * as jobsApi from '../api/jobs'; export const jobsKeys = { all: [ 'jobs' ], list: ( params ) => [ ...jobsKeys.all, 'list', params ], + children: ( parentJobId ) => [ ...jobsKeys.all, 'children', parentJobId ], pipelines: () => [ 'pipelines', 'dropdown' ], flows: ( pipelineId ) => [ 'flows', 'dropdown', pipelineId ], }; @@ -38,6 +39,7 @@ export const useJobs = ( { page = 1, perPage = 50, status } = {} ) => page, perPage, status, + hideChildren: true, } ); if ( ! response.success ) { throw new Error( response.message || 'Failed to fetch jobs' ); @@ -76,6 +78,27 @@ export const useClearProcessedItems = () => { } ); }; +/** + * Fetch child jobs for a batch parent (lazy-loaded on expand) + * + * @param {number|null} parentJobId Parent job ID (null = disabled) + */ +export const useChildJobs = ( parentJobId ) => + useQuery( { + queryKey: jobsKeys.children( parentJobId ), + queryFn: async () => { + const response = await jobsApi.fetchChildJobs( parentJobId ); + if ( ! response.success ) { + throw new Error( + response.message || 'Failed to fetch child jobs' + ); + } + return response.data || []; + }, + enabled: !! parentJobId, + staleTime: 30 * 1000, + } ); + /** * Fetch pipelines for dropdown */ diff --git a/inc/Core/Database/Jobs/JobsOperations.php b/inc/Core/Database/Jobs/JobsOperations.php index e97454a9..5996a628 100644 --- a/inc/Core/Database/Jobs/JobsOperations.php +++ b/inc/Core/Database/Jobs/JobsOperations.php @@ -187,6 +187,15 @@ public function get_jobs_count( array $args = array() ): int { $where_values[] = sanitize_text_field( $args['since'] ); } + if ( isset( $args['parent_job_id'] ) ) { + $where_clauses[] = 'parent_job_id = %d'; + $where_values[] = absint( $args['parent_job_id'] ); + } + + if ( ! empty( $args['hide_children'] ) ) { + $where_clauses[] = '(parent_job_id IS NULL OR parent_job_id = 0)'; + } + $where_sql = ''; if ( ! empty( $where_clauses ) ) { $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses ); @@ -288,6 +297,15 @@ public function get_jobs_for_list_table( array $args ): array { $where_values[] = sanitize_text_field( $args['since'] ); } + if ( isset( $args['parent_job_id'] ) ) { + $where_clauses[] = 'j.parent_job_id = %d'; + $where_values[] = absint( $args['parent_job_id'] ); + } + + if ( ! empty( $args['hide_children'] ) ) { + $where_clauses[] = '(j.parent_job_id IS NULL OR j.parent_job_id = 0)'; + } + $where_sql = ''; if ( ! empty( $where_clauses ) ) { $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses );