Compare commits

..

No commits in common. "main" and "v67" have entirely different histories.
main ... v67

326 changed files with 47673 additions and 15663 deletions

8
.eslintignore Normal file
View file

@ -0,0 +1,8 @@
# Ignore programatically generated API slices
imageBuilderApi.ts
contentSourcesApi.ts
rhsmApi.ts
provisioningApi.ts
edgeApi.ts
complianceApi.ts
composerCloudApi.ts

65
.eslintrc.yml Normal file
View file

@ -0,0 +1,65 @@
extends: [
"plugin:jsx-a11y/recommended",
"@redhat-cloud-services/eslint-config-redhat-cloud-services",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-redux/recommended"
]
globals:
insights: 'readonly'
shallow: readonly
render: 'readonly'
mount: 'readonly'
parser: "@typescript-eslint/parser"
parserOptions:
project: ["tsconfig.json"]
plugins:
- import
- disable-autofix
rules:
import/order:
- error
- groups:
- builtin
- external
- internal
- sibling
- parent
- index
alphabetize:
order: asc
caseInsensitive: true
newlines-between: always
pathGroups: # ensures the import of React is always on top
- pattern: react
group: builtin
position: before
pathGroupsExcludedImportTypes:
- react
prefer-const:
- error
- destructuring: any
no-console: error
eqeqeq: error
array-callback-return: warn
"@typescript-eslint/ban-ts-comment":
- error
- ts-expect-error: "allow-with-description"
ts-ignore: "allow-with-description"
ts-nocheck: true
ts-check: true
minimumDescriptionLength: 5
"@typescript-eslint/ban-types": off
disable-autofix/@typescript-eslint/no-unnecessary-condition: warn
# Temporarily disabled
jsx-a11y/no-autofocus: off
rulesdir/forbid-pf-relative-imports: off
overrides:
- files: ["src/tests/**/*.ts"]
extends: "plugin:testing-library/react"
- files: ["playwright/**/*.ts"]
extends: "plugin:playwright/recommended"
rules:
playwright/no-conditional-in-test: off
playwright/no-conditional-expect: off

View file

@ -1 +0,0 @@
1

View file

@ -1,257 +0,0 @@
---
name: Debian Image Builder Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
env:
NODE_VERSION: "18"
DEBIAN_FRONTEND: noninteractive
jobs:
build-and-test:
name: Build and Test Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: |
npm ci
npm run build || echo "Build script not found"
- name: Run tests
run: |
if [ -f package.json ] && npm run test; then
npm test
else
echo "No test script found, skipping tests"
fi
- name: Run linting
run: |
if [ -f package.json ] && npm run lint; then
npm run lint
else
echo "No lint script found, skipping linting"
fi
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: |
dist/
build/
retention-days: 30
package:
name: Package Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
devscripts \
debhelper \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: npm ci
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Create debian directory
run: |
mkdir -p debian
cat > debian/control << EOF
Source: debian-image-builder-frontend
Section: web
Priority: optional
Maintainer: Debian Forge Team <team@debian-forge.org>
Build-Depends: debhelper (>= 13), nodejs, npm, git, ca-certificates
Standards-Version: 4.6.2
Package: debian-image-builder-frontend
Architecture: all
Depends: \${misc:Depends}, nodejs, nginx
Description: Debian Image Builder Frontend
Web-based frontend for Debian Image Builder with Cockpit integration.
Provides a user interface for managing image builds, blueprints,
and system configurations through a modern React application.
EOF
cat > debian/rules << EOF
#!/usr/bin/make -f
%:
dh \$@
override_dh_auto_install:
dh_auto_install
mkdir -p debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend
mkdir -p debian/debian-image-builder-frontend/etc/nginx/sites-available
mkdir -p debian/debian-image-builder-frontend/etc/cockpit
# Copy built frontend files
if [ -d dist ]; then
cp -r dist/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
elif [ -d build ]; then
cp -r build/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
fi
# Copy source files for development
cp -r src debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
cp package.json debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
# Create nginx configuration
cat > debian/debian-image-builder-frontend/etc/nginx/sites-available/debian-image-builder-frontend << 'NGINX_EOF'
server {
listen 80;
server_name localhost;
root /usr/share/debian-image-builder-frontend;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8080/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
NGINX_EOF
# Create cockpit manifest
cat > debian/debian-image-builder-frontend/etc/cockpit/debian-image-builder.manifest << 'COCKPIT_EOF'
{
"version": 1,
"manifest": {
"name": "debian-image-builder",
"version": "1.0.0",
"title": "Debian Image Builder",
"description": "Build and manage Debian atomic images",
"url": "/usr/share/debian-image-builder-frontend",
"icon": "debian-logo",
"requires": {
"cockpit": ">= 200"
}
}
}
COCKPIT_EOF
EOF
cat > debian/changelog << EOF
debian-image-builder-frontend (1.0.0-1) unstable; urgency=medium
* Initial release
* Debian Image Builder Frontend with Cockpit integration
* React-based web interface for image management
-- Debian Forge Team <team@debian-forge.org> $(date -R)
EOF
cat > debian/compat << EOF
13
EOF
chmod +x debian/rules
- name: Build Debian package
run: |
dpkg-buildpackage -us -uc -b
ls -la ../*.deb
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-image-builder-frontend-deb
path: ../*.deb
retention-days: 30
cockpit-integration:
name: Test Cockpit Integration
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install Node.js dependencies
run: npm ci
- name: Test cockpit integration
run: |
echo "Testing Cockpit integration..."
if [ -d cockpit ]; then
echo "Cockpit directory found:"
ls -la cockpit/
else
echo "No cockpit directory found"
fi
if [ -f package.json ]; then
echo "Package.json scripts:"
npm run
fi

View file

@ -1,257 +0,0 @@
---
name: Debian Image Builder Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
env:
NODE_VERSION: "18"
DEBIAN_FRONTEND: noninteractive
jobs:
build-and-test:
name: Build and Test Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: |
npm ci
npm run build || echo "Build script not found"
- name: Run tests
run: |
if [ -f package.json ] && npm run test; then
npm test
else
echo "No test script found, skipping tests"
fi
- name: Run linting
run: |
if [ -f package.json ] && npm run lint; then
npm run lint
else
echo "No lint script found, skipping linting"
fi
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: |
dist/
build/
retention-days: 30
package:
name: Package Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
devscripts \
debhelper \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: npm ci
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Create debian directory
run: |
mkdir -p debian
cat > debian/control << EOF
Source: debian-image-builder-frontend
Section: web
Priority: optional
Maintainer: Debian Forge Team <team@debian-forge.org>
Build-Depends: debhelper (>= 13), nodejs, npm, git, ca-certificates
Standards-Version: 4.6.2
Package: debian-image-builder-frontend
Architecture: all
Depends: \${misc:Depends}, nodejs, nginx
Description: Debian Image Builder Frontend
Web-based frontend for Debian Image Builder with Cockpit integration.
Provides a user interface for managing image builds, blueprints,
and system configurations through a modern React application.
EOF
cat > debian/rules << EOF
#!/usr/bin/make -f
%:
dh \$@
override_dh_auto_install:
dh_auto_install
mkdir -p debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend
mkdir -p debian/debian-image-builder-frontend/etc/nginx/sites-available
mkdir -p debian/debian-image-builder-frontend/etc/cockpit
# Copy built frontend files
if [ -d dist ]; then
cp -r dist/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
elif [ -d build ]; then
cp -r build/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
fi
# Copy source files for development
cp -r src debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
cp package.json debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
# Create nginx configuration
cat > debian/debian-image-builder-frontend/etc/nginx/sites-available/debian-image-builder-frontend << 'NGINX_EOF'
server {
listen 80;
server_name localhost;
root /usr/share/debian-image-builder-frontend;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8080/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
NGINX_EOF
# Create cockpit manifest
cat > debian/debian-image-builder-frontend/etc/cockpit/debian-image-builder.manifest << 'COCKPIT_EOF'
{
"version": 1,
"manifest": {
"name": "debian-image-builder",
"version": "1.0.0",
"title": "Debian Image Builder",
"description": "Build and manage Debian atomic images",
"url": "/usr/share/debian-image-builder-frontend",
"icon": "debian-logo",
"requires": {
"cockpit": ">= 200"
}
}
}
COCKPIT_EOF
EOF
cat > debian/changelog << EOF
debian-image-builder-frontend (1.0.0-1) unstable; urgency=medium
* Initial release
* Debian Image Builder Frontend with Cockpit integration
* React-based web interface for image management
-- Debian Forge Team <team@debian-forge.org> $(date -R)
EOF
cat > debian/compat << EOF
13
EOF
chmod +x debian/rules
- name: Build Debian package
run: |
dpkg-buildpackage -us -uc -b
ls -la ../*.deb
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-image-builder-frontend-deb
path: ../*.deb
retention-days: 30
cockpit-integration:
name: Test Cockpit Integration
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install Node.js dependencies
run: npm ci
- name: Test cockpit integration
run: |
echo "Testing Cockpit integration..."
if [ -d cockpit ]; then
echo "Cockpit directory found:"
ls -la cockpit/
else
echo "No cockpit directory found"
fi
if [ -f package.json ]; then
echo "Package.json scripts:"
npm run
fi

View file

@ -5,79 +5,35 @@ on:
branches: [ "main" ]
push:
branches: [ "main" ]
merge_group:
concurrency:
group: ${{github.workflow}}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Check
dev-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run build
run: npm run build
lint-checks:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run lint check
run: npm run lint
circular-dependencies:
name: Circular Dependencies Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check for circular dependencies
run: npm run circular
api-changes:
name: Manual API Changes Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check for manual changes to API
run: |
npm run api
if [ -n "$(git status --porcelain)" ]; then
echo
echo "✗ API manually changed, please refer to the README for the procedure to follow for programmatically generated API endpoints."
exit 1
else
echo
echo "✓ No manual API changes."
exit 0
fi
run: npm run api:generate && [ -z "$(git status --porcelain=v1 2>/dev/null)" ] && echo "✓ No manual API changes." || echo "✗ API manually changed, please refer to the README for the procedure to follow for programmatically generated API endpoints." && [ -z "$(git status --porcelain=v1 2>/dev/null)" ]
- name: Check for circular dependencies
run: npm run circular
- name: Run build
run: npm run build
- name: Run lint check
run: npm run lint
- name: Run unit tests
run: npm run test:coverage
- name: Run unit tests with cockpit
run: npm run test:cockpit
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/junit.xml
verbose: true

View file

@ -4,13 +4,6 @@ on:
pull_request:
types: [opened, reopened, synchronize, labeled, unlabeled]
workflow_dispatch:
merge_group:
# this prevents multiple jobs from the same pr
# running when new changes are pushed.
concurrency:
group: ${{github.workflow}}-${{ github.ref }}
cancel-in-progress: true
jobs:
playwright-tests:
@ -37,8 +30,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
- name: Install front-end dependencies
run: npm ci
@ -55,11 +48,8 @@ jobs:
- name: Start front-end server
run: |
npm run start:federated &
npx wait-on http://localhost:8003/apps/image-builder/
- name: Run testing proxy
run: docker run -d --network=host -e HTTPS_PROXY=$RH_PROXY_URL -v "$(pwd)/config:/config:ro,Z" --name consoledot-testing-proxy quay.io/dvagner/consoledot-testing-proxy
npm run start:stage &
npx wait-on https://localhost:1337
- name: Run front-end Playwright tests
env:

View file

@ -6,7 +6,6 @@ on:
types: [opened, synchronize, reopened, edited]
issue_comment:
types: [created]
merge_group:
jobs:
pr-best-practices:

View file

@ -13,10 +13,10 @@ jobs:
# artefact name.
- uses: actions/checkout@v4
- name: Use Node.js 22
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: 'npm'
- name: Install dependencies

View file

@ -8,7 +8,6 @@ jobs:
stale:
runs-on: ubuntu-latest
permissions:
actions: write # needed to clean up the saved action state
issues: write
pull-requests: write
steps:

View file

@ -1,51 +0,0 @@
name: Unit Tests
on:
pull_request:
branches: [ "main" ]
push:
branches: [ "main" ]
merge_group:
# this prevents multiple jobs from the same pr
# running when new changes are pushed.
concurrency:
group: ${{github.workflow}}-${{ github.ref }}
cancel-in-progress: true
jobs:
unit-tests:
name: Service Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/junit.xml
verbose: true
cockpit-unit-tests:
name: Cockpit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with cockpit
run: npm run test:cockpit

View file

@ -1,51 +0,0 @@
# This action checks API updates every day at 5:00 UTC.
name: Update API code generation
on:
workflow_dispatch:
schedule:
- cron: "0 5 * * *"
jobs:
update-api:
name: "Update API definitions"
if: github.repository == 'osbuild/image-builder-frontend'
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Mark the working directory as safe for git
run: git config --global --add safe.directory "$(pwd)"
- name: Run API code generation
run: npm run api
- name: Check if there are any changes
run: |
if [ "$(git status --porcelain)" ]; then
echo
echo "API codegen is up-to-date"
exit "0"
fi
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
branch: update-apis
delete-branch: true
title: "api: regenerate api code generation"
commit-message: "api: regenerate api code generation"
body: Update api code generation
token: ${{ secrets.SCHUTZBOT_GITHUB_ACCESS_TOKEN }}
author: schutzbot <schutzbot@gmail.com>

1
.gitignore vendored
View file

@ -48,4 +48,3 @@ rpmbuild
/blob-report/
/playwright/.cache/
.env
.auth

View file

@ -30,9 +30,8 @@ test:
parallel:
matrix:
- RUNNER:
- aws/fedora-41-x86_64
- aws/fedora-42-x86_64
- aws/rhel-10.1-nightly-x86_64
- aws/rhel-9.6-nightly-x86_64
- aws/rhel-10.0-nightly-x86_64
INTERNAL_NETWORK: ["true"]
finish:

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"semi": true,
"tabWidth": 2,
"singleQuote": false,
"jsxSingleQuote": false,
"bracketSpacing": true,
"tsxSingleQuote": true,
"tsSingleQuote": true
}

View file

@ -7,8 +7,9 @@ metadata:
build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}'
build.appstudio.redhat.com/target_branch: '{{target_branch}}'
pipelinesascode.tekton.dev/max-keep-runs: "3"
pipelinesascode.tekton.dev/on-cel-expression: (event == "pull_request" && target_branch == "main") || (event == "push" && target_branch.startsWith("gh-readonly-queue/main/"))
creationTimestamp:
pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch
== "main"
creationTimestamp: null
labels:
appstudio.openshift.io/application: insights-image-builder
appstudio.openshift.io/component: image-builder-frontend
@ -45,7 +46,7 @@ spec:
- name: name
value: show-sbom
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:beb0616db051952b4b861dd8c3e00fa1c0eccbd926feddf71194d3bb3ace9ce7
value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:002f7c8c1d2f9e09904035da414aba1188ae091df0ea9532cd997be05e73d594
- name: kind
value: task
resolver: bundles
@ -64,7 +65,7 @@ spec:
- name: name
value: summary
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-summary:0.2@sha256:3f6e8513cbd70f0416eb6c6f2766973a754778526125ff33d8e3633def917091
value: quay.io/konflux-ci/tekton-catalog/task-summary:0.2@sha256:76075b709fa06ed824cbc84f41448b397b85bfde1cf9809395ba6d286f5b7cbd
- name: kind
value: task
resolver: bundles
@ -83,11 +84,13 @@ spec:
name: output-image
type: string
- default: .
description: Path to the source code of an application's component from where to build image.
description: Path to the source code of an application's component from where
to build image.
name: path-context
type: string
- default: Dockerfile
description: Path to the Dockerfile inside the context specified by parameter path-context
description: Path to the Dockerfile inside the context specified by parameter
path-context
name: dockerfile
type: string
- default: "false"
@ -107,7 +110,8 @@ spec:
name: prefetch-input
type: string
- default: ""
description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively.
description: Image tag expiration time, time values could be something like
1h, 2d, 3w for hours, days, and weeks, respectively.
name: image-expires-after
- default: "false"
description: Build a source image.
@ -152,7 +156,7 @@ spec:
- name: name
value: init
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:08e18a4dc5f947c1d20e8353a19d013144bea87b72f67236b165dd4778523951
value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:7a24924417260b7094541caaedd2853dc8da08d4bb0968f710a400d3e8062063
- name: kind
value: task
resolver: bundles
@ -169,7 +173,7 @@ spec:
- name: name
value: git-clone
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-git-clone:0.1@sha256:7939000e2f92fc8b5d2c4ee4ba9000433c5aa7700d2915a1d4763853d5fd1fd4
value: quay.io/konflux-ci/tekton-catalog/task-git-clone:0.1@sha256:3ced9a6b9d8520773d3ffbf062190515a362ecda11e72f56e38e4dd980294b57
- name: kind
value: task
resolver: bundles
@ -194,7 +198,7 @@ spec:
- name: name
value: prefetch-dependencies
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:ce5f2485d759221444357fe38276be876fc54531651e50dcfc0f84b34909d760
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:89fb1850fdfa76eb9cc9d38952cb637f3b44108bf9b53cad5ed3716e17c53678
- name: kind
value: task
resolver: bundles
@ -238,7 +242,7 @@ spec:
- name: name
value: buildah
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:7782cb7462130de8e8839a58dd15ed78e50938d718b51375267679c6044b4367
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:c777fdb0947aff3e4ac29a93ed6358c6f7994e6b150154427646788ec773c440
- name: kind
value: task
resolver: bundles
@ -270,7 +274,7 @@ spec:
- name: name
value: build-image-index
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:72f77a8c62f9d6f69ab5c35170839e4b190026e6cc3d7d4ceafa7033fc30ad7b
value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:462ecbf94ec44a8b770d6ef8838955f91f57ee79795e5c18bdc0fcb0df593742
- name: kind
value: task
resolver: bundles
@ -282,9 +286,7 @@ spec:
- name: build-source-image
params:
- name: BINARY_IMAGE
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: BINARY_IMAGE_DIGEST
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
value: $(params.output-image)
runAfter:
- build-image-index
taskRef:
@ -292,7 +294,7 @@ spec:
- name: name
value: source-build
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-source-build:0.3@sha256:96ed9431854ecf9805407dca77b063abdf7aba1b3b9d1925a5c6145c6b7e95fd
value: quay.io/konflux-ci/tekton-catalog/task-source-build:0.2@sha256:11029fa30652154f772b44132f8a116382c136a6223e8f9576137f99b9901dcb
- name: kind
value: task
resolver: bundles
@ -321,7 +323,7 @@ spec:
- name: name
value: sast-shell-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check:0.1@sha256:4a63982791a1a68f560c486f524ef5b9fdbeee0c16fe079eee3181a2cfd1c1bf
value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check:0.1@sha256:188a4f6a582ac43d4de46c3998ded3c2a8ee237fb0604d90559a3b6e0aa62b0f
- name: kind
value: task
resolver: bundles
@ -337,8 +339,6 @@ spec:
params:
- name: image-url
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: image-digest
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
runAfter:
- build-image-index
taskRef:
@ -346,7 +346,7 @@ spec:
- name: name
value: sast-unicode-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check:0.3@sha256:bec18fa5e82e801c3f267f29bf94535a5024e72476f2b27cca7271d506abb5ad
value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check:0.2@sha256:e4a5215b45b1886a185a9db8ab392f8440c2b0848f76d719885637cf8d2628ed
- name: kind
value: task
resolver: bundles
@ -371,7 +371,7 @@ spec:
- name: name
value: deprecated-image-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:1d07d16810c26713f3d875083924d93697900147364360587ccb5a63f2c31012
value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:eb8136b543147b4a3e88ca3cc661ca6a11e303f35f0db44059f69151beea8496
- name: kind
value: task
resolver: bundles
@ -393,7 +393,7 @@ spec:
- name: name
value: clair-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:893ffa3ce26b061e21bb4d8db9ef7ed4ddd4044fe7aa5451ef391034da3ff759
value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:7c73e2beca9b8306387efeaf775831440ec799b05a5f5c008a65bb941a1e91f6
- name: kind
value: task
resolver: bundles
@ -413,7 +413,7 @@ spec:
- name: name
value: ecosystem-cert-preflight-checks
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:1f151e00f7fc427654b7b76045a426bb02fe650d192ffe147a304d2184787e38
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:dea8d9b4bec3e99d612d799798acf132df48276164b5193ea68f9f3c25ae425b
- name: kind
value: task
resolver: bundles
@ -435,7 +435,7 @@ spec:
- name: name
value: sast-snyk-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check:0.4@sha256:351f2dce893159b703e9b6d430a2450b3df9967cb9bd3adb46852df8ccfe4c0d
value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check:0.4@sha256:0d22dbaa528c8edf59aafab3600a0537b5408b80a4f69dd9cb616620795ecdc8
- name: kind
value: task
resolver: bundles
@ -460,7 +460,7 @@ spec:
- name: name
value: clamav-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:cce2dfcc5bd6e91ee54aacdadad523b013eeae5cdaa7f6a4624b8cbcc040f439
value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.2@sha256:59094118aa07d5b0199565c4e0b2d0f4feb9a4741877c8716877572e2c4804f9
- name: kind
value: task
resolver: bundles
@ -471,10 +471,8 @@ spec:
- "false"
- name: apply-tags
params:
- name: IMAGE_URL
- name: IMAGE
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: IMAGE_DIGEST
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
runAfter:
- build-image-index
taskRef:
@ -482,7 +480,7 @@ spec:
- name: name
value: apply-tags
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.2@sha256:70881c97a4c51ee1f4d023fa1110e0bdfcfd2f51d9a261fa543c3862b9a4eee9
value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.1@sha256:3f89ba89cacf8547261b5ce064acce81bfe470c8ace127794d0e90aebc8c347d
- name: kind
value: task
resolver: bundles
@ -503,7 +501,7 @@ spec:
- name: name
value: push-dockerfile
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:d5cb22a833be51dd72a872cac8bfbe149e8ad34da7cb48a643a1e613447a1f9d
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:6124587dffebd15b2123f73ca25807c5e69ff349489b31d4af6ff46a5d0228d6
- name: kind
value: task
resolver: bundles
@ -523,7 +521,7 @@ spec:
- name: name
value: rpms-signature-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2@sha256:1b6c20ab3dbfb0972803d3ebcb2fa72642e59400c77bd66dfd82028bdd09e120
value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2@sha256:2366b2f394610192736dd8edac1a702964daeb961603dfc9ceb6b8188e39a009
- name: kind
value: task
resolver: bundles
@ -544,7 +542,7 @@ spec:
- name: workspace
volumeClaimTemplate:
metadata:
creationTimestamp:
creationTimestamp: null
spec:
accessModes:
- ReadWriteOnce

View file

@ -6,8 +6,9 @@ metadata:
build.appstudio.redhat.com/commit_sha: '{{revision}}'
build.appstudio.redhat.com/target_branch: '{{target_branch}}'
pipelinesascode.tekton.dev/max-keep-runs: "3"
pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main"
creationTimestamp:
pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch
== "main"
creationTimestamp: null
labels:
appstudio.openshift.io/application: insights-image-builder
appstudio.openshift.io/component: image-builder-frontend
@ -42,7 +43,7 @@ spec:
- name: name
value: show-sbom
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:beb0616db051952b4b861dd8c3e00fa1c0eccbd926feddf71194d3bb3ace9ce7
value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:002f7c8c1d2f9e09904035da414aba1188ae091df0ea9532cd997be05e73d594
- name: kind
value: task
resolver: bundles
@ -61,7 +62,7 @@ spec:
- name: name
value: summary
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-summary:0.2@sha256:3f6e8513cbd70f0416eb6c6f2766973a754778526125ff33d8e3633def917091
value: quay.io/konflux-ci/tekton-catalog/task-summary:0.2@sha256:76075b709fa06ed824cbc84f41448b397b85bfde1cf9809395ba6d286f5b7cbd
- name: kind
value: task
resolver: bundles
@ -80,11 +81,13 @@ spec:
name: output-image
type: string
- default: .
description: Path to the source code of an application's component from where to build image.
description: Path to the source code of an application's component from where
to build image.
name: path-context
type: string
- default: Dockerfile
description: Path to the Dockerfile inside the context specified by parameter path-context
description: Path to the Dockerfile inside the context specified by parameter
path-context
name: dockerfile
type: string
- default: "false"
@ -104,7 +107,8 @@ spec:
name: prefetch-input
type: string
- default: ""
description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively.
description: Image tag expiration time, time values could be something like
1h, 2d, 3w for hours, days, and weeks, respectively.
name: image-expires-after
- default: "false"
description: Build a source image.
@ -149,7 +153,7 @@ spec:
- name: name
value: init
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:08e18a4dc5f947c1d20e8353a19d013144bea87b72f67236b165dd4778523951
value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:7a24924417260b7094541caaedd2853dc8da08d4bb0968f710a400d3e8062063
- name: kind
value: task
resolver: bundles
@ -166,7 +170,7 @@ spec:
- name: name
value: git-clone
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-git-clone:0.1@sha256:7939000e2f92fc8b5d2c4ee4ba9000433c5aa7700d2915a1d4763853d5fd1fd4
value: quay.io/konflux-ci/tekton-catalog/task-git-clone:0.1@sha256:3ced9a6b9d8520773d3ffbf062190515a362ecda11e72f56e38e4dd980294b57
- name: kind
value: task
resolver: bundles
@ -191,7 +195,7 @@ spec:
- name: name
value: prefetch-dependencies
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:ce5f2485d759221444357fe38276be876fc54531651e50dcfc0f84b34909d760
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:89fb1850fdfa76eb9cc9d38952cb637f3b44108bf9b53cad5ed3716e17c53678
- name: kind
value: task
resolver: bundles
@ -235,7 +239,7 @@ spec:
- name: name
value: buildah
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:7782cb7462130de8e8839a58dd15ed78e50938d718b51375267679c6044b4367
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:c777fdb0947aff3e4ac29a93ed6358c6f7994e6b150154427646788ec773c440
- name: kind
value: task
resolver: bundles
@ -267,7 +271,7 @@ spec:
- name: name
value: build-image-index
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:72f77a8c62f9d6f69ab5c35170839e4b190026e6cc3d7d4ceafa7033fc30ad7b
value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:462ecbf94ec44a8b770d6ef8838955f91f57ee79795e5c18bdc0fcb0df593742
- name: kind
value: task
resolver: bundles
@ -279,9 +283,7 @@ spec:
- name: build-source-image
params:
- name: BINARY_IMAGE
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: BINARY_IMAGE_DIGEST
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
value: $(params.output-image)
runAfter:
- build-image-index
taskRef:
@ -289,7 +291,7 @@ spec:
- name: name
value: source-build
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-source-build:0.3@sha256:96ed9431854ecf9805407dca77b063abdf7aba1b3b9d1925a5c6145c6b7e95fd
value: quay.io/konflux-ci/tekton-catalog/task-source-build:0.2@sha256:11029fa30652154f772b44132f8a116382c136a6223e8f9576137f99b9901dcb
- name: kind
value: task
resolver: bundles
@ -318,7 +320,7 @@ spec:
- name: name
value: sast-shell-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check:0.1@sha256:4a63982791a1a68f560c486f524ef5b9fdbeee0c16fe079eee3181a2cfd1c1bf
value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check:0.1@sha256:188a4f6a582ac43d4de46c3998ded3c2a8ee237fb0604d90559a3b6e0aa62b0f
- name: kind
value: task
resolver: bundles
@ -334,8 +336,6 @@ spec:
params:
- name: image-url
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: image-digest
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
runAfter:
- build-image-index
taskRef:
@ -343,7 +343,7 @@ spec:
- name: name
value: sast-unicode-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check:0.3@sha256:bec18fa5e82e801c3f267f29bf94535a5024e72476f2b27cca7271d506abb5ad
value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check:0.2@sha256:e4a5215b45b1886a185a9db8ab392f8440c2b0848f76d719885637cf8d2628ed
- name: kind
value: task
resolver: bundles
@ -368,7 +368,7 @@ spec:
- name: name
value: deprecated-image-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:1d07d16810c26713f3d875083924d93697900147364360587ccb5a63f2c31012
value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:eb8136b543147b4a3e88ca3cc661ca6a11e303f35f0db44059f69151beea8496
- name: kind
value: task
resolver: bundles
@ -390,7 +390,7 @@ spec:
- name: name
value: clair-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:893ffa3ce26b061e21bb4d8db9ef7ed4ddd4044fe7aa5451ef391034da3ff759
value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:7c73e2beca9b8306387efeaf775831440ec799b05a5f5c008a65bb941a1e91f6
- name: kind
value: task
resolver: bundles
@ -410,7 +410,7 @@ spec:
- name: name
value: ecosystem-cert-preflight-checks
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:1f151e00f7fc427654b7b76045a426bb02fe650d192ffe147a304d2184787e38
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:dea8d9b4bec3e99d612d799798acf132df48276164b5193ea68f9f3c25ae425b
- name: kind
value: task
resolver: bundles
@ -432,7 +432,7 @@ spec:
- name: name
value: sast-snyk-check
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check:0.4@sha256:351f2dce893159b703e9b6d430a2450b3df9967cb9bd3adb46852df8ccfe4c0d
value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check:0.4@sha256:0d22dbaa528c8edf59aafab3600a0537b5408b80a4f69dd9cb616620795ecdc8
- name: kind
value: task
resolver: bundles
@ -457,7 +457,7 @@ spec:
- name: name
value: clamav-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:cce2dfcc5bd6e91ee54aacdadad523b013eeae5cdaa7f6a4624b8cbcc040f439
value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.2@sha256:59094118aa07d5b0199565c4e0b2d0f4feb9a4741877c8716877572e2c4804f9
- name: kind
value: task
resolver: bundles
@ -468,10 +468,8 @@ spec:
- "false"
- name: apply-tags
params:
- name: IMAGE_URL
- name: IMAGE
value: $(tasks.build-image-index.results.IMAGE_URL)
- name: IMAGE_DIGEST
value: $(tasks.build-image-index.results.IMAGE_DIGEST)
runAfter:
- build-image-index
taskRef:
@ -479,7 +477,7 @@ spec:
- name: name
value: apply-tags
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.2@sha256:70881c97a4c51ee1f4d023fa1110e0bdfcfd2f51d9a261fa543c3862b9a4eee9
value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.1@sha256:3f89ba89cacf8547261b5ce064acce81bfe470c8ace127794d0e90aebc8c347d
- name: kind
value: task
resolver: bundles
@ -500,7 +498,7 @@ spec:
- name: name
value: push-dockerfile
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:d5cb22a833be51dd72a872cac8bfbe149e8ad34da7cb48a643a1e613447a1f9d
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:6124587dffebd15b2123f73ca25807c5e69ff349489b31d4af6ff46a5d0228d6
- name: kind
value: task
resolver: bundles
@ -520,7 +518,7 @@ spec:
- name: name
value: rpms-signature-scan
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2@sha256:1b6c20ab3dbfb0972803d3ebcb2fa72642e59400c77bd66dfd82028bdd09e120
value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2@sha256:2366b2f394610192736dd8edac1a702964daeb961603dfc9ceb6b8188e39a009
- name: kind
value: task
resolver: bundles
@ -541,7 +539,7 @@ spec:
- name: workspace
volumeClaimTemplate:
metadata:
creationTimestamp:
creationTimestamp: null
spec:
accessModes:
- ReadWriteOnce

View file

@ -1,13 +1,12 @@
PACKAGE_NAME = cockpit-image-builder
INSTALL_DIR_BASE = /share/cockpit/
INSTALL_DIR = $(INSTALL_DIR_BASE)$(PACKAGE_NAME)
INSTALL_DIR = /share/cockpit/$(PACKAGE_NAME)
APPSTREAMFILE=org.image-builder.$(PACKAGE_NAME).metainfo.xml
VERSION := $(shell (cd "$(SRCDIR)" && grep "^Version:" cockpit/$(PACKAGE_NAME).spec | sed 's/[^[:digit:]]*\([[:digit:]]\+\).*/\1/'))
COMMIT = $(shell (cd "$(SRCDIR)" && git rev-parse HEAD))
# TODO: figure out a strategy for keeping this updated
COCKPIT_REPO_COMMIT = a70142a7a6f9c4e78e71f3c4ec738b6db2fbb04f
COCKPIT_REPO_COMMIT = b0e82161b4afcb9f0a6fddd8ff94380e983b2238
COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git
COCKPIT_REPO_TREE = '$(strip $(COCKPIT_REPO_COMMIT))^{tree}'
@ -56,7 +55,6 @@ cockpit/devel-uninstall:
cockpit/devel-install: PREFIX=~/.local
cockpit/devel-install:
PREFIX="~/.local"
mkdir -p $(PREFIX)$(INSTALL_DIR_BASE)
ln -s $(shell pwd)/cockpit/public $(PREFIX)$(INSTALL_DIR)
.PHONY: cockpit/download

211
README.md
View file

@ -19,20 +19,16 @@ Frontend code for Image Builder.
## Table of Contents
1. [How to build and run image-builder-frontend](#frontend-development)
1. [Frontend Development](#frontend-development)
2. [Image builder as Cockpit plugin](#image-builder-as-cockpit-plugin)
3. [Backend Development](#backend-development)
2. [API](#api-endpoints)
3. [Unleash feature flags](#unleash-feature-flags)
4. [File structure](#file-structure)
5. [Style Guidelines](#style-guidelines)
6. [Test Guidelines](#test-guidelines)
7. [Running hosted service Playwright tests](#running-hosted-service-playwright-tests)
1. [API](#api-endpoints)
2. [Unleash feature flags](#unleash-feature-flags)
2. [Backend Development](#backend-development)
2. [File structure](#file-structure)
3. [Style Guidelines](#style-guidelines)
4. [Test Guidelines](#test-guidelines)
5. [Running hosted service Playwright tests](#running-hosted-service-playwright-tests)
## How to build and run image-builder-frontend
> [!IMPORTANT]
> Running image-builder-frontend against [console.redhat.com](https://console.redhat.com/) requires connection to the Red Hat VPN, which is only available to Red Hat employees. External contributors can locally run [image builder as Cockpit plugin](#image-builder-as-cockpit-plugin).
### Frontend Development
To develop the frontend you can use a proxy to run image-builder-frontend locally
@ -43,7 +39,7 @@ worrying if a feature from stage has been released yet.
#### Nodejs and npm version
Make sure you have npm@10 and node 22+ installed. If you need multiple versions of nodejs check out [nvm](https://github.com/nvm-sh/nvm).
Make sure you have npm@10 and node 18+ installed. If you need multiple versions of nodejs check out [nvm](https://github.com/nvm-sh/nvm).
#### Webpack proxy
@ -73,70 +69,52 @@ echo "127.0.0.1 stage.foo.redhat.com" >> /etc/hosts
4. open browser at `https://stage.foo.redhat.com:1337/beta/insights/image-builder`
### Image builder as Cockpit plugin
#### Insights proxy (deprecated)
> [!NOTE]
> Issues marked with [cockpit-image-builder](https://github.com/osbuild/image-builder-frontend/issues?q=is%3Aissue%20state%3Aopen%20label%3Acockpit-image-builder) label are reproducible in image builder plugin and can be worked on by external contributors without connection to the Red Hat VPN.
1. Clone the insights proxy: https://github.com/RedHatInsights/insights-proxy
#### Cockpit setup
To install and setup Cockpit follow guide at: https://cockpit-project.org/running.html
2. Setting up the proxy
#### On-premises image builder installation and configuration
To install and configure `osbuild-composer` on your local machine follow our documentation: https://osbuild.org/docs/on-premises/installation/
Choose a runner (podman or docker), and point the SPANDX_CONFIG variable to
`profile/local-frontend.js` included in image-builder-frontend.
#### Scripts for local development of image builder plugin
```bash
sudo insights-proxy/scripts/patch-etc-hosts.sh
export RUNNER="podman"
export SPANDX_CONFIG=$PATH_TO/image-builder-frontend/profiles/local-frontend.js
sudo -E insights-proxy/scripts/run.sh
```
The following scripts are used to build the frontend with Webpack and install it into the Cockpit directories. These scripts streamline the development process by automating build and installation steps.
3. Starting up image-builder-frontend
Runs Webpack with the specified configuration (cockpit/webpack.config.ts) to build the frontend assets.
Use this command whenever you need to compile the latest changes in your frontend code.
In the image-builder-frontend checkout directory
Creates the necessary directory in the user's local Cockpit share (~/.local/share/cockpit/).
Creates a symbolic link (image-builder-frontend) pointing to the built frontend assets (cockpit/public).
Use this command after building the frontend to install it locally for development purposes.
The symbolic link allows Cockpit to serve the frontend assets from your local development environment,
making it easier to test changes in real-time without deploying to a remote server.
```bash
npm install
npm start
```
```bash
make cockpit/build
```
The UI should be running on
https://prod.foo.redhat.com:1337/beta/insights/image-builder/landing.
Note that this requires you to have access to either production or stage (plus VPN and proxy config) of insights.
```bash
make cockpit/devel-install
```
To uninstall and remove the symbolic link, run the following command:
```bash
make cockpit/devel-uninstall
```
For convenience, you can run the following to combine all three steps:
```bash
make cockpit/devel
```
### Backend Development
To develop both the frontend and the backend you can again use the proxy to run both the
frontend and backend locally against the chrome at cloud.redhat.com. For instructions
see the [osbuild-getting-started project](https://github.com/osbuild/osbuild-getting-started).
## API endpoints
#### API endpoints
API slice definitions are programmatically generated using the [@rtk-query/codegen-openapi](https://redux-toolkit.js.org/rtk-query/usage/code-generation) package.
The OpenAPI schema are imported during code generation. OpenAPI configuration files are
stored in `/api/config`. Each endpoint has a corresponding empty API slice and generated API
slice which are stored in `/src/store`.
OpenAPI schema for the endpoints are stored in `/api/schema`. Their
corresponding configuration files are stored in `/api/config`. Each endpoint
has a corresponding empty API slice and generated API slice which are stored in
`/src/store`.
### Add a new API schema
##### Add a new API
For a hypothetical API called foobar
1. Create a new "empty" API file under `src/store/emptyFoobarApi.ts` that has following
1. Download the foobar API OpenAPI json or yaml representation under
`api/schema/foobar.json`
2. Create a new "empty" API file under `src/store/emptyFoobarApi.ts` that has following
content:
```typescript
@ -152,21 +130,21 @@ export const emptyFoobarApi = createApi({
});
```
2. Declare new constant `FOOBAR_API` with the API url in `src/constants.ts`
3. Declare new constant `FOOBAR_API` with the API url in `src/constants.ts`
```typescript
export const FOOBAR_API = 'api/foobar/v1'
```
3. Create the config file for code generation in `api/config/foobar.ts` containing:
4. Create the config file for code generation in `api/config/foobar.ts` containing:
```typescript
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: 'URL_TO_THE_OPENAPI_SCHEMA',
schemaFile: '../schema/foobar.json',
apiFile: '../../src/store/emptyFoobarApi.ts',
apiImport: 'emptyContentSourcesApi',
apiImport: 'emptyEdgeApi',
outputFile: '../../src/store/foobarApi.ts',
exportName: 'foobarApi',
hooks: true,
@ -174,16 +152,20 @@ const config: ConfigFile = {
};
```
4. Update the `eslint.config.js` file by adding the generated code path to the ignores array:
5. Update the `api.sh` script by adding a new line for npx to generate the code:
```
ignores: [
<other ignored files>,
'**/foobarApi.ts',
]
```bash
npx @rtk-query/codegen-openapi ./api/config/foobar.ts &
```
5. run api generation
6. Update the `.eslintignore` file by adding a new line for the generated code:
```
foobarApi.ts
```
7. run api generation
```bash
npm run api
@ -191,12 +173,12 @@ npm run api
And voilà!
### Add a new endpoint
##### Add a new endpoint
To add a new endpoint, simply update the `api/config/foobar.ts` file with new
endpoints in the `filterEndpoints` table.
## Unleash feature flags
#### Unleash feature flags
Your user needs to have the corresponding rights, do the
same as this MR in internal gitlab https://gitlab.cee.redhat.com/service/app-interface/-/merge_requests/79225
@ -212,7 +194,7 @@ existing flags:
https://github.com/RedHatInsights/image-builder-frontend/blob/c84b493eba82ce83a7844943943d91112ffe8322/src/Components/ImagesTable/ImageLink.js#L99
### Mocking flags for tests
##### Mocking flags for tests
Flags can be mocked for the unit tests to access some feature. Checkout:
https://github.com/osbuild/image-builder-frontend/blob/9a464e416bc3769cfc8e23b62f1dd410eb0e0455/src/test/Components/CreateImageWizard/CreateImageWizard.test.tsx#L49
@ -222,18 +204,66 @@ base, then it's good practice to test the two of them. If not, only test what's
actually owned by the frontend project.
### Cleaning the flags
##### Cleaning the flags
Unleash toggles are expected to live for a limited amount of time, documentation
specify 40 days for a release, we should keep that in mind for each toggle
we're planning on using.
### Backend Development
To develop both the frontend and the backend you can again use the proxy to run both the
frontend and backend locally against the chrome at cloud.redhat.com. For instructions
see the [osbuild-getting-started project](https://github.com/osbuild/osbuild-getting-started).
## File Structure
### OnPremise Development - Cockpit Build and Install
## Overview
The following scripts are used to build the frontend with Webpack and install it into the Cockpit directories. These scripts streamline the development process by automating build and installation steps.
### Scripts
#### 1. Build the Cockpit Frontend
Runs Webpack with the specified configuration (cockpit/webpack.config.ts) to build the frontend assets.
Use this command whenever you need to compile the latest changes in your frontend code.
Creates the necessary directory in the user's local Cockpit share (~/.local/share/cockpit/).
Creates a symbolic link (image-builder-frontend) pointing to the built frontend assets (cockpit/public).
Use this command after building the frontend to install it locally for development purposes.
The symbolic link allows Cockpit to serve the frontend assets from your local development environment,
making it easier to test changes in real-time without deploying to a remote server.
```bash
make devel-install
```
```bash
make build
```
To uninstall and remove the symbolic link, run the following command:
```bash
make devel-uninstall
```
For convenience, you can run the following to combine all three steps:
```bash
make cockpit/all
```
### Quick Reference
| Directory | Description |
| --------- | ----------- |
| [`/api`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/api) | API schema and config files |
| [`/config`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/config) | webpack configuration |
| [`/devel`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/devel) | tools for local development |
| [`/src`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/src) | source code |
| [`/src/Components`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/src/Components) | source code split by individual components |
| [`/src/test`](https://github.com/RedHatInsights/image-builder-frontend/tree/main/src/test) | test utilities |
@ -242,19 +272,8 @@ we're planning on using.
## Style Guidelines
This project uses recommended rule sets rom several plugins:
- `@eslint/js`
- `typescript-eslint`
- `eslint-plugin-react`
- `eslint-plugin-react-hooks`
- `eslint-plugin-react-redux`
- `eslint-plugin-import`
- `eslint-plugin-jsx-a11y`
- `eslint-plugin-disable-autofix`
- `eslint-plugin-jest-dom`
- `eslint-plugin-testing-library`
- `eslint-plugin-playwright`
- `@redhat-cloud-services/eslint-config-redhat-cloud-services`
This project uses eslint's recommended styling guidelines. These rules can be found here:
https://eslint.org/docs/rules/
To run the linter, use:
```bash
@ -263,10 +282,16 @@ npm run lint
Any errors that can be fixed automatically, can be corrected by running:
```bash
npm run lint:js:fix
npm run lint --fix
```
All the linting rules and configuration of ESLint can be found in [`eslint.config.js`](https://github.com/RedHatInsights/image-builder-frontend/blob/main/eslint.config.js).
All the linting rules and configuration of eslint can be found in [`.eslintrc.yml`](https://github.com/RedHatInsights/image-builder-frontend/blob/main/.eslintrc.yml).
### Additional eslint rules
There are also additional rules added to enforce code style. Those being:
- `import/order` -> enforces the order in import statements and separates them into groups based on their type
- `prefer-const` -> enforces use of `const` declaration for variables that are never reassigned
- `no-console` -> throws an error for any calls of `console` methods leftover after debugging
## Test Guidelines
@ -344,16 +369,16 @@ Follow these steps to find and paste the certification file into the 'Keychain A
npm ci
```
3. Download the Playwright browsers with
3. Download the Playwright browsers with
```bash
npx playwright install
```
4. Start the local development stage server by running
4. Start the local development stage server by running
```bash
npm run start:stage
```
5. Now you have two options of how to run the tests:
* (Preferred) Use VS Code and the [Playwright Test module for VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). But other editors do have similar plugins for ease of use, if so desired
* Using terminal - `npx playwright test` will run the playwright test suite. `npx playwright test --headed` will run the suite in a vnc-like browser so you can watch it's interactions.
* Using terminal - `npx playwright test` will run the playwright test suite. `npx playwright test --headed` will run the suite in a vnc-like browser so you can watch it's interactions.

View file

@ -5,6 +5,7 @@ npx @rtk-query/codegen-openapi ./api/config/imageBuilder.ts &
npx @rtk-query/codegen-openapi ./api/config/rhsm.ts &
npx @rtk-query/codegen-openapi ./api/config/contentSources.ts &
npx @rtk-query/codegen-openapi ./api/config/provisioning.ts &
npx @rtk-query/codegen-openapi ./api/config/edge.ts &
npx @rtk-query/codegen-openapi ./api/config/compliance.ts &
npx @rtk-query/codegen-openapi ./api/config/composerCloudApi.ts &

View file

@ -1,7 +1,7 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: 'https://console.redhat.com/api/compliance/v2/openapi.json',
schemaFile: '../schema/compliance.json',
apiFile: '../../src/store/service/emptyComplianceApi.ts',
apiImport: 'emptyComplianceApi',
outputFile: '../../src/store/service/complianceApi.ts',

View file

@ -1,15 +1,17 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile:
'https://raw.githubusercontent.com/osbuild/osbuild-composer/main/internal/cloudapi/v2/openapi.v2.yml',
schemaFile: '../schema/composerCloudApi.v2.yaml',
apiFile: '../../src/store/cockpit/emptyComposerCloudApi.ts',
apiImport: 'emptyComposerCloudApi',
outputFile: '../../src/store/cockpit/composerCloudApi.ts',
exportName: 'composerCloudApi',
hooks: false,
unionUndefined: true,
filterEndpoints: ['postCompose', 'getComposeStatus'],
filterEndpoints: [
'postCompose',
'getComposeStatus',
],
};
export default config;

View file

@ -1,7 +1,7 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: 'https://console.redhat.com/api/content-sources/v1/openapi.json',
schemaFile: '../schema/contentSources.json',
apiFile: '../../src/store/service/emptyContentSourcesApi.ts',
apiImport: 'emptyContentSourcesApi',
outputFile: '../../src/store/service/contentSourcesApi.ts',
@ -12,7 +12,6 @@ const config: ConfigFile = {
'createRepository',
'listRepositories',
'listRepositoriesRpms',
'listRepositoryParameters',
'searchRpm',
'searchPackageGroup',
'listFeatures',

36
api/config/edge.ts Normal file
View file

@ -0,0 +1,36 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: '../schema/edge.json',
apiFile: '../../src/store/service/emptyEdgeApi.ts',
apiImport: 'emptyEdgeApi',
outputFile: '../../src/store/service/edgeApi.ts',
exportName: 'edgeApi',
hooks: true,
unionUndefined: true,
filterEndpoints: [
'createImage',
'createImageUpdate',
'getAllImages',
'getImageStatusByID',
'getImageByID',
'getImageDetailsByID',
'getImageByOstree',
'createInstallerForImage',
'getRepoForImage',
'getMetadataForImage',
'createKickStartForImage',
'checkImageName',
'retryCreateImage',
'listAllImageSets',
'getImageSetsByID',
'getImageSetsView',
'getImageSetViewByID',
'getAllImageSetImagesView',
'getImageSetsDevicesByID',
'deleteImageSet',
'getImageSetImageView',
],
};
export default config;

View file

@ -1,8 +1,7 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile:
'https://raw.githubusercontent.com/osbuild/image-builder/main/internal/v1/api.yaml',
schemaFile: '../schema/imageBuilder.yaml',
apiFile: '../../src/store/service/emptyImageBuilderApi.ts',
apiImport: 'emptyImageBuilderApi',
outputFile: '../../src/store/service/imageBuilderApi.ts',

View file

@ -1,7 +1,7 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: 'https://console.redhat.com/api/provisioning/v1/openapi.json',
schemaFile: '../schema/provisioning.json',
apiFile: '../../src/store/service/emptyProvisioningApi.ts',
apiImport: 'emptyProvisioningApi',
outputFile: '../../src/store/service/provisioningApi.ts',

View file

@ -1,7 +1,7 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi';
const config: ConfigFile = {
schemaFile: 'https://console.redhat.com/api/rhsm/v2/openapi.json',
schemaFile: '../schema/rhsm.json',
apiFile: '../../src/store/service/emptyRhsmApi.ts',
apiImport: 'emptyRhsmApi',
outputFile: '../../src/store/service/rhsmApi.ts',

10
api/pull.sh Normal file
View file

@ -0,0 +1,10 @@
#!/bin/bash
# Download the most up-to-date imageBuilder.yaml file and overwrite the existing one
curl https://raw.githubusercontent.com/osbuild/image-builder/main/internal/v1/api.yaml -o ./api/schema/imageBuilder.yaml
curl https://console.redhat.com/api/compliance/v2/openapi.json -o ./api/schema/compliance.json
curl https://console.redhat.com/api/content-sources/v1/openapi.json -o ./api/schema/contentSources.json
curl https://raw.githubusercontent.com/osbuild/osbuild-composer/main/internal/cloudapi/v2/openapi.v2.yml -o ./api/schema/composerCloudApi.v2.yaml

17420
api/schema/compliance.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

5722
api/schema/edge.json Normal file

File diff suppressed because it is too large Load diff

2230
api/schema/imageBuilder.yaml Normal file

File diff suppressed because it is too large Load diff

2044
api/schema/provisioning.json Normal file

File diff suppressed because it is too large Load diff

1
api/schema/rhsm.json Normal file

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit b496d0a8c1755608bd256a6960869b14a7689d38
Subproject commit 28b7b8f444533ae9d233d3ccfe1f94abf5f37ac2

View file

@ -9,7 +9,7 @@ export COMPONENT="image-builder"
export IMAGE="quay.io/cloudservices/image-builder-frontend"
export APP_ROOT=$(pwd)
export WORKSPACE=${WORKSPACE:-$APP_ROOT} # if running in jenkins, use the build's workspace
export NODE_BUILD_VERSION=22
export NODE_BUILD_VERSION=18
COMMON_BUILDER=https://raw.githubusercontent.com/RedHatInsights/insights-frontend-builder-common/master
set -exv

View file

@ -1,3 +1 @@
# cockpit-image-builder
The "cockpit-image-builder" provides an on-premise frontend for image building, designed to integrate with [Cockpit](https://cockpit-project.org/) as a plugin. It allows users to create, manage, and compose custom operating system images, with images stored locally.
TODO

View file

@ -1,5 +1,5 @@
Name: cockpit-image-builder
Version: 76
Version: 67
Release: 1%{?dist}
Summary: Image builder plugin for Cockpit

View file

@ -1,15 +1,15 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-us" class="layout-pf pf-m-redhat-font">
<head>
<meta charset="utf-8" />
<head>
<meta charset="utf-8">
<title>Image-Builder</title>
<!-- js dependencies -->
<script type="text/javascript" src="../base1/cockpit.js"></script>
<script defer src="main.js"></script>
<link href="main.css" rel="stylesheet" />
</head>
<body>
<div class="ct-page-fill" id="main"></div>
</body>
<link href="main.css" rel="stylesheet">
</head>
<body>
<div id="main"></div>
<script defer src="main.js"></script>
</body>
</html>

View file

@ -75,15 +75,7 @@ module.exports = {
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: { url: false },
},
'sass-loader',
],
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
},
],
},

View file

@ -1,3 +0,0 @@
{
"/apps/image-builder*": { "url": "http://127.0.0.1:8003" }
}

View file

@ -0,0 +1,183 @@
---
apiVersion: template.openshift.io/v1
kind: Template
metadata:
name: image-builder-frontend-tests
objects:
- apiVersion: batch/v1
kind: Job
metadata:
name: image-builder-frontend-${TEST_TYPE}-tests-${IMAGE_TAG}-${UID}
annotations:
"ignore-check.kube-linter.io/no-liveness-probe": "probes not required on Job pods"
"ignore-check.kube-linter.io/no-readiness-probe": "probes not required on Job pods"
spec:
backoffLimit: 0
template:
spec:
imagePullSecrets:
- name: quay-cloudservices-pull
restartPolicy: Never
volumes:
- name: sel-shm
emptyDir:
medium: Memory
- name: sel-downloads
emptyDir:
medium: Memory
sizeLimit: 64Mi
containers:
- name: image-builder-frontend-iqe-${TEST_TYPE}-tests-${IMAGE_TAG}-${UID}
image: ${IQE_IMAGE}
imagePullPolicy: Always
args:
- run
env:
- name: ENV_FOR_DYNACONF
value: ${ENV_FOR_DYNACONF}
- name: DYNACONF_MAIN__use_beta
value: ${USE_BETA}
- name: IQE_IBUTSU_SOURCE
value: image-builder-${IMAGE_TAG}-tests-${UID}-${ENV_FOR_DYNACONF}
- name: IQE_BROWSERLOG
value: ${IQE_BROWSERLOG}
- name: IQE_NETLOG
value: ${IQE_NETLOG}
- name: IQE_PLUGINS
value: ${IQE_PLUGINS}
- name: IQE_MARKER_EXPRESSION
value: ${IQE_MARKER_EXPRESSION}
- name: IQE_FILTER_EXPRESSION
value: ${IQE_FILTER_EXPRESSION}
- name: IQE_LOG_LEVEL
value: ${IQE_LOG_LEVEL}
- name: IQE_REQUIREMENTS
value: ${IQE_REQUIREMENTS}
- name: IQE_PARALLEL_ENABLED
value: ${IQE_PARALLEL_ENABLED}
- name: IQE_REQUIREMENTS_PRIORITY
value: ${IQE_REQUIREMENTS_PRIORITY}
- name: IQE_TEST_IMPORTANCE
value: ${IQE_TEST_IMPORTANCE}
- name: DYNACONF_IQE_VAULT_LOADER_ENABLED
value: "true"
- name: DYNACONF_IQE_VAULT_VERIFY
value: "true"
- name: DYNACONF_IQE_VAULT_URL
valueFrom:
secretKeyRef:
key: url
name: iqe-vault
optional: true
- name: DYNACONF_IQE_VAULT_MOUNT_POINT
valueFrom:
secretKeyRef:
key: mountPoint
name: iqe-vault
optional: true
- name: DYNACONF_IQE_VAULT_ROLE_ID
valueFrom:
secretKeyRef:
key: roleId
name: iqe-vault
optional: true
- name: DYNACONF_IQE_VAULT_SECRET_ID
valueFrom:
secretKeyRef:
key: secretId
name: iqe-vault
optional: true
resources:
limits:
cpu: ${IQE_CPU_LIMIT}
memory: ${IQE_MEMORY_LIMIT}
requests:
cpu: ${IQE_CPU_REQUEST}
memory: ${IQE_MEMORY_REQUEST}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- name: sel-downloads
mountPath: /sel-downloads
- name: image-builder-sel-${TEST_TYPE}-tests-${IMAGE_TAG}-${UID}
image: ${IQE_SEL_IMAGE}
env:
- name: _JAVA_OPTIONS
value: ${SELENIUM_JAVA_OPTS}
- name: VNC_GEOMETRY
value: ${VNC_GEOMETRY}
- name: SE_NODE_SESSION_TIMEOUT
value: ${SE_NODE_SESSION_TIMEOUT}
resources:
limits:
cpu: ${SELENIUM_CPU_LIMIT}
memory: ${SELENIUM_MEMORY_LIMIT}
requests:
cpu: ${SELENIUM_CPU_REQUEST}
memory: ${SELENIUM_MEMORY_REQUEST}
volumeMounts:
- name: sel-shm
mountPath: /dev/shm
- name: sel-downloads
mountPath: /home/selenium/Downloads
parameters:
- name: IMAGE_TAG
value: ''
required: true
- name: UID
description: "Unique job name suffix"
generate: expression
from: "[a-z0-9]{6}"
- name: IQE_IMAGE
description: "container image path for the iqe plugin"
value: quay.io/cloudservices/iqe-tests:insights-experiences
- name: ENV_FOR_DYNACONF
value: stage_proxy
- name: USE_BETA
value: "true"
- name: IQE_PLUGINS
value: insights_experiences
- name: IQE_MARKER_EXPRESSION
value: 'image_builder'
- name: IQE_FILTER_EXPRESSION
value: ''
- name: IQE_LOG_LEVEL
value: info
- name: IQE_REQUIREMENTS
value: ''
- name: IQE_REQUIREMENTS_PRIORITY
value: ''
- name: IQE_TEST_IMPORTANCE
value: ''
- name: IQE_SEL_IMAGE
value: 'quay.io/redhatqe/selenium-standalone:ff_91.9.1esr_chrome_103.0.5060.114'
- name: IQE_BROWSERLOG
value: "1"
- name: IQE_NETLOG
value: "1"
- name: TEST_TYPE
value: ''
- name: IQE_CPU_LIMIT
value: "1"
- name: IQE_MEMORY_LIMIT
value: 1.5Gi
- name: IQE_CPU_REQUEST
value: 250m
- name: IQE_MEMORY_REQUEST
value: 1Gi
- name: SELENIUM_CPU_LIMIT
value: 500m
- name: SELENIUM_MEMORY_LIMIT
value: 2Gi
- name: SELENIUM_CPU_REQUEST
value: 100m
- name: SELENIUM_MEMORY_REQUEST
value: 1Gi
- name: SELENIUM_JAVA_OPTS
value: ''
- name: VNC_GEOMETRY
value: '1920x1080'
- name: IQE_PARALLEL_ENABLED
value: "false"
- name: SE_NODE_SESSION_TIMEOUT
value: "600"

12
distribution/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:18
WORKDIR /app
COPY . .
RUN npm ci
EXPOSE 8002
EXPOSE 1337
CMD [ "npm", "run", "devel" ]

View file

@ -1,174 +0,0 @@
const js = require('@eslint/js');
const tseslint = require('typescript-eslint');
const pluginReact = require('eslint-plugin-react');
const pluginReactHooks = require('eslint-plugin-react-hooks');
const pluginReactRedux = require('eslint-plugin-react-redux');
const pluginImport = require('eslint-plugin-import');
const fecConfig = require('@redhat-cloud-services/eslint-config-redhat-cloud-services');
const pluginJsxA11y = require('eslint-plugin-jsx-a11y');
const disableAutofix = require('eslint-plugin-disable-autofix');
const pluginPrettier = require('eslint-plugin-prettier');
const jestDom = require('eslint-plugin-jest-dom');
const pluginTestingLibrary = require('eslint-plugin-testing-library');
const pluginPlaywright = require('eslint-plugin-playwright');
const { defineConfig } = require('eslint/config');
const globals = require('globals');
module.exports = defineConfig([
{ // Ignore programatically generated files
ignores: [
'**/mockServiceWorker.js',
'**/imageBuilderApi.ts',
'**/contentSourcesApi.ts',
'**/rhsmApi.ts',
'**/provisioningApi.ts',
'**/complianceApi.ts',
'**/composerCloudApi.ts'
]
},
{ // Base config for js/ts files
files: ['**/*.{js,ts,jsx,tsx}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json'
},
globals: {
...globals.browser,
// node
'JSX': 'readonly',
'process': 'readonly',
'__dirname': 'readonly',
'require': 'readonly',
// vitest
'describe': 'readonly',
'it': 'readonly',
'test': 'readonly',
'expect': 'readonly',
'vi': 'readonly',
'beforeAll': 'readonly',
'beforeEach': 'readonly',
'afterAll': 'readonly',
'afterEach': 'readonly'
},
},
plugins: {
js,
'@typescript-eslint': tseslint.plugin,
react: pluginReact,
'react-hooks': pluginReactHooks,
'react-redux': pluginReactRedux,
import: pluginImport,
jsxA11y: pluginJsxA11y,
'disable-autofix': disableAutofix,
prettier: pluginPrettier,
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommended.rules,
...pluginReact.configs.flat.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
...pluginReactRedux.configs.recommended.rules,
...fecConfig.rules,
'import/order': ['error', {
groups: ['builtin', 'external', 'internal', 'sibling', 'parent', 'index'],
alphabetize: {
order: 'asc',
caseInsensitive: true
},
'newlines-between': 'always',
pathGroups: [ // ensures the import of React is always on top
{
pattern: 'react',
group: 'builtin',
position: 'before'
}
],
pathGroupsExcludedImportTypes: ['react']
}],
'sort-imports': ['error', {
ignoreCase: true,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
}],
'no-duplicate-imports': 'error',
'prefer-const': ['error', {
destructuring: 'any',
}],
'no-console': 'error',
'eqeqeq': 'error',
'array-callback-return': 'warn',
'@typescript-eslint/ban-ts-comment': ['error', {
'ts-expect-error': 'allow-with-description',
'ts-ignore': 'allow-with-description',
'ts-nocheck': true,
'ts-check': true,
minimumDescriptionLength: 5,
}],
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/no-require-imports': 'error',
'disable-autofix/@typescript-eslint/no-unnecessary-condition': 'warn',
'no-unused-vars': 'off', // disable js rule in favor of @typescript-eslint's rule
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'jsx-a11y/no-autofocus': 'off',
'prettier/prettier': ['error', {
semi: true,
tabWidth: 2,
singleQuote: true,
jsxSingleQuote: true,
bracketSpacing: true,
tsxSingleQuote: true,
tsSingleQuote: true,
printWidth: 80,
trailingComma: 'all',
}],
},
settings: {
react: {
version: 'detect', // Automatically detect React version
},
},
},
{ // Override for test files
files: ['src/test/**/*.{ts,tsx}'],
plugins: {
'jest-dom': jestDom,
'testing-library': pluginTestingLibrary,
},
rules: {
...jestDom.configs.recommended.rules,
...pluginTestingLibrary.configs.react.rules,
'react/display-name': 'off',
'react/prop-types': 'off',
'testing-library/no-debugging-utils': 'error'
},
},
{ // Override for Playwright tests
files: ['playwright/**/*.ts'],
plugins: {
playwright: pluginPlaywright,
},
rules: {
...pluginPlaywright.configs.recommended.rules,
'playwright/no-conditional-in-test': 'off',
'playwright/no-conditional-expect': 'off',
'playwright/no-skipped-test': [
'error',
{
'allowConditional': true
}
]
},
},
]);

5134
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,18 +8,17 @@
},
"dependencies": {
"@ltd/j-toml": "1.38.0",
"@patternfly/patternfly": "6.3.1",
"@patternfly/react-code-editor": "6.3.1",
"@patternfly/react-core": "6.3.1",
"@patternfly/react-table": "6.3.1",
"@redhat-cloud-services/frontend-components": "7.0.3",
"@redhat-cloud-services/frontend-components-notifications": "6.1.5",
"@redhat-cloud-services/frontend-components-utilities": "7.0.3",
"@redhat-cloud-services/types": "3.0.1",
"@reduxjs/toolkit": "2.8.2",
"@patternfly/patternfly": "5.4.1",
"@patternfly/react-code-editor": "5.4.1",
"@patternfly/react-core": "5.4.12",
"@patternfly/react-table": "5.4.14",
"@redhat-cloud-services/frontend-components": "5.2.6",
"@redhat-cloud-services/frontend-components-notifications": "4.1.20",
"@redhat-cloud-services/frontend-components-utilities": "5.0.11",
"@reduxjs/toolkit": "2.8.1",
"@scalprum/react-core": "0.9.5",
"@sentry/webpack-plugin": "4.1.1",
"@unleash/proxy-client-react": "5.0.1",
"@sentry/webpack-plugin": "3.4.0",
"@unleash/proxy-client-react": "5.0.0",
"classnames": "2.5.1",
"jwt-decode": "4.0.0",
"lodash": "4.17.21",
@ -31,72 +30,68 @@
"redux-promise-middleware": "6.2.0"
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/preset-env": "7.28.0",
"@babel/core": "7.26.10",
"@babel/preset-env": "7.27.1",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"@currents/playwright": "1.15.3",
"@eslint/js": "9.32.0",
"@patternfly/react-icons": "6.3.1",
"@babel/preset-typescript": "7.27.0",
"@currents/playwright": "1.12.4",
"@patternfly/react-icons": "5.4.2",
"@playwright/test": "1.51.1",
"@redhat-cloud-services/eslint-config-redhat-cloud-services": "3.0.0",
"@redhat-cloud-services/eslint-config-redhat-cloud-services": "2.0.12",
"@redhat-cloud-services/frontend-components-config": "6.3.8",
"@redhat-cloud-services/tsc-transform-imports": "1.0.25",
"@redhat-cloud-services/tsc-transform-imports": "1.0.24",
"@rtk-query/codegen-openapi": "2.0.0",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.3.0",
"@types/node": "22.15.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react-redux": "7.1.34",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.40.0",
"@typescript-eslint/parser": "8.40.0",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "3.2.4",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.1",
"@vitejs/plugin-react": "4.4.1",
"@vitest/coverage-v8": "3.1.2",
"babel-loader": "10.0.0",
"chart.js": "4.5.0",
"chart.js": "4.4.9",
"chartjs-adapter-moment": "1.0.1",
"chartjs-plugin-annotation": "3.1.0",
"copy-webpack-plugin": "13.0.0",
"css-loader": "7.1.2",
"eslint": "9.33.0",
"eslint": "8.57.1",
"eslint-plugin-disable-autofix": "5.0.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.2",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-redux": "4.2.2",
"eslint-plugin-testing-library": "7.6.6",
"eslint-plugin-testing-library": "7.1.1",
"git-revision-webpack-plugin": "5.0.0",
"globals": "16.3.0",
"history": "5.3.0",
"identity-obj-proxy": "3.0.0",
"jsdom": "26.1.0",
"madge": "8.0.0",
"mini-css-extract-plugin": "2.9.2",
"moment": "2.30.1",
"msw": "2.10.5",
"msw": "2.7.5",
"npm-run-all": "4.1.5",
"path-browserify": "1.0.1",
"postcss-scss": "4.0.9",
"react-chartjs-2": "5.3.0",
"redux-mock-store": "1.5.5",
"sass": "1.90.0",
"sass": "1.87.0",
"sass-loader": "16.0.5",
"stylelint": "16.23.1",
"stylelint-config-recommended-scss": "16.0.0",
"stylelint": "16.18.0",
"stylelint-config-recommended-scss": "14.1.0",
"ts-node": "10.9.2",
"ts-patch": "3.3.0",
"typescript": "5.8.3",
"typescript-eslint": "8.40.0",
"uuid": "11.1.0",
"vitest": "3.2.4",
"vitest": "3.1.2",
"vitest-canvas-mock": "0.3.3",
"webpack-bundle-analyzer": "4.10.2",
"whatwg-fetch": "3.6.20"
@ -117,7 +112,9 @@
"test:cockpit": "src/test/cockpit-tests.sh",
"build": "fec build",
"build:cockpit": "webpack --config cockpit/webpack.config.ts",
"api": "bash api/codegen.sh",
"api": "npm-run-all api:pull api:generate",
"api:generate": "bash api/codegen.sh",
"api:pull": "bash api/pull.sh",
"verify": "npm-run-all build lint test",
"postinstall": "ts-patch install",
"circular": "madge --circular ./src --extensions js,ts,tsx",

View file

@ -16,15 +16,6 @@ srpm_build_deps:
- npm
jobs:
- job: tests
identifier: self
trigger: pull_request
tmt_plan: /plans/all/main
targets:
- centos-stream-10
- fedora-41
- fedora-42
- job: copr_build
trigger: pull_request
targets: &build_targets
@ -33,6 +24,7 @@ jobs:
- centos-stream-10
- centos-stream-10-aarch64
- fedora-all
- fedora-all-aarch64
- job: copr_build
trigger: commit

View file

@ -1,14 +0,0 @@
summary: cockpit-image-builder playwright tests
prepare:
how: install
package:
- cockpit-image-builder
discover:
how: fmf
execute:
how: tmt
/main:
summary: playwright tests
discover+:
test: /schutzbot/playwright

View file

@ -34,14 +34,9 @@ export default defineConfig({
ignoreHTTPSErrors: true,
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'] },
},
],
});

View file

@ -1,214 +0,0 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
const validCallbackUrl =
'https://controller.url/api/controller/v2/job_templates/9/callback/';
const validHttpCallbackUrl =
'http://controller.url/api/controller/v2/job_templates/9/callback/';
const validHostConfigKey = 'hostconfigkey';
const validCertificate = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOEzx5ezZ9EIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAklOMQswCQYDVQQIDAJLUjEMMAoGA1UEBwwDS1JHMRAwDgYDVQQKDAdUZXN0
IENBMB4XDTI1MDUxNTEyMDAwMFoXDTI2MDUxNTEyMDAwMFowRTELMAkGA1UEBhMC
SU4xCzAJBgNVBAgMAktSMQwwCgYDVQQHDANSR0sxEDAOBgNVBAoMB1Rlc3QgQ0Ew
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+R4gfN5pyJQo5qBTTtN+7
eE9CSXZJ8SVVaE3U54IgqQoqsSoBY5QtExy7v5C6l6mW4E6dzK/JecmvTTO/BvlG
A5k2hxB6bOQxtxYwfgElH+RFWN9P4xxhtEiQgHoG1rDfnXuDJk1U3YEkCQELUebz
fF3EIDU1yR0Sz2bA+Sl2VXe8og1MEZfytq8VZUVltxtn2PfW7zI5gOllBR2sKeUc
K6h8HXN7qMgfEvsLIXxTw7fU/zA3ibcxfRCl3m6QhF8hwRh6F9Wtz2s8hCzGegV5
z0M39nY7X8C3GZQ4Ly8v8DdY+FbEix7K3SSBRbWtdPfAHRFlX9Er2Wf8DAr7O2hH
AgMBAAGjUDBOMB0GA1UdDgQWBBTXXz2eIDgK+BhzDUAGzptn0OMcpDAfBgNVHSME
GDAWgBTXXz2eIDgK+BhzDUAGzptn0OMcpDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQAoUgY4jsuBMB3el9cc7JS2rcOhhJzn47Hj2UANfJq52g5lbjo7
XDc7Wb3VDcV+1LzjdzayT1qO1WzHb6FDPW9L9f6h4s8lj6MvJ+xhOWgD11srdIt3
vbQaQW4zDfeVRcKXzqbcUX8BLXAdzJPqVwZ+Z4EDjYrJ7lF9k+IqfZm0MsYX7el9
kvdRHbLuF4Q0sZ05CXMFkhM0Ulhu4MZ+1FcsQa7nWfZzTmbjHOuWJPB4z5WwrB7z
U8YYvWJ3qxToWGbATqJxkRKGGqLrNrmwcfzgPqkpuCRYi0Kky6gJ1RvL+DRopY9x
uD+ckf3oH2wYAB6RpPRMkfVxe7lGMvq/yEZ6
-----END CERTIFICATE-----`;
const invalidCertificate = `-----BEGIN CERTIFICATE-----
ThisIs*Not+Valid/Base64==
-----END CERTIFICATE-----`;
test('Create a blueprint with AAP registration customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Skip entirely in Cockpit/on-premise where AAP customization is unavailable
test.skip(!isHosted(), 'AAP customization is not available in the plugin');
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Select and fill the AAP step with valid configuration', async () => {
await frame
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
await frame
.getByRole('textbox', { name: 'host config key' })
.fill(validHostConfigKey);
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByRole('button', { name: 'Next' })).toBeEnabled();
});
await test.step('Test TLS confirmation checkbox for HTTPS URLs', async () => {
// TLS confirmation checkbox should appear for HTTPS URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeVisible();
// Check TLS confirmation and verify CA input is hidden
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.check();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeHidden();
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.uncheck();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
});
await test.step('Test certificate validation', async () => {
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(invalidCertificate);
await expect(frame.getByText(/Certificate.*is not valid/)).toBeVisible();
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByText('Certificate was uploaded')).toBeVisible();
});
await test.step('Test HTTP URL behavior', async () => {
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validHttpCallbackUrl);
// TLS confirmation checkbox should NOT appear for HTTP URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeHidden();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
});
await test.step('Complete AAP configuration and proceed to review', async () => {
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP and verify AAP configuration persists', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Ansible Automation Platform step').click();
await expect(
frame.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
frame.getByRole('textbox', { name: 'host config key' }),
).toHaveValue(validHostConfigKey);
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(page);
await page
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await expect(
page.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
page.getByRole('textbox', { name: 'host config key' }),
).toBeEmpty();
await expect(
page.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -1,230 +0,0 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { FILE_SYSTEM_CUSTOMIZATION_URL } from '../../src/constants';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Filesystem customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Login, navigate to IB and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Check URLs for documentation', async () => {
await frame
.getByRole('button', { name: 'File system configuration' })
.click();
await frame
.getByRole('radio', { name: 'Use automatic partitioning' })
.click();
const [newPageAutomatic] = await Promise.all([
page.context().waitForEvent('page'),
frame
.getByRole('link', {
name: 'Customizing file systems during the image creation',
})
.click(),
]);
await newPageAutomatic.waitForLoadState();
const finalUrlAutomatic = newPageAutomatic.url();
expect(finalUrlAutomatic).toContain(FILE_SYSTEM_CUSTOMIZATION_URL);
await newPageAutomatic.close();
await frame
.getByRole('radio', { name: 'Manually configure partitions' })
.click();
const [newPageManual] = await Promise.all([
page.context().waitForEvent('page'),
frame
.getByRole('link', {
name: 'Read more about manual configuration here',
})
.click(),
]);
await newPageManual.waitForLoadState();
const finalUrlManual = newPageManual.url();
expect(finalUrlManual).toContain(FILE_SYSTEM_CUSTOMIZATION_URL);
await newPageManual.close();
});
await test.step('Fill manually selected partitions', async () => {
await expect(frame.getByRole('button', { name: '/' })).toBeDisabled();
const closeRootButton = frame
.getByRole('row', {
name: 'Draggable row draggable button / xfs 10 GiB',
})
.getByRole('button')
.nth(3);
await expect(closeRootButton).toBeDisabled();
await frame.getByRole('button', { name: 'Add partition' }).click();
await frame.getByRole('button', { name: '/home' }).click();
await frame.getByRole('option', { name: '/tmp' }).click();
await frame
.getByRole('textbox', { name: 'mountpoint suffix' })
.fill('/usb');
await frame
.getByRole('gridcell', { name: '1', exact: true })
.getByPlaceholder('File system')
.fill('1000');
await frame.getByRole('button', { name: 'GiB' }).nth(1).click();
await frame.getByRole('option', { name: 'KiB' }).click();
const closeTmpButton = frame
.getByRole('row', {
name: 'Draggable row draggable button /tmp /usb xfs 1000 KiB',
})
.getByRole('button')
.nth(3);
await expect(closeTmpButton).toBeEnabled();
});
await test.step('Fill the BP details', async () => {
await frame.getByRole('button', { name: 'Review and finish' }).click();
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit File system configuration step').click();
const closeRootButton = frame
.getByRole('row', {
name: 'Draggable row draggable button / xfs 10 GiB',
})
.getByRole('button')
.nth(3);
await expect(closeRootButton).toBeDisabled();
const closeTmpButton = frame
.getByRole('row', {
name: 'Draggable row draggable button /tmp /usb xfs 1000 KiB',
})
.getByRole('button')
.nth(3);
await expect(closeTmpButton).toBeEnabled();
const usbTextbox = frame.getByRole('textbox', {
name: 'mountpoint suffix',
});
await expect(usbTextbox).toHaveValue('/usb');
await frame
.getByRole('gridcell', { name: '1000', exact: true })
.getByPlaceholder('File system')
.click();
await frame
.getByRole('gridcell', { name: '1000', exact: true })
.getByPlaceholder('File system')
.fill('1024');
await frame.getByRole('button', { name: '/tmp' }).click();
await frame.getByRole('option', { name: '/usr' }).click();
await expect(
frame.getByText(
'Sub-directories for the /usr mount point are no longer supported',
),
).toBeVisible();
await frame.getByRole('button', { name: '/usr' }).click();
await frame.getByRole('option', { name: '/srv' }).click();
await frame
.getByRole('textbox', { name: 'mountpoint suffix' })
.fill('/data');
await frame.getByRole('button', { name: 'KiB' }).click();
await frame.getByRole('option', { name: 'MiB' }).click();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(page);
await frame
.getByRole('button', { name: 'File system configuration' })
.click();
const closeRootButton = frame
.getByRole('row', {
name: 'Draggable row draggable button / xfs 10 GiB',
})
.getByRole('button')
.nth(3);
await expect(closeRootButton).toBeDisabled();
const closeTmpButton = frame
.getByRole('row', {
name: 'Draggable row draggable button /srv /data xfs 1 GiB',
})
.getByRole('button')
.nth(3);
await expect(closeTmpButton).toBeEnabled();
const dataTextbox = frame.getByRole('textbox', {
name: 'mountpoint suffix',
});
await expect(dataTextbox).toHaveValue('/data');
const size = frame
.getByRole('gridcell', { name: '1', exact: true })
.getByPlaceholder('File system');
await expect(size).toHaveValue('1');
const unitButton = frame.getByRole('button', { name: 'GiB' }).nth(1);
await expect(unitButton).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -1,22 +1,18 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import { login } from '../helpers/login';
import { navigateToOptionalSteps, ibFrame } from '../helpers/navHelpers';
import {
registerLater,
fillInDetails,
createBlueprint,
fillInImageOutputGuest,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Firewall customization', async ({
@ -28,10 +24,8 @@ test('Create a blueprint with Firewall customization', async ({
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
// Login, navigate to IB and get the frame
await login(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
@ -63,29 +57,19 @@ test('Create a blueprint with Firewall customization', async ({
await test.step('Select and incorrectly fill the ports in Firewall step', async () => {
await frame.getByPlaceholder('Add ports').fill('x');
await frame.getByRole('button', { name: 'Add ports' }).click();
await expect(
frame
.getByText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp',
)
.nth(0),
).toBeVisible();
await expect(frame.getByText('Invalid format.').nth(0)).toBeVisible();
});
await test.step('Select and incorrectly fill the disabled services in Firewall step', async () => {
await frame.getByPlaceholder('Add disabled service').fill('1');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(0),
).toBeVisible();
await expect(frame.getByText('Invalid format.').nth(1)).toBeVisible();
});
await test.step('Select and incorrectly fill the enabled services in Firewall step', async () => {
await frame.getByPlaceholder('Add enabled service').fill('ťčš');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(1),
).toBeVisible();
await expect(frame.getByText('Invalid format.').nth(2)).toBeVisible();
});
await test.step('Fill the BP details', async () => {

View file

@ -1,22 +1,18 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import { login } from '../helpers/login';
import { navigateToOptionalSteps, ibFrame } from '../helpers/navHelpers';
import {
registerLater,
fillInDetails,
createBlueprint,
fillInImageOutputGuest,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Hostname customization', async ({
@ -29,10 +25,8 @@ test('Create a blueprint with Hostname customization', async ({
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
// Login, navigate to IB and get the frame
await login(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
@ -83,7 +77,7 @@ test('Create a blueprint with Hostname customization', async ({
await fillInImageOutputGuest(page);
await page.getByRole('button', { name: 'Hostname' }).click();
await expect(
page.getByRole('textbox', { name: 'hostname input' }),
page.getByRole('textbox', { name: 'hostname input' })
).toHaveValue(hostname + 'edited');
await page.getByRole('button', { name: 'Cancel' }).click();
});

View file

@ -1,133 +0,0 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Kernel customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Select and fill the Kernel step', async () => {
await frame.getByRole('button', { name: 'Kernel' }).click();
await frame.getByRole('button', { name: 'Menu toggle' }).click();
await frame.getByRole('option', { name: 'kernel', exact: true }).click();
await frame.getByPlaceholder('Add kernel argument').fill('rootwait');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await frame
.getByPlaceholder('Add kernel argument')
.fill('invalid$argument');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await expect(
frame.getByText(
'Expected format: <kernel-argument>. Example: console=tty0',
),
).toBeVisible();
await frame.getByPlaceholder('Select kernel package').fill('new-package');
await frame
.getByRole('option', { name: 'Custom kernel package "new-' })
.click();
await expect(
frame.getByRole('heading', { name: 'Warning alert: Custom kernel' }),
).toBeVisible();
await frame.getByRole('button', { name: 'Clear input' }).first().click();
await frame.getByRole('button', { name: 'Menu toggle' }).click();
await expect(
frame.getByRole('option', { name: 'new-package' }),
).toBeVisible();
await frame.getByPlaceholder('Select kernel package').fill('f');
await expect(
frame.getByRole('option', {
name: '"f" is not a valid kernel package name',
}),
).toBeVisible();
await frame.getByPlaceholder('Add kernel argument').fill('console=tty0');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await frame.getByPlaceholder('Add kernel argument').fill('xxnosmp');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await frame
.getByPlaceholder('Add kernel argument')
.fill('console=ttyS0,115200n8');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Kernel step').click();
await frame.getByRole('button', { name: 'Menu toggle' }).click();
await frame.getByRole('option', { name: 'kernel', exact: true }).click();
await frame.getByPlaceholder('Add kernel argument').fill('new=argument');
await frame.getByRole('button', { name: 'Add kernel argument' }).click();
await frame.getByRole('button', { name: 'Close xxnosmp' }).click();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(frame);
await frame.getByRole('button', { name: 'Kernel' }).click();
await expect(frame.getByPlaceholder('Select kernel package')).toHaveValue(
'kernel',
);
await expect(frame.getByText('rootwait')).toBeVisible();
await expect(frame.getByText('console=tty0')).toBeVisible();
await expect(frame.getByText('console=ttyS0,115200n8')).toBeVisible();
await expect(frame.getByText('new=argument')).toBeVisible();
await expect(frame.getByText('xxnosmp')).toBeHidden();
await frame.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -1,22 +1,18 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import { login } from '../helpers/login';
import { navigateToOptionalSteps, ibFrame } from '../helpers/navHelpers';
import {
registerLater,
fillInDetails,
createBlueprint,
fillInImageOutputGuest,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Locale customization', async ({
@ -28,10 +24,8 @@ test('Create a blueprint with Locale customization', async ({
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
// Login, navigate to IB and get the frame
await login(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
@ -42,45 +36,27 @@ test('Create a blueprint with Locale customization', async ({
await test.step('Select and fill the Locale step', async () => {
await frame.getByRole('button', { name: 'Locale' }).click();
await frame.getByPlaceholder('Select a language').fill('fy');
await frame
.getByRole('option', { name: 'Western Frisian - Germany (fy_DE.UTF-8)' })
.click();
await frame.getByRole('option', { name: 'fy_DE.UTF-' }).click();
await expect(
frame.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
}),
frame.getByRole('button', { name: 'Close fy_DE.UTF-' })
).toBeEnabled();
await frame
.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
})
.click();
await frame.getByRole('button', { name: 'Close fy_DE.UTF-' }).click();
await expect(
frame.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
}),
frame.getByRole('button', { name: 'Close fy_DE.UTF-' })
).toBeHidden();
await frame.getByPlaceholder('Select a language').fill('fy');
await frame
.getByRole('option', { name: 'Western Frisian - Germany (fy_DE.UTF-8)' })
.click();
await frame.getByRole('option', { name: 'fy_DE.UTF-' }).click();
await expect(
frame.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
}),
frame.getByRole('button', { name: 'Close fy_DE.UTF-' })
).toBeEnabled();
await frame.getByPlaceholder('Select a language').fill('aa');
await frame
.getByRole('option', { name: 'aa - Djibouti (aa_DJ.UTF-8)' })
.click();
await frame.getByRole('option', { name: 'aa_DJ.UTF-' }).click();
await expect(
frame.getByRole('button', { name: 'Close aa - Djibouti (aa_DJ.UTF-8)' }),
frame.getByRole('button', { name: 'Close aa_DJ.UTF-' })
).toBeEnabled();
await frame.getByPlaceholder('Select a language').fill('aa');
await expect(
frame.getByText(
'aa - Djibouti (aa_DJ.UTF-8)Language already addedaa - Eritrea (aa_ER.UTF-8)aa - Ethiopia (aa_ET.UTF-8)',
),
frame.getByText('aa_DJ.UTF-8Language already addedaa_ER.UTF-8aa_ET.UTF-')
).toBeAttached();
await frame.getByPlaceholder('Select a language').fill('xxx');
await expect(frame.getByText('No results found for')).toBeAttached();
@ -102,19 +78,15 @@ test('Create a blueprint with Locale customization', async ({
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Locale step').click();
await expect(
frame.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
}),
frame.getByRole('button', { name: 'Close fy_DE.UTF-' })
).toBeEnabled();
await expect(
frame.getByRole('button', { name: 'Close aa - Djibouti (aa_DJ.UTF-8)' }),
frame.getByRole('button', { name: 'Close aa_DJ.UTF-' })
).toBeEnabled();
await frame.getByPlaceholder('Select a language').fill('aa');
await frame
.getByRole('option', { name: 'aa - Eritrea (aa_ER.UTF-8)' })
.click();
await frame.getByRole('option', { name: 'aa_ER.UTF-' }).click();
await expect(
frame.getByRole('button', { name: 'Close aa - Eritrea (aa_ER.UTF-8)' }),
frame.getByRole('button', { name: 'Close aa_ER.UTF-' })
).toBeEnabled();
await frame.getByRole('button', { name: 'Clear input' }).click();
await frame.getByRole('button', { name: 'Menu toggle' }).nth(1).click();
@ -141,18 +113,16 @@ test('Create a blueprint with Locale customization', async ({
await fillInImageOutputGuest(page);
await page.getByRole('button', { name: 'Locale' }).click();
await expect(
frame.getByRole('button', {
name: 'Close Western Frisian - Germany (fy_DE.UTF-8)',
}),
frame.getByRole('button', { name: 'Close fy_DE.UTF-' })
).toBeEnabled();
await expect(
frame.getByRole('button', { name: 'Close aa - Djibouti (aa_DJ.UTF-8)' }),
frame.getByRole('button', { name: 'Close aa_DJ.UTF-' })
).toBeEnabled();
await expect(
frame.getByRole('button', { name: 'Close aa - Eritrea (aa_ER.UTF-8)' }),
frame.getByRole('button', { name: 'Close aa_ER.UTF-' })
).toBeEnabled();
await expect(frame.getByPlaceholder('Select a keyboard')).toHaveValue(
'ANSI-dvorak',
'ANSI-dvorak'
);
await page.getByRole('button', { name: 'Cancel' }).click();
});

View file

@ -1,189 +0,0 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import { ibFrame, navigateToLandingPage } from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with OpenSCAP customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
test.skip(!isHosted(), 'Exporting is not available in the plugin');
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Select RHEL 9 and go to optional steps in Wizard', async () => {
await frame.getByRole('button', { name: 'Create image blueprint' }).click();
await frame.getByTestId('release_select').click();
await frame
.getByRole('option', {
name: 'Red Hat Enterprise Linux (RHEL) 9 Full support ends: May 2027 | Maintenance',
})
.click();
await frame.getByRole('checkbox', { name: 'Virtualization' }).click();
await frame.getByRole('button', { name: 'Next' }).click();
await registerLater(frame);
});
await test.step('Select only OpenSCAP, and check if dependencies are preselected', async () => {
await frame.getByRole('button', { name: 'Compliance' }).click();
await frame.getByRole('textbox', { name: 'Type to filter' }).fill('cis');
await frame
.getByRole('option', {
name: 'CIS Red Hat Enterprise Linux 9 Benchmark for Level 1 - Server This profile',
})
.click();
await frame
.getByRole('button', { name: 'File system configuration' })
.click();
await expect(
frame
.getByRole('row', {
name: 'Draggable row draggable button /tmp xfs 1 GiB',
})
.getByRole('button')
.nth(3),
).toBeVisible();
await frame.getByRole('button', { name: 'Additional packages' }).click();
await frame.getByRole('button', { name: 'Selected (8)' }).click();
await expect(frame.getByRole('gridcell', { name: 'aide' })).toBeVisible();
await expect(frame.getByRole('gridcell', { name: 'chrony' })).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'firewalld' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'libpwquality' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'libselinux' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'nftables' }),
).toBeVisible();
await expect(frame.getByRole('gridcell', { name: 'sudo' })).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'systemd-journal-remote' }),
).toBeVisible();
await frame.getByRole('button', { name: 'Systemd services' }).click();
await expect(
frame.getByText('Required by OpenSCAPcrondfirewalldsystemd-journald'),
).toBeVisible();
await frame.getByPlaceholder('Add masked service').fill('nftables');
await frame.getByPlaceholder('Add masked service').press('Enter');
await expect(
frame.getByText('Masked service already exists'),
).toBeVisible();
await expect(frame.getByText('Required by OpenSCAPcupsnfs-')).toBeVisible();
await expect(frame.getByText('nfs-server')).toBeVisible();
await expect(frame.getByText('rpcbind')).toBeVisible();
await expect(frame.getByText('avahi-daemon')).toBeVisible();
await expect(frame.getByText('autofs')).toBeVisible();
await expect(frame.getByText('bluetooth')).toBeVisible();
await expect(frame.getByText('nftables')).toBeVisible();
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByRole('button', { name: 'Compliance' }).click();
await expect(frame.getByText('Level 1 - Server')).toBeVisible();
await frame.getByRole('textbox', { name: 'Type to filter' }).fill('cis');
await frame
.getByRole('option', {
name: 'CIS Red Hat Enterprise Linux 9 Benchmark for Level 2 - Server This profile',
})
.click();
await frame.getByRole('button', { name: 'Kernel' }).click();
await expect(
frame.getByText('Required by OpenSCAPaudit_backlog_limit=8192audit='),
).toBeVisible();
await frame.getByRole('button', { name: 'Additional packages' }).click();
await frame.getByRole('button', { name: 'Selected (10)' }).click();
await expect(frame.getByRole('gridcell', { name: 'aide' })).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'audit-libs' }),
).toBeVisible();
await expect(frame.getByRole('gridcell', { name: 'chrony' })).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'firewalld' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'libpwquality' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'libselinux' }),
).toBeVisible();
await expect(
frame.getByRole('gridcell', { name: 'nftables' }),
).toBeVisible();
await expect(frame.getByRole('gridcell', { name: 'sudo' })).toBeVisible();
await frame.getByRole('button', { name: 'Systemd services' }).click();
await expect(
frame.getByText(
'Required by OpenSCAPauditdcrondfirewalldsystemd-journald',
),
).toBeVisible();
await frame.getByPlaceholder('Add masked service').fill('nftables');
await frame.getByPlaceholder('Add masked service').press('Enter');
await expect(
frame.getByText('Masked service already exists'),
).toBeVisible();
await expect(frame.getByText('Required by OpenSCAPcupsnfs-')).toBeVisible();
await expect(frame.getByText('nfs-server')).toBeVisible();
await expect(frame.getByText('rpcbind')).toBeVisible();
await expect(frame.getByText('avahi-daemon')).toBeVisible();
await expect(frame.getByText('autofs')).toBeVisible();
await expect(frame.getByText('bluetooth')).toBeVisible();
await expect(frame.getByText('nftables')).toBeVisible();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async () => {
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async () => {
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async () => {
await fillInImageOutputGuest(page);
await page.getByRole('button', { name: 'Compliance' }).click();
await expect(frame.getByText('Level 2 - Server')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -1,156 +0,0 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Systemd customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Select and correctly fill all of the service fields', async () => {
await frame.getByRole('button', { name: 'Systemd services' }).click();
await frame
.getByPlaceholder('Add disabled service')
.fill('systemd-dis.service');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await expect(frame.getByText('systemd-dis.service')).toBeVisible();
await frame
.getByPlaceholder('Add enabled service')
.fill('systemd-en.service');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await expect(frame.getByText('systemd-en.service')).toBeVisible();
await frame
.getByPlaceholder('Add masked service')
.fill('systemd-m.service');
await frame.getByRole('button', { name: 'Add masked service' }).click();
await expect(frame.getByText('systemd-m.service')).toBeVisible();
});
await test.step('Select and incorrectly fill all of the service fields', async () => {
await frame.getByPlaceholder('Add disabled service').fill('&&');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(0),
).toBeVisible();
await frame.getByPlaceholder('Add enabled service').fill('áá');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(1),
).toBeVisible();
await frame.getByPlaceholder('Add masked service').fill('78');
await frame.getByRole('button', { name: 'Add masked service' }).click();
await expect(
frame.getByText('Expected format: <service-name>. Example: sshd').nth(2),
).toBeVisible();
});
await test.step('Fill the BP details', async () => {
await frame.getByRole('button', { name: 'Review and finish' }).click();
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Systemd services step').click();
await frame
.getByPlaceholder('Add disabled service')
.fill('disabled-service');
await frame.getByRole('button', { name: 'Add disabled service' }).click();
await frame.getByPlaceholder('Add enabled service').fill('enabled-service');
await frame.getByRole('button', { name: 'Add enabled service' }).click();
await frame.getByPlaceholder('Add masked service').fill('masked-service');
await frame.getByRole('button', { name: 'Add masked service' }).click();
await frame
.getByRole('button', { name: 'Close systemd-m.service' })
.click();
await frame
.getByRole('button', { name: 'Close systemd-en.service' })
.click();
await frame
.getByRole('button', { name: 'Close systemd-dis.service' })
.click();
await expect(frame.getByText('enabled-service')).toBeVisible();
await expect(frame.getByText('disabled-service')).toBeVisible();
await expect(frame.getByText('masked-service')).toBeVisible();
await expect(frame.getByText('systemd-en.service')).toBeHidden();
await expect(frame.getByText('systemd-dis.service')).toBeHidden();
await expect(frame.getByText('systemd-m.service')).toBeHidden();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(page);
await page.getByRole('button', { name: 'Systemd services' }).click();
await expect(frame.getByText('enabled-service')).toBeVisible();
await expect(frame.getByText('disabled-service')).toBeVisible();
await expect(frame.getByText('masked-service')).toBeVisible();
await expect(frame.getByText('systemd-en.service')).toBeHidden();
await expect(frame.getByText('systemd-dis.service')).toBeHidden();
await expect(frame.getByText('systemd-m.service')).toBeHidden();
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -1,22 +1,18 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { test } from '../fixtures/cleanup';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import { login } from '../helpers/login';
import { navigateToOptionalSteps, ibFrame } from '../helpers/navHelpers';
import {
registerLater,
fillInDetails,
createBlueprint,
fillInImageOutputGuest,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
test('Create a blueprint with Timezone customization', async ({
@ -28,10 +24,8 @@ test('Create a blueprint with Timezone customization', async ({
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
// Login, navigate to IB and get the frame
await login(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
@ -55,11 +49,7 @@ test('Create a blueprint with Timezone customization', async ({
await expect(frame.getByText('NTP server already exists.')).toBeVisible();
await frame.getByPlaceholder('Add NTP servers').fill('xxxx');
await frame.getByRole('button', { name: 'Add NTP server' }).click();
await expect(
frame
.getByText('Expected format: <ntp-server>. Example: time.redhat.com')
.nth(0),
).toBeVisible();
await expect(frame.getByText('Invalid format.')).toBeVisible();
await frame.getByPlaceholder('Add NTP servers').fill('0.cz.pool.ntp.org');
await frame.getByRole('button', { name: 'Add NTP server' }).click();
await expect(frame.getByText('0.cz.pool.ntp.org')).toBeVisible();
@ -86,12 +76,12 @@ test('Create a blueprint with Timezone customization', async ({
await frame.getByLabel('Revisit Timezone step').click();
await expect(frame.getByText('Canada/Saskatchewan')).toBeHidden();
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
'Europe/Stockholm',
'Europe/Stockholm'
);
await frame.getByPlaceholder('Select a timezone').fill('Europe');
await frame.getByRole('option', { name: 'Europe/Oslo' }).click();
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
'Europe/Oslo',
'Europe/Oslo'
);
await expect(frame.getByText('0.nl.pool.ntp.org')).toBeVisible();
await expect(frame.getByText('0.de.pool.ntp.org')).toBeVisible();
@ -118,7 +108,7 @@ test('Create a blueprint with Timezone customization', async ({
await fillInImageOutputGuest(page);
await frame.getByRole('button', { name: 'Timezone' }).click();
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
'Europe/Oslo',
'Europe/Oslo'
);
await expect(frame.getByText('0.nl.pool.ntp.org')).toBeVisible();
await expect(frame.getByText('0.de.pool.ntp.org')).toBeVisible();

View file

@ -11,6 +11,7 @@ export interface Cleanup {
}
export const test = oldTest.extend<WithCleanup>({
// eslint-disable-next-line no-empty-pattern
cleanup: async ({}, use) => {
const cleanupFns: Map<symbol, () => Promise<unknown>> = new Map();
@ -38,7 +39,7 @@ export const test = oldTest.extend<WithCleanup>({
async () => {
await Promise.all(Array.from(cleanupFns).map(([, fn]) => fn()));
},
{ box: true },
{ box: true }
);
},
});

View file

@ -1,8 +0,0 @@
// This is a common fixture for the customizations tests
import { mergeTests } from '@playwright/test';
import { test as cleanupTest } from './cleanup';
import { test as popupTest } from './popupHandler';
// Combine the fixtures into one
export const test = mergeTests(cleanupTest, popupTest);

View file

@ -1,18 +0,0 @@
import { test as base } from '@playwright/test';
import { closePopupsIfExist } from '../helpers/helpers';
export interface PopupHandlerFixture {
popupHandler: void;
}
// This fixture will close any popups that might get opened during the test execution
export const test = base.extend<PopupHandlerFixture>({
popupHandler: [
async ({ page }, use) => {
await closePopupsIfExist(page);
await use(undefined);
},
{ auto: true },
],
});

View file

@ -1,12 +0,0 @@
import { test as setup } from '@playwright/test';
import { login, storeStorageStateAndToken } from './helpers/login';
setup.describe('Setup', () => {
setup.describe.configure({ retries: 3 });
setup('Authenticate', async ({ page }) => {
await login(page);
await storeStorageStateAndToken(page);
});
});

View file

@ -1,7 +1,4 @@
import { execSync } from 'child_process';
import { readFileSync } from 'node:fs';
import { expect, type Page } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
export const togglePreview = async (page: Page) => {
const toggleSwitch = page.locator('#preview-toggle');
@ -24,64 +21,15 @@ export const isHosted = (): boolean => {
export const closePopupsIfExist = async (page: Page) => {
const locatorsToCheck = [
page.locator('.pf-v6-c-alert.notification-item button'), // This closes all toast pop-ups
page.locator('.pf-v5-c-alert.notification-item button'), // This closes all toast pop-ups
page.locator(`button[id^="pendo-close-guide-"]`), // This closes the pendo guide pop-up
page.locator(`button[id="truste-consent-button"]`), // This closes the trusted consent pop-up
page.getByLabel('close-notification'), // This closes a one off info notification (May be covered by the toast above, needs recheck.)
page
.locator('iframe[name="intercom-modal-frame"]')
.contentFrame()
.getByRole('button', { name: 'Close' }), // This closes the intercom pop-up
page
.locator('iframe[name="intercom-notifications-frame"]')
.contentFrame()
.getByRole('button', { name: 'Profile image for Rob Rob' })
.last(), // This closes the intercom pop-up notification at the bottom of the screen, the last notification is displayed first if stacked (different from the modal popup handled above)
];
for (const locator of locatorsToCheck) {
await page.addLocatorHandler(locator, async () => {
await locator.first().click({ timeout: 10_000, noWaitAfter: true }); // There can be multiple toast pop-ups
await locator.first().click(); // There can be multiple toast pop-ups
});
}
};
// copied over from constants
const ON_PREM_RELEASES = new Map([
['centos-10', 'CentOS Stream 10'],
['fedora-41', 'Fedora Linux 41'],
['fedora-42', 'Fedora Linux 42'],
['rhel-10', 'Red Hat Enterprise Linux (RHEL) 10'],
]);
/* eslint-disable @typescript-eslint/no-explicit-any */
export const getHostDistroName = (): string => {
const osRelData = readFileSync('/etc/os-release');
const lines = osRelData
.toString('utf-8')
.split('\n')
.filter((l) => l !== '');
const osRel = {};
for (const l of lines) {
const lineData = l.split('=');
(osRel as any)[lineData[0]] = lineData[1].replace(/"/g, '');
}
// strip minor version from rhel
const distro = ON_PREM_RELEASES.get(
`${(osRel as any)['ID']}-${(osRel as any)['VERSION_ID'].split('.')[0]}`,
);
if (distro === undefined) {
/* eslint-disable no-console */
console.error('getHostDistroName failed, os-release config:', osRel);
throw new Error('getHostDistroName failed, distro undefined');
}
return distro;
};
export const getHostArch = (): string => {
return execSync('uname -m').toString('utf-8').replace(/\s/g, '');
};

View file

@ -1,6 +1,4 @@
import path from 'path';
import { expect, type Page } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import { closePopupsIfExist, isHosted, togglePreview } from './helpers';
import { ibFrame } from './navHelpers';
@ -23,38 +21,6 @@ export const login = async (page: Page) => {
return loginCockpit(page, user, password);
};
/**
* Checks if the user is already authenticated, if not, logs them in
* @param page - the page object
*/
export const ensureAuthenticated = async (page: Page) => {
// Navigate to the target page
if (isHosted()) {
await page.goto('/insights/image-builder/landing');
} else {
await page.goto('/cockpit-image-builder');
}
// Check for authentication success indicator
const successIndicator = isHosted()
? page.getByRole('heading', { name: 'All images' })
: ibFrame(page).getByRole('heading', { name: 'All images' });
let isAuthenticated = false;
try {
// Give it a 30 second period to load, it's less expensive than having to rerun the test
await expect(successIndicator).toBeVisible({ timeout: 30000 });
isAuthenticated = true;
} catch {
isAuthenticated = false;
}
if (!isAuthenticated) {
// Not authenticated, need to login
await login(page);
}
};
const loginCockpit = async (page: Page, user: string, password: string) => {
await page.goto('/cockpit-image-builder');
@ -65,74 +31,46 @@ const loginCockpit = async (page: Page, user: string, password: string) => {
// image-builder lives inside an iframe
const frame = ibFrame(page);
try {
// Check if the user already has administrative access
await expect(
page.getByRole('button', { name: 'Administrative access' }),
).toBeVisible();
} catch {
// If not, try to gain it
// cockpit-image-builder needs superuser, expect an error message
// when the user does not have admin priviliges
await expect(
frame.getByRole('heading', { name: 'Access is limited' }),
).toBeVisible();
await page.getByRole('button', { name: 'Limited access' }).click();
// cockpit-image-builder needs superuser, expect an error message
// when the user does not have admin priviliges
await expect(
frame.getByRole('heading', { name: 'Access is limited' })
).toBeVisible();
await page.getByRole('button', { name: 'Limited access' }).click();
// different popup opens based on type of account (can be passwordless)
const authenticateButton = page.getByRole('button', {
name: 'Authenticate',
});
const closeButton = page.getByText('Close');
await expect(authenticateButton.or(closeButton)).toBeVisible();
// different popup opens based on type of account (can be passwordless)
const authenticateButton = page.getByRole('button', { name: 'Authenticate' });
const closeButton = page.getByText('Close');
await expect(authenticateButton.or(closeButton)).toBeVisible();
if (await authenticateButton.isVisible()) {
// with password
await page.getByRole('textbox', { name: 'Password' }).fill(password);
await authenticateButton.click();
}
if (await closeButton.isVisible()) {
// passwordless
await closeButton.click();
}
if (await authenticateButton.isVisible()) {
// with password
await page.getByRole('textbox', { name: 'Password' }).fill(password);
await authenticateButton.click();
}
if (await closeButton.isVisible()) {
// passwordless
await closeButton.click();
}
// expect to have administrative access
await expect(
page.getByRole('button', { name: 'Administrative access' }),
page.getByRole('button', { name: 'Administrative access' })
).toBeVisible();
await expect(
frame.getByRole('heading', { name: 'All images' }),
frame.getByRole('heading', { name: 'All images' })
).toBeVisible();
};
const loginConsole = async (page: Page, user: string, password: string) => {
await closePopupsIfExist(page);
await page.goto('/insights/image-builder/landing');
await page.getByRole('textbox', { name: 'Red Hat login' }).fill(user);
await page
.getByRole('textbox', { name: 'Red Hat login or email' })
.fill(user);
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('textbox', { name: 'Password' }).fill(password);
await page.getByRole('button', { name: 'Log in' }).click();
await togglePreview(page);
await expect(page.getByRole('heading', { name: 'All images' })).toBeVisible();
};
export const storeStorageStateAndToken = async (page: Page) => {
const { cookies } = await page
.context()
.storageState({ path: path.join(__dirname, '../../.auth/user.json') });
if (isHosted()) {
// For hosted service, look for cs_jwt token
process.env.TOKEN = `Bearer ${
cookies.find((cookie) => cookie.name === 'cs_jwt')?.value
}`;
} else {
// For Cockpit, we don't need a TOKEN but we can still store it for consistency
const cockpitCookie = cookies.find((cookie) => cookie.name === 'cockpit');
if (cockpitCookie) {
process.env.TOKEN = cockpitCookie.value;
}
}
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(100);
};

View file

@ -1,20 +1,13 @@
import { expect, FrameLocator, Page } from '@playwright/test';
import type { FrameLocator, Page } from '@playwright/test';
import { getHostArch, getHostDistroName, isHosted } from './helpers';
import { isHosted } from './helpers';
/**
* Opens the wizard, fills out the "Image Output" step, and navigates to the optional steps
* @param page - the page object
*/
export const navigateToOptionalSteps = async (page: Page | FrameLocator) => {
await page.getByRole('button', { name: 'Create image blueprint' }).click();
if (!isHosted()) {
// wait until the distro and architecture aligns with the host
await expect(page.getByTestId('release_select')).toHaveText(
getHostDistroName(),
);
await expect(page.getByTestId('arch_select')).toHaveText(getHostArch());
}
await page.getByRole('button', { name: 'Create blueprint' }).click();
await page.getByRole('checkbox', { name: 'Virtualization' }).click();
await page.getByRole('button', { name: 'Next' }).click();
};
@ -31,15 +24,3 @@ export const ibFrame = (page: Page): FrameLocator | Page => {
.locator('iframe[name="cockpit1\\:localhost\\/cockpit-image-builder"]')
.contentFrame();
};
/**
* Navigates to the landing page of the Image Builder
* @param page - the page object
*/
export const navigateToLandingPage = async (page: Page) => {
if (isHosted()) {
await page.goto('/insights/image-builder/landing');
} else {
await page.goto('/cockpit-image-builder');
}
};

View file

@ -1,7 +1,7 @@
import { expect, FrameLocator, type Page, test } from '@playwright/test';
import { closePopupsIfExist, isHosted } from './helpers';
import { ibFrame, navigateToLandingPage } from './navHelpers';
import { isHosted } from './helpers';
import { ibFrame } from './navHelpers';
/**
* Clicks the create button, handles the modal, clicks the button again and selecets the BP in the list
@ -10,15 +10,13 @@ import { ibFrame, navigateToLandingPage } from './navHelpers';
*/
export const createBlueprint = async (
page: Page | FrameLocator,
blueprintName: string,
blueprintName: string
) => {
await page.getByRole('button', { name: 'Create blueprint' }).click();
await page.getByRole('button', { name: 'Close' }).first().click();
await page.getByRole('button', { name: 'Create blueprint' }).click();
await page.getByRole('textbox', { name: 'Search input' }).fill(blueprintName);
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await page.locator(`button[id="${blueprintName}"]`).click();
await page.getByTestId('blueprint-card').getByText(blueprintName).click();
};
/**
@ -31,7 +29,7 @@ export const createBlueprint = async (
*/
export const fillInDetails = async (
page: Page | FrameLocator,
blueprintName: string,
blueprintName: string
) => {
await page.getByRole('listitem').filter({ hasText: 'Details' }).click();
await page
@ -67,41 +65,27 @@ export const fillInImageOutputGuest = async (page: Page | FrameLocator) => {
/**
* Delete the blueprint with the given name
* Will locate to the Image Builder page and search for the blueprint first
* If the blueprint is not found, it will fail gracefully
* @param page - the page object
* @param blueprintName - the name of the blueprint to delete
*/
export const deleteBlueprint = async (page: Page, blueprintName: string) => {
// Since new browser is opened during the BP cleanup, we need to call the popup closer again
await closePopupsIfExist(page);
await test.step(
'Delete the blueprint with name: ' + blueprintName,
async () => {
// Locate back to the Image Builder page every time because the test can fail at any stage
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await frame
.getByRole('textbox', { name: 'Search input' })
.fill(blueprintName);
// Check if no blueprints found -> that means no blueprint was created -> fail gracefully and do not raise error
try {
await expect(
frame.getByRole('heading', { name: 'No blueprints found' }),
).toBeVisible({ timeout: 5_000 }); // Shorter timeout to avoid hanging uncessarily
return; // Fail gracefully, no blueprint to delete
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// If the No BP heading was not found, it means the blueprint (possibly) was created -> continue with deletion
}
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await frame.locator(`button[id="${blueprintName}"]`).click();
await frame
.getByTestId('blueprint-card')
.getByText(blueprintName)
.click();
await frame.getByRole('button', { name: 'Menu toggle' }).click();
await frame.getByRole('menuitem', { name: 'Delete blueprint' }).click();
await frame.getByRole('button', { name: 'Delete' }).click();
},
{ box: true },
{ box: true }
);
};
@ -129,7 +113,7 @@ export const exportBlueprint = async (page: Page, blueprintName: string) => {
*/
export const importBlueprint = async (
page: Page | FrameLocator,
blueprintName: string,
blueprintName: string
) => {
if (isHosted()) {
await page.getByRole('button', { name: 'Import' }).click();
@ -138,7 +122,7 @@ export const importBlueprint = async (
.locator('input[type=file]')
.setInputFiles('../../downloads/' + blueprintName + '.json');
await expect(
page.getByRole('textbox', { name: 'File upload' }),
page.getByRole('textbox', { name: 'File upload' })
).not.toBeEmpty();
await page.getByRole('button', { name: 'Review and Finish' }).click();
}

View file

@ -1,88 +1,75 @@
import { readFileSync } from 'node:fs';
import TOML from '@ltd/j-toml';
import { expect, test } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { closePopupsIfExist, isHosted } from './helpers/helpers';
import { ensureAuthenticated } from './helpers/login';
import { ibFrame, navigateToLandingPage } from './helpers/navHelpers';
import { isHosted } from './helpers/helpers';
import { login } from './helpers/login';
import { ibFrame } from './helpers/navHelpers';
test.describe.serial('test', () => {
const blueprintName = uuidv4();
test('create blueprint', async ({ page }) => {
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await login(page);
const frame = await ibFrame(page);
frame.getByRole('heading', { name: 'Images About image builder' });
frame.getByRole('heading', { name: 'Blueprints' });
await frame.getByRole('heading', { name: 'Images About image builder' });
await frame.getByRole('heading', { name: 'Blueprints' });
await frame.getByTestId('blueprints-create-button').click();
frame.getByRole('heading', { name: 'Image output' });
await frame
.getByRole('checkbox', { name: /Virtualization guest image/i })
.click();
await frame.getByRole('heading', { name: 'Image output' });
await frame.getByTestId('checkbox-guest-image').click();
await frame.getByRole('button', { name: 'Next', exact: true }).click();
if (isHosted()) {
frame.getByRole('heading', {
await frame.getByRole('heading', {
name: 'Register systems using this image',
});
await page.getByRole('radio', { name: /Register later/i }).click();
await page.getByTestId('register-later-radio').click();
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
frame.getByRole('heading', { name: 'Compliance' });
await frame.getByRole('heading', { name: 'Compliance' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'File system configuration' });
await frame.getByRole('heading', { name: 'File system configuration' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
if (isHosted()) {
frame.getByRole('heading', { name: 'Repository snapshot' });
await frame.getByRole('heading', { name: 'Repository snapshot' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Custom repositories' });
await frame.getByRole('heading', { name: 'Custom repositories' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
frame.getByRole('heading', { name: 'Additional packages' });
await frame.getByRole('heading', { name: 'Additional packages' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Users' });
await frame.getByRole('heading', { name: 'Users' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Timezone' });
await frame.getByRole('heading', { name: 'Timezone' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Locale' });
await frame.getByRole('heading', { name: 'Locale' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Hostname' });
await frame.getByRole('heading', { name: 'Hostname' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Kernel' });
await frame.getByRole('heading', { name: 'Kernel' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Firewall' });
await frame.getByRole('heading', { name: 'Firewall' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
frame.getByRole('heading', { name: 'Systemd services' });
await frame.getByRole('heading', { name: 'Systemd services' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
if (isHosted()) {
frame.getByRole('heading', { name: 'Ansible Automation Platform' });
await frame.getByRole('heading', { name: 'First boot configuration' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
if (isHosted()) {
frame.getByRole('heading', { name: 'First boot configuration' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
frame.getByRole('heading', { name: 'Details' });
await frame.getByRole('heading', { name: 'Details' });
await frame.getByTestId('blueprint').fill(blueprintName);
await expect(frame.getByTestId('blueprint')).toHaveValue(blueprintName);
await frame.getByRole('button', { name: 'Next', exact: true }).click();
@ -92,32 +79,22 @@ test.describe.serial('test', () => {
await frame.getByRole('button', { name: 'Create blueprint' }).click();
await expect(
frame.locator('.pf-v6-c-card__title-text').getByText(
// if the name is too long, the blueprint card will have a truncated name.
blueprintName.length > 24
? blueprintName.slice(0, 24) + '...'
: blueprintName,
),
frame.locator('.pf-v5-c-card__title-text').getByText(blueprintName)
).toBeVisible();
});
test('edit blueprint', async ({ page }) => {
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// package searching is really slow the first time in cockpit
if (!isHosted()) {
test.setTimeout(300000);
}
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await login(page);
const frame = await ibFrame(page);
await frame
.getByRole('textbox', { name: 'Search input' })
.fill(blueprintName);
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await frame.locator(`button[id="${blueprintName}"]`).click();
await frame.getByText(blueprintName, { exact: true }).first().click();
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByRole('button', { name: 'Additional packages' }).click();
@ -125,37 +102,30 @@ test.describe.serial('test', () => {
.getByTestId('packages-search-input')
.locator('input')
.fill('osbuild-composer');
frame.getByTestId('packages-table').getByText('Searching');
frame.getByRole('gridcell', { name: 'osbuild-composer' }).first();
await frame.getByTestId('packages-table').getByText('Searching');
await frame.getByRole('gridcell', { name: 'osbuild-composer' }).first();
await frame.getByRole('checkbox', { name: 'Select row 0' }).check();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame.getByRole('button', { name: 'About packages' }).click();
frame.getByRole('gridcell', { name: 'osbuild-composer' });
await frame.getByRole('button', { name: 'Close', exact: true }).click();
await frame.getByRole('gridcell', { name: 'osbuild-composer' });
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByRole('button', { name: 'About packages' }).click();
frame.getByRole('gridcell', { name: 'osbuild-composer' });
await frame.getByRole('button', { name: 'Close', exact: true }).click();
await frame.getByRole('gridcell', { name: 'osbuild-composer' });
await frame.getByRole('button', { name: 'Cancel', exact: true }).click();
frame.getByRole('heading', { name: 'All images' });
await frame.getByRole('heading', { name: 'All images' });
});
test('build blueprint', async ({ page }) => {
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await login(page);
const frame = await ibFrame(page);
await frame
.getByRole('textbox', { name: 'Search input' })
.fill(blueprintName);
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await frame.locator(`button[id="${blueprintName}"]`).click();
await frame.getByText(blueprintName, { exact: true }).first().click();
await frame.getByTestId('blueprint-build-image-menu-option').click();
// make sure the image is present
@ -163,152 +133,18 @@ test.describe.serial('test', () => {
.getByTestId('images-table')
.getByRole('button', { name: 'Details' })
.click();
frame.getByText('Build Information');
await frame.getByText('Build Information');
});
test('delete blueprint', async ({ page }) => {
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await login(page);
const frame = await ibFrame(page);
await frame
.getByRole('textbox', { name: 'Search input' })
.fill(blueprintName);
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await frame.locator(`button[id="${blueprintName}"]`).click();
await frame.getByRole('button', { name: /blueprint menu toggle/i }).click();
await frame.getByText(blueprintName, { exact: true }).first().click();
await frame.getByTestId('blueprint-action-menu-toggle').click();
await frame.getByRole('menuitem', { name: 'Delete blueprint' }).click();
await frame.getByRole('button', { name: 'Delete' }).click();
});
test('cockpit worker config', async ({ page }) => {
if (isHosted()) {
return;
}
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await page.goto('/cockpit-image-builder');
const frame = ibFrame(page);
const header = frame.getByText('Configure AWS Uploads');
if (!(await header.isVisible())) {
await frame
.getByRole('button', { name: 'Configure Cloud Providers' })
.click();
await expect(header).toBeVisible();
}
const bucket = 'cockpit-ib-playwright-bucket';
const credentials = '/test/credentials';
const switchInput = frame.locator('#aws-config-switch');
await expect(switchInput).toBeVisible();
// introduce a wait time, since it takes some time to load the
// worker config file.
await page.waitForTimeout(1000);
// If this test fails for any reason, the config should already be loaded
// and visible on the retury. If it is go back to the landing page
if (await switchInput.isChecked()) {
await frame.getByRole('button', { name: 'Cancel' }).click();
await expect(
frame.getByRole('heading', { name: 'All images' }),
).toBeVisible();
} else {
const switchToggle = frame.locator('.pf-v6-c-switch');
await switchToggle.click();
await frame
.getByPlaceholder('AWS bucket')
// this doesn't need to exist, we're just testing that
// the form works as expected
.fill(bucket);
await frame.getByPlaceholder('Path to AWS credentials').fill(credentials);
await frame.getByRole('button', { name: 'Submit' }).click();
await expect(
frame.getByRole('heading', { name: 'All images' }),
).toBeVisible();
}
await frame
.getByRole('button', { name: 'Configure Cloud Providers' })
.click();
await expect(header).toBeVisible();
// introduce a wait time, since it takes some time to load the
// worker config file.
await page.waitForTimeout(1500);
await expect(frame.locator('#aws-config-switch')).toBeChecked();
await expect(frame.getByPlaceholder('AWS bucket')).toHaveValue(bucket);
await expect(frame.getByPlaceholder('Path to AWS credentials')).toHaveValue(
credentials,
);
await frame.getByRole('button', { name: 'Cancel' }).click();
const config = readFileSync('/etc/osbuild-worker/osbuild-worker.toml');
// this is for testing, the field `aws` should exist
// eslint-disable-next-line
const parsed = TOML.parse(config) as any;
expect(parsed.aws?.bucket).toBe(bucket);
expect(parsed.aws?.credentials).toBe(credentials);
});
const cockpitBlueprintname = uuidv4();
test('cockpit cloud upload', async ({ page }) => {
if (isHosted()) {
return;
}
await ensureAuthenticated(page);
await closePopupsIfExist(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
await page.goto('/cockpit-image-builder');
const frame = ibFrame(page);
frame.getByRole('heading', { name: 'Images About image builder' });
frame.getByRole('heading', { name: 'Blueprints' });
await frame.getByTestId('blueprints-create-button').click();
frame.getByRole('heading', { name: 'Image output' });
// the first card should be the AWS card
await frame.locator('.pf-v6-c-card').first().click();
await frame.getByRole('button', { name: 'Next', exact: true }).click();
await frame.getByRole('button', { name: 'Next', exact: true }).click();
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame.getByRole('button', { name: 'Back', exact: true }).click();
frame.getByRole('heading', { name: 'Details' });
await frame.getByTestId('blueprint').fill(cockpitBlueprintname);
await expect(frame.getByTestId('blueprint')).toHaveValue(
cockpitBlueprintname,
);
await frame.getByRole('button', { name: 'Next', exact: true }).click();
await frame.getByRole('button', { name: 'Create blueprint' }).click();
await frame.getByTestId('close-button-saveandbuild-modal').click();
await frame.getByRole('button', { name: 'Create blueprint' }).click();
await frame
.getByRole('textbox', { name: 'Search input' })
.fill(cockpitBlueprintname);
// the clickable blueprint cards are a bit awkward, so use the
// button's id instead
await frame.locator(`button[id="${cockpitBlueprintname}"]`).click();
await frame.getByTestId('blueprint-build-image-menu-option').click();
// make sure the image is present
await frame
.getByTestId('images-table')
.getByRole('button', { name: 'Details' })
.click();
frame.getByText('Build Information');
});
});

57
pr_check.sh Executable file
View file

@ -0,0 +1,57 @@
#!/bin/bash
# --------------------------------------------
# Export vars for helper scripts to use
# --------------------------------------------
# name of app-sre "application" folder this component lives in; needs to match for quay
export COMPONENT_NAME="image-builder-frontend"
# IMAGE should match the quay repo set by app.yaml in app-interface
export IMAGE="quay.io/cloudservices/image-builder-frontend"
export WORKSPACE=${WORKSPACE:-$APP_ROOT} # if running in jenkins, use the build's workspace
export APP_ROOT=$(pwd)
#16 is the default Node version. Change this to override it.
export NODE_BUILD_VERSION=20
# skip unit tests on frontend-build
export SKIP_VERIFY=True
COMMON_BUILDER=https://raw.githubusercontent.com/RedHatInsights/insights-frontend-builder-common/master
# --------------------------------------------
# Options that must be configured by app owner
# --------------------------------------------
export IQE_PLUGINS="image-builder"
export IQE_CJI_TIMEOUT="90m"
export IQE_MARKER_EXPRESSION="fe_pr_check"
export IQE_SELENIUM="true"
export IQE_ENV="ephemeral"
export IQE_IMAGE_TAG="image-builder"
export IQE_PARALLEL_ENABLED="false"
export RESERVE_DURATION="2h"
# bootstrap bonfire and it's config
CICD_URL=https://raw.githubusercontent.com/RedHatInsights/bonfire/master/cicd
curl -s "$CICD_URL"/bootstrap.sh >.cicd_bootstrap.sh && source .cicd_bootstrap.sh
# # source is preferred to | bash -s in this case to avoid a subshell
source <(curl -sSL $COMMON_BUILDER/src/frontend-build.sh)
# reserve ephemeral namespace
export DEPLOY_FRONTENDS="true"
export EXTRA_DEPLOY_ARGS="provisioning sources rhsm-api-proxy --set-template-ref rhsm-api-proxy=master"
export APP_NAME="image-builder-crc"
export DEPLOY_TIMEOUT="1200"
export REF_ENV="insights-stage"
# overwrites any resource limits imposed by bonfire
export COMPONENTS_W_RESOURCES="compliance notifications-backend notifications-engine"
source "$CICD_ROOT"/deploy_ephemeral_env.sh
# Run smoke tests using a ClowdJobInvocation (preferred)
# The contents of this script can be found at:
# https://raw.githubusercontent.com/RedHatInsights/bonfire/master/cicd/cji_smoke_test.sh
export COMPONENT_NAME="image-builder"
source "$CICD_ROOT"/cji_smoke_test.sh
# Post a comment with test run IDs to the PR
# The contents of this script can be found at:
# https://raw.githubusercontent.com/RedHatInsights/bonfire/master/cicd/post_test_results.sh
source "$CICD_ROOT"/post_test_results.sh

View file

@ -6,16 +6,12 @@ source /etc/os-release
sudo dnf install -y \
libappstream-glib
# RHEL9 has nodejs and npm separately
if [[ "$ID" == rhel && ${VERSION_ID%.*} == 10 ]]; then
sudo dnf install -y nodejs-npm \
sqlite # node fails to pull this in
elif [[ "$ID" == rhel ]]; then
else
sudo dnf install -y npm
elif [[ "$ID" == fedora ]]; then
sudo dnf install -y \
nodejs-npm \
sqlite \
gettext
fi
npm ci

View file

@ -1,8 +0,0 @@
summary: run playwright tests
test: ./playwright_tests.sh
require:
- cockpit-image-builder
- podman
- nodejs
- nodejs-npm
duration: 30m

View file

@ -1,16 +1,16 @@
#!/bin/bash
set -euo pipefail
TMT_SOURCE_DIR=${TMT_SOURCE_DIR:-}
if [ -n "$TMT_SOURCE_DIR" ]; then
# Move to the directory with sources
cd "${TMT_SOURCE_DIR}/cockpit-image-builder"
npm ci
elif [ "${CI:-}" != "true" ]; then
# packit drops us into the schutzbot directory
cd ../
npm ci
fi
# As playwright isn't supported on fedora/el, install dependencies
# beforehand.
sudo dnf install -y \
alsa-lib \
libXrandr-devel \
libXdamage-devel \
libXcomposite-devel \
at-spi2-atk-devel \
cups \
atk
sudo systemctl enable --now cockpit.socket
@ -19,13 +19,10 @@ sudo usermod -aG wheel admin
echo "admin ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/admin-nopasswd"
function upload_artifacts {
if [ -n "${TMT_TEST_DATA:-}" ]; then
mv playwright-report "$TMT_TEST_DATA"/playwright-report
else
USER="$(whoami)"
sudo chown -R "$USER:$USER" playwright-report
mv playwright-report /tmp/artifacts/
fi
mkdir -p /tmp/artifacts/extra-screenshots
USER="$(whoami)"
sudo chown -R "$USER:$USER" playwright-report
mv playwright-report /tmp/artifacts/
}
trap upload_artifacts EXIT
@ -76,12 +73,10 @@ sudo podman run \
-e "CI=true" \
-e "PLAYWRIGHT_USER=admin" \
-e "PLAYWRIGHT_PASSWORD=foobar" \
-e "CURRENTS_PROJECT_ID=${CURRENTS_PROJECT_ID:-}" \
-e "CURRENTS_RECORD_KEY=${CURRENTS_RECORD_KEY:-}" \
-e "CURRENTS_PROJECT_ID=$CURRENTS_PROJECT_ID" \
-e "CURRENTS_RECORD_KEY=$CURRENTS_RECORD_KEY" \
--net=host \
-v "$PWD:/tests" \
-v '/etc:/etc' \
-v '/etc/os-release:/etc/os-release' \
--privileged \
--rm \
--init \

View file

@ -1 +1 @@
cf0a810fd3b75fa27139746c4dfe72222e13dcba
9e23ef712339fdc44d3499ee487ef3a3a202db25

View file

@ -1,7 +1,7 @@
#!/bin/bash
# if a user is logged in to the runner, wait until they're done
while (( $(who -u | grep -v '?' | wc -l) > 0 )); do
while (( $(who -s | wc -l) > 0 )); do
echo "Waiting for user(s) to log off"
sleep 30
done

View file

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import NotificationsProvider from '@redhat-cloud-services/frontend-components-notifications/NotificationsProvider';
import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
import NotificationsPortal from '@redhat-cloud-services/frontend-components-notifications/NotificationPortal';
import '@patternfly/patternfly/patternfly-addons.css';
import { Router } from './Router';
@ -14,21 +14,10 @@ const App = () => {
hideGlobalFilter(true);
}, [hideGlobalFilter, updateDocumentTitle]);
// Necessary for in-page wizard overflow to behave properly
// The .chr-render class is defined in Insights Chrome:
// https://github.com/RedHatInsights/insights-chrome/blob/fe573705020ff64003ac9e6101aa978b471fe6f2/src/sass/chrome.scss#L82
useEffect(() => {
const chrRenderDiv = document.querySelector('.chr-render');
if (chrRenderDiv) {
(chrRenderDiv as HTMLElement).style.overflow = 'auto';
}
}, []);
return (
<React.Fragment>
<NotificationsProvider>
<Router />
</NotificationsProvider>
<NotificationsPortal />
<Router />
</React.Fragment>
);
};

View file

@ -1,78 +0,0 @@
@font-face {
font-family: 'Red Hat Text';
font-style: normal;
font-weight: 400 500;
src: url('/cockpit/static/fonts/RedHatText/RedHatTextVF.woff2')
format('woff2-variations');
font-display: fallback;
}
@font-face {
font-family: 'Red Hat Text';
font-style: italic;
font-weight: 400 500;
src: url('/cockpit/static/fonts/RedHatText/RedHatTextVF-Italic.woff2')
format('woff2-variations');
font-display: fallback;
}
@font-face {
font-family: 'Red Hat Display';
font-style: normal;
font-weight: 400 700;
src: url('/cockpit/static/fonts/RedHatDisplay/RedHatDisplayVF.woff2')
format('woff2-variations');
font-display: fallback;
}
@font-face {
font-family: 'Red Hat Display';
font-style: italic;
font-weight: 400 700;
src: url('/cockpit/static/fonts/RedHatDisplay/RedHatDisplayVF-Italic.woff2')
format('woff2-variations');
font-display: fallback;
}
@font-face {
font-family: RedHatText;
font-style: normal;
font-weight: 400;
src: url('/cockpit/static/fonts/RedHatText-Regular.woff2') format('woff2');
font-display: fallback;
}
@font-face {
font-family: RedHatText;
font-style: normal;
font-weight: 700;
src: url('/cockpit/static/fonts/RedHatText-Medium.woff2') format('woff2');
font-display: fallback;
}
// Override as PF Page doesn't allow empty masthead and sidebar
@media (min-width: 75rem) {
.pf-v6-c-page.no-masthead-sidebar {
/* custom class to scope this style to a specific page component instance */
--pf-v6-c-page__main-container--GridArea: var(
--pf-v6-c-page--masthead--main-container--GridArea
);
}
}
.pf-v6-c-page__main-section {
padding-inline: 0;
padding-block-start: 0;
}
.pf-v6-c-page__main > section.pf-v6-c-page__main-section:not(.pf-m-padding) {
padding-inline: 0;
}
.pf-v6-c-card {
&.pf-m-clickable::before,
&.pf-m-selectable::before {
border: var(--pf-v6-c-card--BorderColor) var(--pf-v6-c-card--BorderStyle)
var(--pf-v6-c-card--BorderWidth) !important;
}
}

View file

@ -3,14 +3,11 @@ import '@patternfly/patternfly/patternfly-addons.css';
import React from 'react';
import 'cockpit-dark-theme';
import { Page, PageSection } from '@patternfly/react-core';
import NotificationsProvider from '@redhat-cloud-services/frontend-components-notifications/NotificationsProvider';
import NotificationsPortal from '@redhat-cloud-services/frontend-components-notifications/NotificationPortal';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';
import './AppCockpit.scss';
import { NotReady, RequireAdmin } from './Components/Cockpit';
import { Router } from './Router';
import { onPremStore as store } from './store';
@ -31,21 +28,16 @@ const Application = () => {
return (
<React.Fragment>
<NotificationsProvider>
<HashRouter>
<Router />
</HashRouter>
</NotificationsProvider>
<NotificationsPortal />
<HashRouter>
<Router />
</HashRouter>
</React.Fragment>
);
};
const ImageBuilder = () => (
<Provider store={store}>
<Page className='no-masthead-sidebar' isContentFilled>
<PageSection>
<Application />
</PageSection>
</Page>
<Application />
</Provider>
);

View file

@ -30,7 +30,7 @@ export const BlueprintActionsMenu: React.FunctionComponent<
setShowBlueprintActionsMenu(!showBlueprintActionsMenu);
};
const importExportFlag = useFlagWithEphemDefault(
'image-builder.import.enabled',
'image-builder.import.enabled'
);
const [trigger] = useLazyExportBlueprintQuery();
@ -58,10 +58,11 @@ export const BlueprintActionsMenu: React.FunctionComponent<
ref={toggleRef}
isExpanded={showBlueprintActionsMenu}
onClick={() => setShowBlueprintActionsMenu(!showBlueprintActionsMenu)}
variant='plain'
aria-label='blueprint menu toggle'
variant="plain"
aria-label="blueprint menu toggle"
data-testid="blueprint-action-menu-toggle"
>
<EllipsisVIcon aria-hidden='true' />
<EllipsisVIcon aria-hidden="true" />
</MenuToggle>
)}
>
@ -81,7 +82,7 @@ export const BlueprintActionsMenu: React.FunctionComponent<
async function handleExportBlueprint(
blueprintName: string,
blueprint: BlueprintExportResponse,
blueprint: BlueprintExportResponse
) {
const jsonData = JSON.stringify(blueprint, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });

View file

@ -3,20 +3,22 @@ import React from 'react';
import {
Badge,
Card,
CardBody,
CardFooter,
CardHeader,
CardTitle,
CardBody,
CardFooter,
Spinner,
} from '@patternfly/react-core';
import { useDeleteBPWithNotification as useDeleteBlueprintMutation } from '../../Hooks';
import {
selectSelectedBlueprintId,
setBlueprintId,
} from '../../store/BlueprintSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { BlueprintItem } from '../../store/imageBuilderApi';
import {
BlueprintItem,
useDeleteBlueprintMutation,
} from '../../store/imageBuilderApi';
type blueprintProps = {
blueprint: BlueprintItem;
@ -26,45 +28,28 @@ const BlueprintCard = ({ blueprint }: blueprintProps) => {
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const dispatch = useAppDispatch();
const { isLoading } = useDeleteBlueprintMutation({
const [, { isLoading }] = useDeleteBlueprintMutation({
fixedCacheKey: 'delete-blueprint',
});
return (
<>
<Card
isClicked={blueprint.id === selectedBlueprintId}
isSelected={blueprint.id === selectedBlueprintId}
data-testid={`blueprint-card`}
isCompact
isClickable
onClick={() => dispatch(setBlueprintId(blueprint.id))}
isSelectableRaised
hasSelectableInput
selectableInputAriaLabel={`Select blueprint ${blueprint.name}`}
>
<CardHeader
data-testid={blueprint.id}
selectableActions={{
name: blueprint.name,
// use the name rather than the id. This helps us
// chose the correct item in the playwright tests
selectableActionId: blueprint.name,
selectableActionAriaLabel: blueprint.name,
onChange: () => dispatch(setBlueprintId(blueprint.id)),
}}
>
<CardTitle aria-label={blueprint.name}>
<CardHeader data-testid={blueprint.id}>
<CardTitle>
{isLoading && blueprint.id === selectedBlueprintId && (
<Spinner size='md' />
<Spinner size="md" />
)}
{
// NOTE: This might be an issue with the pf6 truncate component.
// Since we're not really using the popover, we can just
// use vanilla js to truncate the string rather than use the
// Truncate component. We can match the behaviour of the component
// by also splitting on 24 characters.
// https://github.com/patternfly/patternfly-react/issues/11964
blueprint.name && blueprint.name.length > 24
? blueprint.name.slice(0, 24) + '...'
: blueprint.name
}
{blueprint.name}
</CardTitle>
</CardHeader>
<CardBody>{blueprint.description}</CardBody>

View file

@ -1,14 +1,7 @@
import React from 'react';
import { DiffEditor } from '@monaco-editor/react';
import {
Button,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
} from '@patternfly/react-core';
import { Button, Modal, ModalVariant } from '@patternfly/react-core';
import { BuildImagesButton } from './BuildImagesButton';
@ -34,11 +27,11 @@ const BlueprintDiffModal = ({
const { data: baseBlueprint } = useGetBlueprintQuery(
{ id: selectedBlueprintId as string, version: baseVersion || -1 },
{ skip: !selectedBlueprintId || !baseVersion },
{ skip: !selectedBlueprintId || !baseVersion }
);
const { data: blueprint } = useGetBlueprintQuery(
{ id: selectedBlueprintId as string },
{ skip: !selectedBlueprintId },
{ skip: !selectedBlueprintId }
);
if (!baseBlueprint || !blueprint) {
@ -46,32 +39,32 @@ const BlueprintDiffModal = ({
}
return (
<Modal variant={ModalVariant.large} isOpen={isOpen} onClose={onClose}>
<ModalHeader
title={`Compare ${blueprintName || ''} versions`}
titleIconVariant={'info'}
/>
<ModalBody>
<DiffEditor
height='90vh'
language='json'
original={JSON.stringify(baseBlueprint, undefined, 2)}
modified={JSON.stringify(blueprint, undefined, 2)}
/>
</ModalBody>
<ModalFooter>
<BuildImagesButton key='build-button'>
<Modal
variant={ModalVariant.large}
titleIconVariant={'info'}
isOpen={isOpen}
onClose={onClose}
title={`Compare ${blueprintName || ''} versions`}
actions={[
<BuildImagesButton key="build-button">
Synchronize images
</BuildImagesButton>
</BuildImagesButton>,
<Button
key='cancel-button'
variant='link'
type='button'
key="cancel-button"
variant="link"
type="button"
onClick={onClose}
>
Cancel
</Button>
</ModalFooter>
</Button>,
]}
>
<DiffEditor
height="90vh"
language="json"
original={JSON.stringify(baseBlueprint, undefined, 2)}
modified={JSON.stringify(blueprint, undefined, 2)}
/>
</Modal>
);
};

View file

@ -10,9 +10,9 @@ import { MenuToggleElement } from '@patternfly/react-core/dist/esm/components/Me
import { FilterIcon } from '@patternfly/react-icons';
import {
versionFilterType,
selectBlueprintVersionFilter,
setBlueprintVersionFilter,
versionFilterType,
} from '../../store/BlueprintSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
@ -33,7 +33,7 @@ const BlueprintVersionFilter: React.FC<blueprintVersionFilterProps> = ({
const onSelect = (
_event: React.MouseEvent<Element, MouseEvent> | undefined,
value: versionFilterType,
value: versionFilterType
) => {
dispatch(setBlueprintVersionFilter(value));
if (onFilterChange) onFilterChange();
@ -58,10 +58,10 @@ const BlueprintVersionFilter: React.FC<blueprintVersionFilterProps> = ({
shouldFocusToggleOnSelect
>
<DropdownList>
<DropdownItem value={'all'} key='all'>
<DropdownItem value={'all'} key="all">
All versions
</DropdownItem>
<DropdownItem value={'latest'} key='newest'>
<DropdownItem value={'latest'} key="newest">
Newest
</DropdownItem>
</DropdownList>

View file

@ -50,8 +50,8 @@ const BlueprintsPagination = () => {
page={currPage}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId='blueprints-pagination-bottom'
data-testid='blueprints-pagination-bottom'
widgetId="blueprints-pagination-bottom"
data-testid="blueprints-pagination-bottom"
isCompact
/>
);

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
Bullseye,
@ -7,6 +7,8 @@ import {
EmptyStateActions,
EmptyStateBody,
EmptyStateFooter,
EmptyStateHeader,
EmptyStateIcon,
Flex,
FlexItem,
SearchInput,
@ -17,6 +19,7 @@ import {
import { PlusCircleIcon, SearchIcon } from '@patternfly/react-icons';
import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import debounce from 'lodash/debounce';
import { Link } from 'react-router-dom';
@ -25,10 +28,9 @@ import BlueprintsPagination from './BlueprintsPagination';
import {
DEBOUNCED_SEARCH_WAIT_TIME,
PAGINATION_LIMIT,
PAGINATION_OFFSET,
PAGINATION_LIMIT,
} from '../../constants';
import { useGetUser } from '../../Hooks';
import { useGetBlueprintsQuery } from '../../store/backendApi';
import {
selectBlueprintSearchInput,
@ -46,6 +48,7 @@ import {
} from '../../store/imageBuilderApi';
import { imageBuilderApi } from '../../store/service/enhancedImageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
import { useGetEnvironment } from '../../Utilities/useGetEnvironment';
type blueprintSearchProps = {
blueprintsTotal: number;
@ -60,8 +63,9 @@ type emptyBlueprintStateProps = {
};
const BlueprintsSidebar = () => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { isFedoraEnv } = useGetEnvironment();
const { analytics, auth } = useChrome();
const { userData } = useGetUser(auth);
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const blueprintSearchInput = useAppSelector(selectBlueprintSearchInput);
@ -73,6 +77,13 @@ const BlueprintsSidebar = () => {
offset: blueprintsOffset,
};
useEffect(() => {
(async () => {
const data = await auth?.getUser();
setUserData(data);
})();
}, [auth]);
if (blueprintSearchInput) {
searchParams.search = blueprintSearchInput;
}
@ -90,7 +101,7 @@ const BlueprintsSidebar = () => {
if (isLoading) {
return (
<Bullseye>
<Spinner size='xl' />
<Spinner size="xl" />
</Bullseye>
);
}
@ -104,8 +115,8 @@ const BlueprintsSidebar = () => {
<EmptyBlueprintState
icon={PlusCircleIcon}
action={<Link to={resolveRelPath('imagewizard')}>Add blueprint</Link>}
titleText='No blueprints yet'
bodyText='Add a blueprint and optionally build related images.'
titleText="No blueprints yet"
bodyText="Add a blueprint and optionally build related images."
/>
);
}
@ -115,8 +126,8 @@ const BlueprintsSidebar = () => {
dispatch(setBlueprintId(undefined));
};
if (!process.env.IS_ON_PREMISE) {
const orgId = userData?.identity.internal?.org_id;
if (!process.env.IS_ON_PREMISE && !isFedoraEnv) {
const orgId = userData?.identity?.internal?.org_id;
analytics.group(orgId, {
imagebuilder_blueprint_count: blueprintsData?.meta.count,
@ -137,7 +148,7 @@ const BlueprintsSidebar = () => {
<Flex justifyContent={{ default: 'justifyContentCenter' }}>
<FlexItem>
<Button
variant='link'
variant="link"
isDisabled={!selectedBlueprintId}
onClick={handleClickViewAll}
>
@ -153,14 +164,14 @@ const BlueprintsSidebar = () => {
icon={SearchIcon}
action={
<Button
variant='link'
variant="link"
onClick={() => dispatch(setBlueprintSearchInput(undefined))}
>
Clear all filters
</Button>
}
titleText='No blueprints found'
bodyText='No blueprints match your search criteria. Try a different search.'
titleText="No blueprints found"
bodyText="No blueprints match your search criteria. Try a different search."
/>
)}
{blueprintsTotal > 0 &&
@ -184,7 +195,7 @@ const BlueprintSearch = ({ blueprintsTotal }: blueprintSearchProps) => {
dispatch(imageBuilderApi.util.invalidateTags([{ type: 'Blueprints' }]));
dispatch(setBlueprintSearchInput(filter.length > 0 ? filter : undefined));
}, DEBOUNCED_SEARCH_WAIT_TIME),
[],
[]
);
React.useEffect(() => {
return () => {
@ -202,7 +213,7 @@ const BlueprintSearch = ({ blueprintsTotal }: blueprintSearchProps) => {
return (
<SearchInput
value={blueprintSearchInput || ''}
placeholder='Search by name or description'
placeholder="Search by name or description"
onChange={(_event, value) => onChange(value)}
onClear={() => onChange('')}
resultsCount={`${blueprintsTotal} blueprints`}
@ -216,7 +227,12 @@ const EmptyBlueprintState = ({
icon,
action,
}: emptyBlueprintStateProps) => (
<EmptyState headingLevel='h4' icon={icon} titleText={titleText} variant='sm'>
<EmptyState variant="sm">
<EmptyStateHeader
titleText={titleText}
headingLevel="h4"
icon={<EmptyStateIcon icon={icon} />}
/>
<EmptyStateBody>{bodyText}</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>{action}</EmptyStateActions>

View file

@ -1,31 +1,32 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Button,
ButtonProps,
Dropdown,
Flex,
FlexItem,
MenuToggle,
Menu,
MenuContent,
MenuItem,
MenuList,
MenuToggle,
MenuToggleAction,
MenuItem,
Flex,
FlexItem,
Spinner,
MenuToggleAction,
ButtonProps,
Button,
} from '@patternfly/react-core';
import { MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/MenuToggle';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { ChromeUser } from '@redhat-cloud-services/types';
import { skipToken } from '@reduxjs/toolkit/query';
import { AMPLITUDE_MODULE_NAME, targetOptions } from '../../constants';
import {
useComposeBPWithNotification as useComposeBlueprintMutation,
useGetUser,
} from '../../Hooks';
import { useGetBlueprintQuery } from '../../store/backendApi';
useGetBlueprintQuery,
useComposeBlueprintMutation,
} from '../../store/backendApi';
import { selectSelectedBlueprintId } from '../../store/BlueprintSlice';
import { useAppSelector } from '../../store/hooks';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { ImageTypes } from '../../store/imageBuilderApi';
type BuildImagesButtonPropTypes = {
@ -36,27 +37,44 @@ type BuildImagesButtonPropTypes = {
export const BuildImagesButton = ({ children }: BuildImagesButtonPropTypes) => {
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const [deselectedTargets, setDeselectedTargets] = useState<ImageTypes[]>([]);
const { trigger: buildBlueprint, isLoading: imageBuildLoading } =
const [buildBlueprint, { isLoading: imageBuildLoading }] =
useComposeBlueprintMutation();
const dispatch = useAppDispatch();
const { analytics, auth } = useChrome();
const { userData } = useGetUser(auth);
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
useEffect(() => {
(async () => {
const data = await auth?.getUser();
setUserData(data);
})();
}, [auth]);
const onBuildHandler = async () => {
if (selectedBlueprintId) {
await buildBlueprint({
id: selectedBlueprintId,
body: {
image_types: blueprintImageType?.filter(
(target) => !deselectedTargets.includes(target),
),
},
});
if (!process.env.IS_ON_PREMISE) {
try {
await buildBlueprint({
id: selectedBlueprintId,
body: {
image_types: blueprintImageType?.filter(
(target) => !deselectedTargets.includes(target)
),
},
});
analytics.track(`${AMPLITUDE_MODULE_NAME} - Image Requested`, {
module: AMPLITUDE_MODULE_NAME,
trigger: 'synchronize images',
account_id: userData?.identity.internal?.account_id || 'Not found',
});
} catch (imageBuildError) {
dispatch(
addNotification({
variant: 'warning',
title: 'No blueprint was build',
description: imageBuildError?.data?.error?.message,
})
);
}
}
};
@ -65,21 +83,21 @@ export const BuildImagesButton = ({ children }: BuildImagesButtonPropTypes) => {
setIsOpen(!isOpen);
};
const { data: blueprintDetails } = useGetBlueprintQuery(
selectedBlueprintId ? { id: selectedBlueprintId } : skipToken,
selectedBlueprintId ? { id: selectedBlueprintId } : skipToken
);
const blueprintImageType = blueprintDetails?.image_requests.map(
(image) => image.image_type,
(image) => image.image_type
);
const onSelect = (
_event: React.MouseEvent<Element, MouseEvent>,
itemId: number,
itemId: number
) => {
const imageType = blueprintImageType?.[itemId];
if (imageType && deselectedTargets.includes(imageType)) {
setDeselectedTargets(
deselectedTargets.filter((target) => target !== imageType),
deselectedTargets.filter((target) => target !== imageType)
);
} else if (imageType) {
setDeselectedTargets([...deselectedTargets, imageType]);
@ -92,40 +110,43 @@ export const BuildImagesButton = ({ children }: BuildImagesButtonPropTypes) => {
onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
variant='primary'
data-testid='blueprint-build-image-menu'
variant="primary"
data-testid="blueprint-build-image-menu"
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
splitButtonItems={[
<MenuToggleAction
data-testid='blueprint-build-image-menu-option'
key='split-action'
onClick={onBuildHandler}
id='wizard-build-image-btn'
isDisabled={
!selectedBlueprintId ||
deselectedTargets.length === blueprintImageType?.length
}
>
<Flex display={{ default: 'inlineFlex' }}>
{imageBuildLoading && (
<FlexItem>
<Spinner
style={
{
'--pf-v6-c-spinner--Color': '#fff',
} as React.CSSProperties
}
isInline
size='md'
/>
</FlexItem>
)}
<FlexItem>{children ? children : 'Build images'}</FlexItem>
</Flex>
</MenuToggleAction>,
]}
splitButtonOptions={{
variant: 'action',
items: [
<MenuToggleAction
data-testid="blueprint-build-image-menu-option"
key="split-action"
onClick={onBuildHandler}
id="wizard-build-image-btn"
isDisabled={
!selectedBlueprintId ||
deselectedTargets.length === blueprintImageType?.length
}
>
<Flex display={{ default: 'inlineFlex' }}>
{imageBuildLoading && (
<FlexItem>
<Spinner
style={
{
'--pf-v5-c-spinner--Color': '#fff',
} as React.CSSProperties
}
isInline
size="md"
/>
</FlexItem>
)}
<FlexItem>{children ? children : 'Build images'}</FlexItem>
</Flex>
</MenuToggleAction>,
],
}}
></MenuToggle>
)}
>
@ -162,7 +183,7 @@ export const BuildImagesButtonEmptyState = ({
children,
}: BuildImagesButtonEmptyStatePropTypes) => {
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const { trigger: buildBlueprint, isLoading: imageBuildLoading } =
const [buildBlueprint, { isLoading: imageBuildLoading }] =
useComposeBlueprintMutation();
const onBuildHandler = async () => {
if (selectedBlueprintId) {

View file

@ -1,14 +1,13 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
ActionGroup,
Button,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
} from '@patternfly/react-core';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import {
AMPLITUDE_MODULE_NAME,
@ -16,10 +15,10 @@ import {
PAGINATION_OFFSET,
} from '../../constants';
import {
useDeleteBPWithNotification as useDeleteBlueprintMutation,
useGetUser,
} from '../../Hooks';
import { backendApi, useGetBlueprintsQuery } from '../../store/backendApi';
backendApi,
useDeleteBlueprintMutation,
useGetBlueprintsQuery,
} from '../../store/backendApi';
import {
selectBlueprintSearchInput,
selectLimit,
@ -44,7 +43,14 @@ export const DeleteBlueprintModal: React.FunctionComponent<
const blueprintsLimit = useAppSelector(selectLimit) || PAGINATION_LIMIT;
const dispatch = useAppDispatch();
const { analytics, auth } = useChrome();
const { userData } = useGetUser(auth);
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
useEffect(() => {
(async () => {
const data = await auth?.getUser();
setUserData(data);
})();
}, [auth]);
const searchParams: GetBlueprintsApiArg = {
limit: blueprintsLimit,
@ -59,21 +65,19 @@ export const DeleteBlueprintModal: React.FunctionComponent<
selectFromResult: ({ data }) => ({
blueprintName: data?.data.find(
(blueprint: { id: string | undefined }) =>
blueprint.id === selectedBlueprintId,
blueprint.id === selectedBlueprintId
)?.name,
}),
});
const { trigger: deleteBlueprint } = useDeleteBlueprintMutation({
const [deleteBlueprint] = useDeleteBlueprintMutation({
fixedCacheKey: 'delete-blueprint',
});
const handleDelete = async () => {
if (selectedBlueprintId) {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Blueprint Deleted`, {
module: AMPLITUDE_MODULE_NAME,
account_id: userData?.identity.internal?.account_id || 'Not found',
});
}
analytics.track(`${AMPLITUDE_MODULE_NAME} - Blueprint Deleted`, {
module: AMPLITUDE_MODULE_NAME,
account_id: userData?.identity.internal?.account_id || 'Not found',
});
setShowDeleteModal(false);
await deleteBlueprint({ id: selectedBlueprintId });
dispatch(setBlueprintId(undefined));
@ -84,20 +88,22 @@ export const DeleteBlueprintModal: React.FunctionComponent<
setShowDeleteModal(false);
};
return (
<Modal variant={ModalVariant.small} isOpen={isOpen} onClose={onDeleteClose}>
<ModalHeader title={'Delete blueprint?'} titleIconVariant='warning' />
<ModalBody>
All versions of {blueprintName} and its associated images will be
deleted.
</ModalBody>
<ModalFooter>
<Button variant='danger' type='button' onClick={handleDelete}>
<Modal
variant={ModalVariant.small}
titleIconVariant="warning"
isOpen={isOpen}
onClose={onDeleteClose}
title={'Delete blueprint?'}
description={`All versions of ${blueprintName} and its associated images will be deleted.`}
>
<ActionGroup>
<Button variant="danger" type="button" onClick={handleDelete}>
Delete
</Button>
<Button variant='link' type='button' onClick={onDeleteClose}>
<Button variant="link" type="button" onClick={onDeleteClose}>
Cancel
</Button>
</ModalFooter>
</ActionGroup>
</Modal>
);
};

View file

@ -16,7 +16,7 @@ export const EditBlueprintButton = () => {
onClick={() =>
navigate(resolveRelPath(`imagewizard/${selectedBlueprintId}`))
}
variant='secondary'
variant="secondary"
>
Edit blueprint
</Button>

View file

@ -2,6 +2,7 @@ import React from 'react';
import { parse } from '@ltd/j-toml';
import {
ActionGroup,
Button,
Checkbox,
FileUpload,
@ -11,15 +12,12 @@ import {
HelperText,
HelperTextItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
Popover,
} from '@patternfly/react-core';
import { DropEvent } from '@patternfly/react-core/dist/esm/helpers';
import { HelpIcon } from '@patternfly/react-icons';
import { useAddNotification } from '@redhat-cloud-services/frontend-components-notifications/hooks';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useNavigate } from 'react-router-dom';
import { mapOnPremToHosted } from './helpers/onPremToHostedBlueprintMapper';
@ -29,6 +27,7 @@ import {
ApiRepositoryRequest,
useBulkImportRepositoriesMutation,
} from '../../store/contentSourcesApi';
import { useAppDispatch } from '../../store/hooks';
import {
BlueprintExportResponse,
BlueprintItem,
@ -66,24 +65,24 @@ export const ImportBlueprintModal: React.FunctionComponent<
const [isRejected, setIsRejected] = React.useState(false);
const [isOnPrem, setIsOnPrem] = React.useState(false);
const [isCheckedImportRepos, setIsCheckedImportRepos] = React.useState(true);
const addNotification = useAddNotification();
const dispatch = useAppDispatch();
const [importRepositories] = useBulkImportRepositoriesMutation();
const handleFileInputChange = (
_event: React.ChangeEvent<HTMLInputElement> | React.DragEvent<HTMLElement>,
file: File,
file: File
) => {
setFileContent('');
setFilename(file.name);
};
async function handleRepositoryImport(
blueprintExportedResponse: BlueprintExportResponse,
blueprintExportedResponse: BlueprintExportResponse
): Promise<CustomRepository[] | undefined> {
if (isCheckedImportRepos && blueprintExportedResponse.content_sources) {
const customRepositories: ApiRepositoryRequest[] =
blueprintExportedResponse.content_sources.map(
(item) => item as ApiRepositoryRequest,
(item) => item as ApiRepositoryRequest
);
try {
@ -98,34 +97,40 @@ export const ImportBlueprintModal: React.FunctionComponent<
repository as ApiRepositoryImportResponseRead;
if (contentSourcesRepo.uuid) {
newCustomRepos.push(
...mapToCustomRepositories(contentSourcesRepo),
...mapToCustomRepositories(contentSourcesRepo)
);
}
if (repository.warnings?.length === 0 && repository.url) {
importedRepositoryNames.push(repository.url);
return;
}
addNotification({
variant: 'warning',
title: 'Failed to import custom repositories',
description: JSON.stringify(repository.warnings),
});
dispatch(
addNotification({
variant: 'warning',
title: 'Failed to import custom repositories',
description: JSON.stringify(repository.warnings),
})
);
});
if (importedRepositoryNames.length !== 0) {
addNotification({
variant: 'info',
title: 'Successfully imported custom repositories',
description: importedRepositoryNames.join(', '),
});
dispatch(
addNotification({
variant: 'info',
title: 'Successfully imported custom repositories',
description: importedRepositoryNames.join(', '),
})
);
}
return newCustomRepos;
}
} catch {
addNotification({
variant: 'danger',
title: 'Custom repositories import failed',
});
dispatch(
addNotification({
variant: 'danger',
title: 'Custom repositories import failed',
})
);
}
}
}
@ -139,11 +144,11 @@ export const ImportBlueprintModal: React.FunctionComponent<
if (isToml) {
const tomlBlueprint = parse(fileContent);
const blueprintFromFile = mapOnPremToHosted(
tomlBlueprint as BlueprintItem,
tomlBlueprint as BlueprintItem
);
const importBlueprintState = mapExportRequestToState(
blueprintFromFile,
[],
[]
);
setIsOnPrem(true);
setImportedBlueprint(importBlueprintState);
@ -155,8 +160,9 @@ export const ImportBlueprintModal: React.FunctionComponent<
blueprintFromFile.content_sources &&
blueprintFromFile.content_sources.length > 0
) {
const imported =
await handleRepositoryImport(blueprintFromFile);
const imported = await handleRepositoryImport(
blueprintFromFile
);
customRepos = imported ?? [];
}
@ -174,7 +180,7 @@ export const ImportBlueprintModal: React.FunctionComponent<
undefined;
const importBlueprintState = mapExportRequestToState(
blueprintExportedResponse,
blueprintFromFile.image_requests || [],
blueprintFromFile.image_requests || []
);
setIsOnPrem(false);
@ -184,7 +190,7 @@ export const ImportBlueprintModal: React.FunctionComponent<
mapOnPremToHosted(blueprintFromFile);
const importBlueprintState = mapExportRequestToState(
blueprintFromFileMapped,
[],
[]
);
setIsOnPrem(true);
setImportedBlueprint(importBlueprintState);
@ -192,11 +198,13 @@ export const ImportBlueprintModal: React.FunctionComponent<
}
} catch (error) {
setIsInvalidFormat(true);
addNotification({
variant: 'warning',
title: 'File is not a valid blueprint',
description: error?.data?.error?.message,
});
dispatch(
addNotification({
variant: 'warning',
title: 'File is not a valid blueprint',
description: error?.data?.error?.message,
})
);
}
};
parseAndImport();
@ -241,98 +249,95 @@ export const ImportBlueprintModal: React.FunctionComponent<
<Modal
variant={ModalVariant.medium}
isOpen={isOpen}
title={
<>
Import pipeline
<Popover
bodyContent={
<div>
You can import the blueprints you created by using the Red Hat
image builder into Insights images to create customized images.
</div>
}
>
<Button
variant="plain"
aria-label="About import"
className="pf-v5-u-pl-sm"
isInline
>
<HelpIcon />
</Button>
</Popover>
</>
}
onClose={onImportClose}
>
<ModalHeader
title={
<>
Import pipeline
<Popover
bodyContent={
<div>
You can import the blueprints you created by using the Red Hat
image builder into Insights images to create customized
images.
</div>
}
>
<Button
icon={<HelpIcon />}
variant='plain'
aria-label='About import'
className='pf-v6-u-pl-sm'
isInline
/>
</Popover>
</>
}
/>
<ModalBody>
<Form>
<FormGroup fieldId='checkbox-import-custom-repositories'>
<Checkbox
label='Import missing custom repositories after file upload.'
isChecked={isCheckedImportRepos}
onChange={() => setIsCheckedImportRepos((prev) => !prev)}
aria-label='Import Custom Repositories checkbox'
id='checkbox-import-custom-repositories'
name='Import Repositories'
/>
</FormGroup>
<FormGroup fieldId='import-blueprint-file-upload'>
<FileUpload
id='import-blueprint-file-upload'
type='text'
value={fileContent}
filename={filename}
filenamePlaceholder='Drag and drop a file or upload one'
onFileInputChange={handleFileInputChange}
onDataChange={handleDataChange}
onReadStarted={handleFileReadStarted}
onReadFinished={handleFileReadFinished}
onClearClick={handleClear}
isLoading={isLoading}
isReadOnly={true}
browseButtonText='Upload'
dropzoneProps={{
accept: { 'text/json': ['.json'], 'text/plain': ['.toml'] },
maxSize: 512000,
onDropRejected: handleFileRejected,
}}
validated={isRejected || isInvalidFormat ? 'error' : 'default'}
/>
<FormHelperText>
<HelperText>
<HelperTextItem variant={variantSwitch()}>
{isRejected
? 'Must be a valid Blueprint JSON/TOML file no larger than 512 KB'
: isInvalidFormat
? 'Not compatible with the blueprints format.'
: isOnPrem
? 'Importing on-premises blueprints is currently in beta. Results may vary.'
: 'Upload your blueprint file. Supported formats: JSON, TOML.'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
type='button'
isDisabled={isRejected || isInvalidFormat || !fileContent}
onClick={() =>
navigate(resolveRelPath(`imagewizard/import`), {
state: { blueprint: importedBlueprint },
})
}
>
Review and finish
</Button>
<Button variant='link' type='button' onClick={onImportClose}>
Cancel
</Button>
</ModalFooter>
<Form>
<FormGroup fieldId="checkbox-import-custom-repositories">
<Checkbox
label="Import missing custom repositories after file upload."
isChecked={isCheckedImportRepos}
onChange={() => setIsCheckedImportRepos((prev) => !prev)}
aria-label="Import Custom Repositories checkbox"
id="checkbox-import-custom-repositories"
name="Import Repositories"
/>
</FormGroup>
<FormGroup fieldId="import-blueprint-file-upload">
<FileUpload
id="import-blueprint-file-upload"
type="text"
value={fileContent}
filename={filename}
filenamePlaceholder="Drag and drop a file or upload one"
onFileInputChange={handleFileInputChange}
onDataChange={handleDataChange}
onReadStarted={handleFileReadStarted}
onReadFinished={handleFileReadFinished}
onClearClick={handleClear}
isLoading={isLoading}
isReadOnly={true}
browseButtonText="Upload"
dropzoneProps={{
accept: { 'text/json': ['.json'], 'text/plain': ['.toml'] },
maxSize: 512000,
onDropRejected: handleFileRejected,
}}
validated={isRejected || isInvalidFormat ? 'error' : 'default'}
/>
<FormHelperText>
<HelperText>
<HelperTextItem variant={variantSwitch()}>
{isRejected
? 'Must be a valid Blueprint JSON/TOML file no larger than 512 KB'
: isInvalidFormat
? 'Not compatible with the blueprints format.'
: isOnPrem
? 'Importing on-premises blueprints is currently in beta. Results may vary.'
: 'Upload your blueprint file. Supported formats: JSON, TOML.'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<ActionGroup>
<Button
type="button"
isDisabled={isRejected || isInvalidFormat || !fileContent}
onClick={() =>
navigate(resolveRelPath(`imagewizard/import`), {
state: { blueprint: importedBlueprint },
})
}
data-testid="import-blueprint-finish"
>
Review and finish
</Button>
<Button variant="link" type="button" onClick={onImportClose}>
Cancel
</Button>
</ActionGroup>
</Form>
</Modal>
);
};

View file

@ -101,13 +101,13 @@ export type SshKeyOnPrem = {
};
export const mapOnPremToHosted = (
blueprint: BlueprintOnPrem,
blueprint: BlueprintOnPrem
): BlueprintExportResponse => {
const users = blueprint.customizations?.user?.map((u) => ({
name: u.name,
ssh_key: u.key,
groups: u.groups,
isAdministrator: u.groups.includes('wheel') || false,
isAdministrator: u.groups?.includes('wheel') || false,
}));
const user_keys = blueprint.customizations?.sshkey?.map((k) => ({
name: k.user,
@ -132,7 +132,7 @@ export const mapOnPremToHosted = (
({ baseurls, ...fs }) => ({
baseurl: baseurls,
...fs,
}),
})
),
packages:
packages !== undefined || groups !== undefined
@ -147,7 +147,7 @@ export const mapOnPremToHosted = (
({ minsize, ...fs }) => ({
min_size: minsize,
...fs,
}),
})
),
fips:
blueprint.customizations?.fips !== undefined
@ -189,14 +189,14 @@ export const mapOnPremToHosted = (
};
export const mapHostedToOnPrem = (
blueprint: CreateBlueprintRequest,
blueprint: CreateBlueprintRequest
): CloudApiBlueprint => {
const result: CloudApiBlueprint = {
name: blueprint.name,
customizations: {},
};
if (blueprint.customizations.packages) {
if (blueprint.customizations?.packages) {
result.packages = blueprint.customizations.packages.map((pkg) => {
return {
name: pkg,
@ -205,30 +205,30 @@ export const mapHostedToOnPrem = (
});
}
if (blueprint.customizations.containers) {
if (blueprint.customizations?.containers) {
result.containers = blueprint.customizations.containers;
}
if (blueprint.customizations.directories) {
if (blueprint.customizations?.directories) {
result.customizations!.directories = blueprint.customizations.directories;
}
if (blueprint.customizations.files) {
if (blueprint.customizations?.files) {
result.customizations!.files = blueprint.customizations.files;
}
if (blueprint.customizations.filesystem) {
if (blueprint.customizations?.filesystem) {
result.customizations!.filesystem = blueprint.customizations.filesystem.map(
(fs) => {
return {
mountpoint: fs.mountpoint,
minsize: fs.min_size,
};
},
}
);
}
if (blueprint.customizations.users) {
if (blueprint.customizations?.users) {
result.customizations!.user = blueprint.customizations.users.map((u) => {
return {
name: u.name,
@ -239,54 +239,54 @@ export const mapHostedToOnPrem = (
});
}
if (blueprint.customizations.services) {
if (blueprint.customizations?.services) {
result.customizations!.services = blueprint.customizations.services;
}
if (blueprint.customizations.hostname) {
if (blueprint.customizations?.hostname) {
result.customizations!.hostname = blueprint.customizations.hostname;
}
if (blueprint.customizations.kernel) {
if (blueprint.customizations?.kernel) {
result.customizations!.kernel = blueprint.customizations.kernel;
}
if (blueprint.customizations.timezone) {
if (blueprint.customizations?.timezone) {
result.customizations!.timezone = blueprint.customizations.timezone;
}
if (blueprint.customizations.locale) {
if (blueprint.customizations?.locale) {
result.customizations!.locale = blueprint.customizations.locale;
}
if (blueprint.customizations.firewall) {
if (blueprint.customizations?.firewall) {
result.customizations!.firewall = blueprint.customizations.firewall;
}
if (blueprint.customizations.installation_device) {
if (blueprint.customizations?.installation_device) {
result.customizations!.installation_device =
blueprint.customizations.installation_device;
}
if (blueprint.customizations.fdo) {
if (blueprint.customizations?.fdo) {
result.customizations!.fdo = blueprint.customizations.fdo;
}
if (blueprint.customizations.ignition) {
if (blueprint.customizations?.ignition) {
result.customizations!.ignition = blueprint.customizations.ignition;
}
if (blueprint.customizations.partitioning_mode) {
if (blueprint.customizations?.partitioning_mode) {
result.customizations!.partitioning_mode =
blueprint.customizations.partitioning_mode;
}
if (blueprint.customizations.fips) {
if (blueprint.customizations?.fips) {
result.customizations!.fips =
blueprint.customizations.fips.enabled || false;
blueprint.customizations.fips?.enabled || false;
}
if (blueprint.customizations.installer) {
if (blueprint.customizations?.installer) {
result.customizations!.installer = blueprint.customizations.installer;
}

View file

@ -1,210 +0,0 @@
import React from 'react';
import {
Button,
Content,
Form,
FormGroup,
Popover,
Switch,
TextInput,
} from '@patternfly/react-core';
import { HelpIcon } from '@patternfly/react-icons';
import { isAwsBucketValid, isAwsCredsPathValid } from './validators';
import {
changeAWSBucketName,
changeAWSCredsPath,
reinitializeAWSConfig,
selectAWSBucketName,
selectAWSCredsPath,
} from '../../store/cloudProviderConfigSlice';
import {
AWSWorkerConfig,
WorkerConfigResponse,
} from '../../store/cockpit/types';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { ValidatedInput } from '../CreateImageWizard/ValidatedInput';
type FormGroupProps<T> = {
value: T | undefined;
onChange: (value: T) => void;
isDisabled?: boolean;
};
type ToggleGroupProps = Omit<FormGroupProps<boolean>, 'isDisabled'>;
const AWSConfigToggle = ({ value, onChange }: ToggleGroupProps) => {
const handleChange = (
_event: React.FormEvent<HTMLInputElement>,
checked: boolean,
) => {
onChange(checked);
};
return (
<FormGroup label='Configure AWS Uploads'>
<Switch
id='aws-config-switch'
ouiaId='aws-config-switch'
aria-label='aws-config-switch'
// empty label so there is no icon
label=''
isChecked={value}
onChange={handleChange}
/>
</FormGroup>
);
};
const DisabledInputGroup = ({
value,
label,
ariaLabel,
}: {
value: string | undefined;
label: React.ReactNode;
ariaLabel: string;
}) => {
return (
<FormGroup label={label}>
<TextInput aria-label={ariaLabel} value={value || ''} isDisabled />
</FormGroup>
);
};
const AWSBucket = ({ value, onChange, isDisabled }: FormGroupProps<string>) => {
const label = 'AWS Bucket';
if (isDisabled) {
return (
<DisabledInputGroup label={label} value={value} ariaLabel='aws-bucket' />
);
}
return (
<FormGroup label={label}>
<ValidatedInput
placeholder='AWS bucket'
ariaLabel='aws-bucket'
value={value || ''}
validator={isAwsBucketValid}
onChange={(_event, value) => onChange(value)}
helperText='Invalid AWS bucket name'
/>
</FormGroup>
);
};
const CredsPathPopover = () => {
return (
<Popover
minWidth='35rem'
headerContent={'What is the AWS Credentials Path?'}
bodyContent={
<Content>
<Content>
This is the path to your AWS credentials file which contains your
aws access key id and secret access key. This path to the file is
normally in the home directory in the credentials file in the .aws
directory, <br /> i.e. /home/USERNAME/.aws/credentials
</Content>
</Content>
}
>
<Button
icon={<HelpIcon />}
variant='plain'
aria-label='Credentials Path Info'
className='pf-v6-u-pl-sm header-button'
/>
</Popover>
);
};
const AWSCredsPath = ({
value,
onChange,
isDisabled,
}: FormGroupProps<string>) => {
const label = (
<>
AWS Credentials Filepath <CredsPathPopover />
</>
);
if (isDisabled) {
return (
<DisabledInputGroup
value={value}
label={label}
ariaLabel='aws-creds-path'
/>
);
}
return (
<FormGroup label={label}>
<ValidatedInput
placeholder='Path to AWS credentials'
ariaLabel='aws-creds-path'
value={value || ''}
validator={isAwsCredsPathValid}
onChange={(_event, value) => onChange(value)}
helperText='Invalid filepath for AWS credentials'
/>
</FormGroup>
);
};
type AWSConfigProps = {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
reinit: (config: AWSWorkerConfig | undefined) => void;
refetch: () => Promise<{
data?: WorkerConfigResponse | undefined;
}>;
};
export const AWSConfig = ({
enabled,
setEnabled,
refetch,
reinit,
}: AWSConfigProps) => {
const dispatch = useAppDispatch();
const bucket = useAppSelector(selectAWSBucketName);
const credentials = useAppSelector(selectAWSCredsPath);
const onToggle = async (v: boolean) => {
if (v) {
try {
const { data } = await refetch();
reinit(data?.aws);
setEnabled(v);
return;
} catch {
return;
}
}
dispatch(reinitializeAWSConfig());
setEnabled(v);
};
return (
<Form>
<AWSConfigToggle value={enabled} onChange={onToggle} />
<AWSBucket
value={bucket}
onChange={(v) => dispatch(changeAWSBucketName(v))}
isDisabled={!enabled}
/>
<AWSCredsPath
value={credentials}
onChange={(v) => dispatch(changeAWSCredsPath(v))}
isDisabled={!enabled}
/>
</Form>
);
};

View file

@ -1,146 +0,0 @@
import React, {
MouseEventHandler,
useCallback,
useEffect,
useState,
} from 'react';
import {
Button,
EmptyState,
EmptyStateActions,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
PageSection,
Skeleton,
Title,
Wizard,
WizardStep,
} from '@patternfly/react-core';
import { ExclamationIcon } from '@patternfly/react-icons';
import { useNavigate } from 'react-router-dom';
import { AWSConfig } from './AWSConfig';
import { isAwsStepValid } from './validators';
import {
changeAWSBucketName,
changeAWSCredsPath,
reinitializeAWSConfig,
selectAWSConfig,
} from '../../store/cloudProviderConfigSlice';
import {
useGetWorkerConfigQuery,
useUpdateWorkerConfigMutation,
} from '../../store/cockpit/cockpitApi';
import { AWSWorkerConfig } from '../../store/cockpit/types';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { resolveRelPath } from '../../Utilities/path';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
const ConfigError = ({
onClose,
}: {
onClose: MouseEventHandler<HTMLButtonElement>;
}) => {
return (
<EmptyState
variant={EmptyStateVariant.xl}
icon={ExclamationIcon}
color='#C9190B'
>
<Title headingLevel='h4' size='lg'>
Error
</Title>
<EmptyStateBody>
There was an error reading the `/etc/osbuild-worker/osbuild-worker.toml`
config file
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button variant='primary' onClick={onClose}>
Go back
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
);
};
export const CloudProviderConfig = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const config = useAppSelector(selectAWSConfig);
const handleClose = () => navigate(resolveRelPath(''));
const [enabled, setEnabled] = useState<boolean>(false);
const [updateConfig] = useUpdateWorkerConfigMutation();
const { data, error, refetch, isLoading } = useGetWorkerConfigQuery({});
const initAWSConfig = useCallback(
(config: AWSWorkerConfig | undefined) => {
if (!config) {
dispatch(reinitializeAWSConfig());
setEnabled(false);
return;
}
setEnabled(true);
const { bucket, credentials } = config;
if (bucket && bucket !== '') {
dispatch(changeAWSBucketName(bucket));
}
if (credentials && credentials !== '') {
dispatch(changeAWSCredsPath(credentials));
}
},
[dispatch, setEnabled],
);
useEffect(() => {
initAWSConfig(data?.aws);
}, [data, initAWSConfig]);
if (isLoading) {
return <Skeleton />;
}
if (error) {
return <ConfigError onClose={handleClose} />;
}
return (
<>
<ImageBuilderHeader inWizard={true} />
<PageSection>
<Wizard onClose={handleClose}>
<WizardStep
name='AWS Config'
id='aws-config'
footer={{
nextButtonText: 'Submit',
isNextDisabled: !isAwsStepValid(config),
isBackDisabled: true,
onNext: () => {
updateConfig({
updateWorkerConfigRequest: { aws: config },
});
navigate(resolveRelPath(''));
},
}}
>
<AWSConfig
refetch={refetch}
reinit={initAWSConfig}
enabled={enabled}
setEnabled={setEnabled}
/>
</WizardStep>
</Wizard>
</PageSection>
</>
);
};

View file

@ -1,37 +0,0 @@
import path from 'path';
import { AWSWorkerConfig } from '../../../store/cockpit/types';
export const isAwsBucketValid = (bucket?: string): boolean => {
if (!bucket || bucket === '') {
return false;
}
const regex = /^[a-z0-9](?:[a-z0-9]|[-.](?=[a-z0-9])){1,61}[a-z0-9]$/;
return regex.test(bucket);
};
export const isAwsCredsPathValid = (credsPath?: string): boolean => {
if (!credsPath || credsPath === '') {
return false;
}
const validPathPattern = /^(\/[^/\0]*)+\/?$/;
return path.isAbsolute(credsPath) && validPathPattern.test(credsPath);
};
export const isAwsStepValid = (
config: AWSWorkerConfig | undefined,
): boolean => {
if (!config) {
return true;
}
if (!config.bucket && !config.credentials) {
return false;
}
return (
isAwsBucketValid(config.bucket) && isAwsCredsPathValid(config.credentials)
);
};

View file

@ -5,6 +5,8 @@ import {
EmptyState,
EmptyStateActions,
EmptyStateFooter,
EmptyStateHeader,
EmptyStateIcon,
EmptyStateVariant,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
@ -12,16 +14,16 @@ import cockpit from 'cockpit';
export const NotReady = ({ enabled }: { enabled: boolean }) => {
return (
<EmptyState
headingLevel='h4'
icon={CubesIcon}
titleText={`OSBuild Composer is not ${enabled ? 'started' : 'enabled'}`}
variant={EmptyStateVariant.xl}
>
<EmptyState variant={EmptyStateVariant.xl}>
<EmptyStateHeader
titleText={`OSBuild Composer is not ${enabled ? 'started' : 'enabled'}`}
headingLevel="h4"
icon={<EmptyStateIcon icon={CubesIcon} />}
/>
<EmptyStateFooter>
<EmptyStateActions>
<Button
variant='primary'
variant="primary"
onClick={(event) => {
event.preventDefault();
cockpit
@ -30,7 +32,7 @@ export const NotReady = ({ enabled }: { enabled: boolean }) => {
{
superuser: 'require',
err: 'message',
},
}
)
.then(() => window.location.reload());
}}
@ -40,12 +42,12 @@ export const NotReady = ({ enabled }: { enabled: boolean }) => {
</EmptyStateActions>
<EmptyStateActions>
<Button
variant='link'
variant="link"
onClick={(event) => {
event.preventDefault();
cockpit.jump(
'/system/services#/osbuild-composer.socket',
cockpit.transport.host,
cockpit.transport.host
);
}}
>

View file

@ -3,18 +3,20 @@ import React from 'react';
import {
EmptyState,
EmptyStateBody,
EmptyStateHeader,
EmptyStateIcon,
EmptyStateVariant,
} from '@patternfly/react-core';
import { LockIcon } from '@patternfly/react-icons';
export const RequireAdmin = () => {
return (
<EmptyState
headingLevel='h4'
icon={LockIcon}
titleText='Access is limited.'
variant={EmptyStateVariant.xl}
>
<EmptyState variant={EmptyStateVariant.xl}>
<EmptyStateHeader
titleText="Access is limited."
headingLevel="h4"
icon={<EmptyStateIcon icon={LockIcon} color="#f4c145" />}
/>
<EmptyStateBody>
Administrative access is required to run the Image Builder frontend.
Click on the icon in the toolbar to grant administrative access.

View file

@ -1,4 +1,4 @@
.pf-v6-c-wizard__nav-list {
.pf-v5-c-wizard__nav-list {
padding-right: 0px;
}
@ -10,24 +10,45 @@
}
.pf-c-form {
--pf-c-form--GridGap: var(--pf6-global--spacer--md);
--pf-c-form--GridGap: var(--pf-v5-global--spacer--md);
}
.pf-c-form__group-label {
--pf-c-form__group-label--PaddingBottom: var(--pf-v6-global--spacer--xs);
--pf-c-form__group-label--PaddingBottom: var(--pf-v5-global--spacer--xs);
}
.tiles {
display: flex;
}
.tile {
flex: 1 0 0px;
max-width: 250px;
}
.pf-c-tile:focus {
--pf-c-tile__title--Color: var(--pf-c-tile__title--Color);
--pf-c-tile__icon--Color: var(---pf-v5-global--Color--100);
--pf-c-tile--before--BorderWidth: var(--pf-v5-global--BorderWidth--sm);
--pf-c-tile--before--BorderColor: var(--pf-v5-global--BorderColor--100);
}
.pf-c-tile.pf-m-selected:focus {
--pf-c-tile__title--Color: var(--pf-c-tile--focus__title--Color);
--pf-c-tile__icon--Color: var(--pf-c-tile--focus__icon--Color);
}
.provider-icon {
width: 3.5em;
height: 3.5em;
width: 1em;
height: 1em;
}
.pf-v6-u-min-width {
--pf-v6-u-min-width--MinWidth: 18ch;
.pf-v5-u-min-width {
--pf-v5-u-min-width--MinWidth: 18ch;
}
.pf-v6-u-max-width {
--pf-v6-u-max-width--MaxWidth: 26rem;
.pf-v5-u-max-width {
--pf-v5-u-max-width--MaxWidth: 26rem;
}
ul.pf-m-plain {
@ -41,12 +62,12 @@ ul.pf-m-plain {
}
.panel-border {
--pf-v6-c-panel--before--BorderColor: #BEE1F4;
--pf-v5-c-panel--before--BorderColor: #BEE1F4;
}
// Targets the alert within the Reviewsteps > content dropdown
// Removes excess top margin padding
div.pf-v6-c-alert.pf-m-inline.pf-m-plain.pf-m-warning {
div.pf-v5-c-alert.pf-m-inline.pf-m-plain.pf-m-warning {
margin-top: 18px;
h4 {
@ -54,7 +75,14 @@ div.pf-v6-c-alert.pf-m-inline.pf-m-plain.pf-m-warning {
}
}
// Ensures the wizard takes up the entire height of the page in Firefox as well
.pf-v6-c-wizard {
flex: 1;
.pf-v5-c-wizard__main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.pf-v5-c-wizard__footer {
position: sticky;
bottom: 0;
}

View file

@ -2,23 +2,20 @@ import React, { useEffect, useState } from 'react';
import {
Button,
Flex,
PageSection,
PageSectionTypes,
useWizardContext,
Wizard,
WizardFooterWrapper,
WizardNavItem,
WizardStep,
useWizardContext,
PageSection,
} from '@patternfly/react-core';
import { WizardStepType } from '@patternfly/react-core/dist/esm/components/Wizard';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AAPStep from './steps/AAP';
import DetailsStep from './steps/Details';
import FileSystemStep from './steps/FileSystem';
import { FileSystemContext } from './steps/FileSystem/components/FileSystemTable';
import { FileSystemContext } from './steps/FileSystem/FileSystemTable';
import FirewallStep from './steps/Firewall';
import FirstBootStep from './steps/FirstBoot';
import HostnameStep from './steps/Hostname';
@ -39,59 +36,59 @@ import Gcp from './steps/TargetEnvironment/Gcp';
import TimezoneStep from './steps/Timezone';
import UsersStep from './steps/Users';
import { getHostArch, getHostDistro } from './utilities/getHostInfo';
import { useHasSpecificTargetOnly } from './utilities/hasSpecificTargetOnly';
import {
useAAPValidation,
useDetailsValidation,
useFilesystemValidation,
useFirewallValidation,
useSnapshotValidation,
useFirstBootValidation,
useDetailsValidation,
useRegistrationValidation,
useHostnameValidation,
useKernelValidation,
useLocaleValidation,
useRegistrationValidation,
useServicesValidation,
useSnapshotValidation,
useTimezoneValidation,
useUsersValidation,
useTimezoneValidation,
useFirewallValidation,
useServicesValidation,
useLocaleValidation,
} from './utilities/useValidation';
import {
isAwsAccountIdValid,
isAzureResourceGroupValid,
isAzureSubscriptionIdValid,
isAzureTenantGUIDValid,
isAzureSubscriptionIdValid,
isAzureResourceGroupValid,
isGcpEmailValid,
} from './validators';
import {
AARCH64,
AMPLITUDE_MODULE_NAME,
RHEL_10,
RHEL_8,
RHEL_9,
RHEL_10_BETA,
AARCH64,
CENTOS_9,
AMPLITUDE_MODULE_NAME,
} from '../../constants';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import './CreateImageWizard.scss';
import {
addImageType,
changeArchitecture,
changeAwsShareMethod,
changeDistribution,
changeArchitecture,
initializeWizard,
selectAwsAccountId,
selectAwsShareMethod,
selectAwsSourceId,
selectAzureResourceGroup,
selectAzureShareMethod,
selectAzureSource,
selectAzureSubscriptionId,
selectAzureTenantId,
selectDistribution,
selectGcpEmail,
selectGcpShareMethod,
selectImageTypes,
addImageType,
changeRegistrationType,
} from '../../store/wizardSlice';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
import { useFlag } from '../../Utilities/useGetEnvironment';
import { useFlag, useGetEnvironment } from '../../Utilities/useGetEnvironment';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
type CustomWizardFooterPropType = {
@ -116,73 +113,71 @@ export const CustomWizardFooter = ({
const cancelBtnID = 'wizard-cancel-btn';
return (
<WizardFooterWrapper>
<Flex columnGap={{ default: 'columnGapSm' }}>
<Button
variant="primary"
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: nextBtnID,
active_step_id: activeStep.id,
});
}
if (!beforeNext || beforeNext()) goToNextStep();
}}
isDisabled={disableNext}
>
Next
</Button>
<Button
variant="secondary"
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: backBtnID,
active_step_id: activeStep.id,
});
}
goToPrevStep();
}}
isDisabled={disableBack || false}
>
Back
</Button>
{optional && (
<Button
variant='primary'
variant="tertiary"
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: nextBtnID,
button_id: reviewAndFinishBtnID,
active_step_id: activeStep.id,
});
}
if (!beforeNext || beforeNext()) goToNextStep();
if (!beforeNext || beforeNext()) goToStepById('step-review');
}}
isDisabled={disableNext}
>
Next
Review and finish
</Button>
<Button
variant='secondary'
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: backBtnID,
active_step_id: activeStep.id,
});
}
goToPrevStep();
}}
isDisabled={disableBack || false}
>
Back
</Button>
{optional && (
<Button
variant='tertiary'
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: reviewAndFinishBtnID,
active_step_id: activeStep.id,
});
}
if (!beforeNext || beforeNext()) goToStepById('step-review');
}}
isDisabled={disableNext}
>
Review and finish
</Button>
)}
<Button
variant='link'
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: cancelBtnID,
active_step_id: activeStep.id,
});
}
close();
}}
>
Cancel
</Button>
</Flex>
)}
<Button
variant="link"
onClick={() => {
if (!process.env.IS_ON_PREMISE) {
analytics.track(`${AMPLITUDE_MODULE_NAME} - Button Clicked`, {
module: AMPLITUDE_MODULE_NAME,
button_id: cancelBtnID,
active_step_id: activeStep.id,
});
}
close();
}}
>
Cancel
</Button>
</WizardFooterWrapper>
);
};
@ -196,22 +191,23 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [searchParams] = useSearchParams();
const { isFedoraEnv } = useGetEnvironment();
// Feature flags
const complianceEnabled = useFlag('image-builder.compliance.enabled');
const isAAPRegistrationEnabled = useFlag('image-builder.aap.enabled');
const isUsersEnabled = useFlag('image-builder.users.enabled');
// IMPORTANT: Ensure the wizard starts with a fresh initial state
useEffect(() => {
dispatch(initializeWizard());
if (isFedoraEnv) {
dispatch(changeDistribution(CENTOS_9));
dispatch(changeRegistrationType('register-later'));
}
if (searchParams.get('release') === 'rhel8') {
dispatch(changeDistribution(RHEL_8));
}
if (searchParams.get('release') === 'rhel9') {
dispatch(changeDistribution(RHEL_9));
}
if (searchParams.get('release') === 'rhel10') {
dispatch(changeDistribution(RHEL_10));
if (searchParams.get('release') === 'rhel10beta') {
dispatch(changeDistribution(RHEL_10_BETA));
}
if (searchParams.get('arch') === AARCH64) {
dispatch(changeArchitecture(AARCH64));
@ -233,10 +229,6 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
dispatch(changeArchitecture(arch));
};
if (process.env.IS_ON_PREMISE) {
dispatch(changeAwsShareMethod('manual'));
}
if (process.env.IS_ON_PREMISE && !isEdit) {
if (!searchParams.get('release')) {
initializeHostDistro();
@ -264,9 +256,11 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const gcpShareMethod = useAppSelector(selectGcpShareMethod);
const gcpEmail = useAppSelector(selectGcpEmail);
// AZURE
const azureShareMethod = useAppSelector(selectAzureShareMethod);
const azureTenantId = useAppSelector(selectAzureTenantId);
const azureSubscriptionId = useAppSelector(selectAzureSubscriptionId);
const azureResourceGroup = useAppSelector(selectAzureResourceGroup);
const azureSource = useAppSelector(selectAzureSource);
// Registration
const registrationValidation = useRegistrationValidation();
// Snapshots
@ -286,8 +280,6 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const firewallValidation = useFirewallValidation();
// Services
const servicesValidation = useServicesValidation();
// AAP
const aapValidation = useAAPValidation();
// Firstboot
const firstBootValidation = useFirstBootValidation();
// Details
@ -295,51 +287,45 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
// Users
const usersValidation = useUsersValidation();
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
let startIndex = 1; // default index
const JUMP_TO_REVIEW_STEP = 23;
if (isEdit) {
startIndex = JUMP_TO_REVIEW_STEP;
startIndex = 22;
}
const [wasRegisterVisited, setWasRegisterVisited] = useState(false);
// Duplicating some of the logic from the Wizard component to allow for custom nav items status
// for original code see https://github.com/patternfly/patternfly-react/blob/184c55f8d10e1d94ffd72e09212db56c15387c5e/packages/react-core/src/components/Wizard/WizardNavInternal.tsx#L128
const CustomStatusNavItem = (
const customStatusNavItem = (
step: WizardStepType,
activeStep: WizardStepType,
steps: WizardStepType[],
goToStepByIndex: (index: number) => void,
goToStepByIndex: (index: number) => void
) => {
const isVisitOptional =
'parentId' in step && step.parentId === 'step-optional-steps';
useEffect(() => {
if (process.env.IS_ON_PREMISE) {
if (step.id === 'step-oscap' && step.isVisited) {
setWasRegisterVisited(true);
}
} else if (step.id === 'step-register' && step.isVisited) {
if (process.env.IS_ON_PREMISE) {
if (step.id === 'step-oscap' && step.isVisited) {
setWasRegisterVisited(true);
}
}, [step.id, step.isVisited]);
} else if (step.id === 'step-register' && step.isVisited) {
setWasRegisterVisited(true);
}
const hasVisitedNextStep = steps.some(
(s) => s.index > step.index && s.isVisited,
(s) => s.index > step.index && s.isVisited
);
// Only this code is different from the original
const status = (step.id !== activeStep.id && step.status) || 'default';
const status = (step?.id !== activeStep?.id && step?.status) || 'default';
return (
<WizardNavItem
key={step.id}
id={step.id}
key={step?.id}
id={step?.id}
content={step.name}
isCurrent={activeStep.id === step.id}
isCurrent={activeStep?.id === step?.id}
isDisabled={
step.isDisabled ||
(!step.isVisited &&
@ -356,7 +342,7 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
{
module: AMPLITUDE_MODULE_NAME,
isPreview: isBeta(),
},
}
);
}
}}
@ -368,15 +354,15 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
return (
<>
<ImageBuilderHeader inWizard />
<PageSection hasBodyWrapper={false} type={PageSectionTypes.wizard}>
<PageSection>
<Wizard
startIndex={startIndex}
onClose={() => navigate(resolveRelPath(''))}
isVisitRequired
>
<WizardStep
name='Image output'
id='step-image-output'
name="Image output"
id="step-image-output"
footer={
<CustomWizardFooter
disableNext={targetEnvironments.length === 0}
@ -387,29 +373,25 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<ImageOutputStep />
</WizardStep>
<WizardStep
name='Target Environment'
id='step-target-environment'
name="Target Environment"
id="step-target-environment"
isHidden={
!targetEnvironments.find(
(target: string) =>
target === 'aws' || target === 'gcp' || target === 'azure',
(target) =>
target === 'aws' || target === 'gcp' || target === 'azure'
)
}
steps={[
<WizardStep
name='Amazon Web Services'
id='wizard-target-aws'
key='wizard-target-aws'
name="Amazon Web Services"
id="wizard-target-aws"
key="wizard-target-aws"
footer={
<CustomWizardFooter
disableNext={
// we don't need the account id for
// on-prem aws.
process.env.IS_ON_PREMISE
? false
: awsShareMethod === 'manual'
? !isAwsAccountIdValid(awsAccountId)
: awsSourceId === undefined
awsShareMethod === 'manual'
? !isAwsAccountIdValid(awsAccountId)
: awsSourceId === undefined
}
/>
}
@ -418,9 +400,9 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<Aws />
</WizardStep>,
<WizardStep
name='Google Cloud Platform'
id='wizard-target-gcp'
key='wizard-target-gcp'
name="Google Cloud Platform"
id="wizard-target-gcp"
key="wizard-target-gcp"
footer={
<CustomWizardFooter
disableNext={
@ -434,15 +416,21 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<Gcp />
</WizardStep>,
<WizardStep
name='Azure'
id='wizard-target-azure'
key='wizard-target-azure'
name="Azure"
id="wizard-target-azure"
key="wizard-target-azure"
footer={
<CustomWizardFooter
disableNext={
!isAzureTenantGUIDValid(azureTenantId) ||
!isAzureSubscriptionIdValid(azureSubscriptionId) ||
!isAzureResourceGroupValid(azureResourceGroup)
azureShareMethod === 'manual'
? !isAzureTenantGUIDValid(azureTenantId) ||
!isAzureSubscriptionIdValid(azureSubscriptionId) ||
!isAzureResourceGroupValid(azureResourceGroup)
: azureShareMethod === 'sources'
? !isAzureTenantGUIDValid(azureTenantId) ||
!isAzureSubscriptionIdValid(azureSubscriptionId) ||
!isAzureResourceGroupValid(azureResourceGroup)
: azureSource === undefined
}
/>
}
@ -453,15 +441,15 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
]}
/>
<WizardStep
name='Optional steps'
id='step-optional-steps'
name="Optional steps"
id="step-optional-steps"
steps={[
<WizardStep
name='Register'
id='step-register'
key='step-register'
name="Register"
id="step-register"
key="step-register"
isHidden={!!process.env.IS_ON_PREMISE || !isRhel(distribution)}
navItem={CustomStatusNavItem}
navItem={customStatusNavItem}
status={
wasRegisterVisited
? registrationValidation.disabledNext
@ -480,9 +468,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
</WizardStep>,
<WizardStep
name={complianceEnabled ? 'Compliance' : 'OpenSCAP'}
id='step-oscap'
key='step-oscap'
navItem={CustomStatusNavItem}
id="step-oscap"
key="step-oscap"
isHidden={distribution === RHEL_10_BETA}
navItem={customStatusNavItem}
footer={
<CustomWizardFooter disableNext={false} optional={true} />
}
@ -490,11 +479,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<OscapStep />
</WizardStep>,
<WizardStep
name='File system configuration'
id='step-file-system'
key='step-file-system'
navItem={CustomStatusNavItem}
isHidden={hasWslTargetOnly}
name="File system configuration"
id="step-file-system"
key="step-file-system"
navItem={customStatusNavItem}
footer={
<CustomWizardFooter
beforeNext={() => {
@ -516,12 +504,16 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
</FileSystemContext.Provider>
</WizardStep>,
<WizardStep
name='Repeatable build'
id='wizard-repository-snapshot'
key='wizard-repository-snapshot'
navItem={CustomStatusNavItem}
name="Repeatable build"
id="wizard-repository-snapshot"
key="wizard-repository-snapshot"
navItem={customStatusNavItem}
status={snapshotValidation.disabledNext ? 'error' : 'default'}
isHidden={!!process.env.IS_ON_PREMISE}
isHidden={
distribution === RHEL_10_BETA ||
!!process.env.IS_ON_PREMISE ||
isFedoraEnv
}
footer={
<CustomWizardFooter
disableNext={snapshotValidation.disabledNext}
@ -532,11 +524,15 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<SnapshotStep />
</WizardStep>,
<WizardStep
name='Custom repositories'
id='wizard-custom-repositories'
key='wizard-custom-repositories'
navItem={CustomStatusNavItem}
isHidden={!!process.env.IS_ON_PREMISE}
name="Custom repositories"
id="wizard-custom-repositories"
key="wizard-custom-repositories"
navItem={customStatusNavItem}
isHidden={
distribution === RHEL_10_BETA ||
!!process.env.IS_ON_PREMISE ||
isFedoraEnv
}
isDisabled={snapshotValidation.disabledNext}
footer={
<CustomWizardFooter disableNext={false} optional={true} />
@ -545,10 +541,11 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<RepositoriesStep />
</WizardStep>,
<WizardStep
name='Additional packages'
id='wizard-additional-packages'
key='wizard-additional-packages'
navItem={CustomStatusNavItem}
name="Additional packages"
id="wizard-additional-packages"
key="wizard-additional-packages"
navItem={customStatusNavItem}
isHidden={isFedoraEnv}
isDisabled={snapshotValidation.disabledNext}
footer={
<CustomWizardFooter disableNext={false} optional={true} />
@ -557,10 +554,11 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<PackagesStep />
</WizardStep>,
<WizardStep
name='Users'
id='wizard-users'
key='wizard-users'
navItem={CustomStatusNavItem}
name="Users"
id="wizard-users"
key="wizard-users"
navItem={customStatusNavItem}
isHidden={!isUsersEnabled}
status={usersValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -572,10 +570,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<UsersStep />
</WizardStep>,
<WizardStep
name='Timezone'
id='wizard-timezone'
key='wizard-timezone'
navItem={CustomStatusNavItem}
name="Timezone"
id="wizard-timezone"
key="wizard-timezone"
navItem={customStatusNavItem}
status={timezoneValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -587,10 +585,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<TimezoneStep />
</WizardStep>,
<WizardStep
name='Locale'
id='wizard-locale'
key='wizard-locale'
navItem={CustomStatusNavItem}
name="Locale"
id="wizard-locale"
key="wizard-locale"
navItem={customStatusNavItem}
status={localeValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -602,10 +600,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<LocaleStep />
</WizardStep>,
<WizardStep
name='Hostname'
id='wizard-hostname'
key='wizard-hostname'
navItem={CustomStatusNavItem}
name="Hostname"
id="wizard-hostname"
key="wizard-hostname"
navItem={customStatusNavItem}
status={hostnameValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -617,11 +615,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<HostnameStep />
</WizardStep>,
<WizardStep
name='Kernel'
id='wizard-kernel'
key='wizard-kernel'
navItem={CustomStatusNavItem}
isHidden={hasWslTargetOnly}
name="Kernel"
id="wizard-kernel"
key="wizard-kernel"
navItem={customStatusNavItem}
status={kernelValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -633,10 +630,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<KernelStep />
</WizardStep>,
<WizardStep
name='Firewall'
id='wizard-firewall'
key='wizard-firewall'
navItem={CustomStatusNavItem}
name="Firewall"
id="wizard-firewall"
key="wizard-firewall"
navItem={customStatusNavItem}
status={firewallValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -648,10 +645,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<FirewallStep />
</WizardStep>,
<WizardStep
name='Systemd services'
id='wizard-services'
key='wizard-services'
navItem={CustomStatusNavItem}
name="Systemd services"
id="wizard-services"
key="wizard-services"
navItem={customStatusNavItem}
status={servicesValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -663,28 +660,12 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<ServicesStep />
</WizardStep>,
<WizardStep
name='Ansible Automation Platform'
id='wizard-aap'
isHidden={!isAAPRegistrationEnabled}
key='wizard-aap'
navItem={CustomStatusNavItem}
status={aapValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
disableNext={aapValidation.disabledNext}
optional={true}
/>
}
>
<AAPStep />
</WizardStep>,
<WizardStep
name='First boot script configuration'
id='wizard-first-boot'
key='wizard-first-boot'
navItem={CustomStatusNavItem}
name="First boot script configuration"
id="wizard-first-boot"
key="wizard-first-boot"
navItem={customStatusNavItem}
status={firstBootValidation.disabledNext ? 'error' : 'default'}
isHidden={!!process.env.IS_ON_PREMISE}
isHidden={!!process.env.IS_ON_PREMISE || isFedoraEnv}
footer={
<CustomWizardFooter
disableNext={firstBootValidation.disabledNext}
@ -697,9 +678,9 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
]}
/>
<WizardStep
name='Details'
id='step-details'
navItem={CustomStatusNavItem}
name="Details"
id="step-details"
navItem={customStatusNavItem}
status={detailsValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
@ -710,8 +691,8 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
<DetailsStep />
</WizardStep>
<WizardStep
name='Review'
id='step-review'
name="Review"
id="step-review"
footer={<ReviewWizardFooter />}
>
<ReviewStep />

View file

@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useAddNotification } from '@redhat-cloud-services/frontend-components-notifications/hooks';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useLocation, useNavigate } from 'react-router-dom';
import CreateImageWizard from './CreateImageWizard';
@ -13,18 +13,19 @@ const ImportImageWizard = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const addNotification = useAddNotification();
const locationState = location.state as { blueprint?: wizardState };
const blueprint = locationState.blueprint;
const blueprint = locationState?.blueprint;
useEffect(() => {
if (blueprint) {
dispatch(loadWizardState(blueprint));
} else {
navigate(resolveRelPath(''));
addNotification({
variant: 'warning',
title: 'No blueprint was imported',
});
dispatch(
addNotification({
variant: 'warning',
title: 'No blueprint was imported',
})
);
}
}, [blueprint, dispatch]);
return <CreateImageWizard />;

View file

@ -4,7 +4,6 @@ import {
Button,
HelperText,
HelperTextItem,
Icon,
Label,
LabelGroup,
TextInputGroup,
@ -48,73 +47,30 @@ const LabelInput = ({
const dispatch = useAppDispatch();
const [inputValue, setInputValue] = useState('');
const [onStepInputErrorText, setOnStepInputErrorText] = useState('');
let [invalidImports, duplicateImports] = ['', ''];
if (stepValidation.errors[fieldName]) {
[invalidImports, duplicateImports] =
stepValidation.errors[fieldName].split('|');
}
const [errorText, setErrorText] = useState(stepValidation.errors[fieldName]);
const onTextInputChange = (
_event: React.FormEvent<HTMLInputElement>,
value: string,
value: string
) => {
setInputValue(value);
setOnStepInputErrorText('');
setErrorText('');
};
const addItem = (value: string) => {
if (list?.includes(value) || requiredList?.includes(value)) {
setOnStepInputErrorText(`${item} already exists.`);
setErrorText(`${item} already exists.`);
return;
}
if (!validator(value)) {
switch (fieldName) {
case 'ports':
setOnStepInputErrorText(
'Expected format: <port/port-name>:<protocol>. Example: 8080:tcp, ssh:tcp',
);
break;
case 'kernelAppend':
setOnStepInputErrorText(
'Expected format: <kernel-argument>. Example: console=tty0',
);
break;
case 'kernelName':
setOnStepInputErrorText(
'Expected format: <kernel-name>. Example: kernel-5.14.0-284.11.1.el9_2.x86_64',
);
break;
case 'groups':
setOnStepInputErrorText(
'Expected format: <group-name>. Example: admin',
);
break;
case 'ntpServers':
setOnStepInputErrorText(
'Expected format: <ntp-server>. Example: time.redhat.com',
);
break;
case 'enabledSystemdServices':
case 'disabledSystemdServices':
case 'maskedSystemdServices':
case 'disabledServices':
case 'enabledServices':
setOnStepInputErrorText(
'Expected format: <service-name>. Example: sshd',
);
break;
default:
setOnStepInputErrorText('Invalid format.');
}
setErrorText('Invalid format.');
return;
}
dispatch(addAction(value));
setInputValue('');
setOnStepInputErrorText('');
setErrorText('');
};
const handleKeyDown = (e: React.KeyboardEvent, value: string) => {
@ -130,18 +86,14 @@ const LabelInput = ({
const handleRemoveItem = (e: React.MouseEvent, value: string) => {
dispatch(removeAction(value));
setErrorText('');
};
const handleClear = () => {
setInputValue('');
setOnStepInputErrorText('');
setErrorText('');
};
const errors = [];
if (onStepInputErrorText) errors.push(onStepInputErrorText);
if (invalidImports) errors.push(invalidImports);
if (duplicateImports) errors.push(duplicateImports);
return (
<>
<TextInputGroup>
@ -153,39 +105,33 @@ const LabelInput = ({
/>
<TextInputGroupUtilities>
<Button
icon={
<Icon status='info'>
<PlusCircleIcon />
</Icon>
}
variant='plain'
variant="plain"
onClick={(e) => handleAddItem(e, inputValue)}
isDisabled={!inputValue}
aria-label={ariaLabel}
/>
>
<PlusCircleIcon className="pf-v5-u-primary-color-100" />
</Button>
<Button
icon={<TimesIcon />}
variant='plain'
variant="plain"
onClick={handleClear}
isDisabled={!inputValue}
aria-label='Clear input'
/>
aria-label="Clear input"
>
<TimesIcon />
</Button>
</TextInputGroupUtilities>
</TextInputGroup>
{errors.length > 0 && (
{errorText && (
<HelperText>
{errors.map((error, index) => (
<HelperTextItem key={index} variant={'error'}>
{error}
</HelperTextItem>
))}
<HelperTextItem variant={'error'}>{errorText}</HelperTextItem>
</HelperText>
)}
{requiredList && requiredList.length > 0 && (
<LabelGroup
categoryName={requiredCategoryName}
numLabels={20}
className='pf-v6-u-mt-sm pf-v6-u-w-100'
className="pf-v5-u-mt-sm pf-v5-u-w-100"
>
{requiredList.map((item) => (
<Label key={item} isCompact>
@ -194,7 +140,7 @@ const LabelInput = ({
))}
</LabelGroup>
)}
<LabelGroup numLabels={20} className='pf-v6-u-mt-sm pf-v6-u-w-100'>
<LabelGroup numLabels={20} className="pf-v5-u-mt-sm pf-v5-u-w-100">
{list?.map((item) => (
<Label
key={item}

View file

@ -5,8 +5,8 @@ import { Alert } from '@patternfly/react-core';
const UsrSubDirectoriesDisabled = () => {
return (
<Alert
variant='warning'
title='Sub-directories for the /usr mount point are no longer supported'
variant="warning"
title="Sub-directories for the /usr mount point are no longer supported"
isInline
>
Please note that including sub-directories in the /usr path is no longer

View file

@ -23,7 +23,7 @@ type ValidatedTextInputPropTypes = TextInputProps & {
type ValidationInputProp = TextInputProps &
TextAreaProps & {
value: string;
placeholder?: string;
placeholder: string;
stepValidation: StepValidation;
dataTestId?: string;
fieldName: string;
@ -31,7 +31,7 @@ type ValidationInputProp = TextInputProps &
ariaLabel: string;
onChange: (
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
value: string,
value: string
) => void;
isRequired?: boolean;
warning?: string;
@ -91,14 +91,16 @@ export const ValidatedInputAndTextArea = ({
onChange={onChange}
validated={validated}
onBlur={handleBlur}
placeholder={placeholder || ''}
placeholder={placeholder}
aria-label={ariaLabel}
data-testid={dataTestId}
/>
)}
{warning !== undefined && warning !== '' && (
<HelperText>
<HelperTextItem variant='warning'>{warning}</HelperTextItem>
<HelperTextItem variant="warning" hasIcon>
{warning}
</HelperTextItem>
</HelperText>
)}
{validated === 'error' && hasError && (
@ -111,13 +113,13 @@ export const ValidatedInputAndTextArea = ({
const getValidationState = (
isPristine: boolean,
errorMessage: string,
isRequired: boolean | undefined,
isRequired: boolean | undefined
): ValidationResult => {
const validated = isPristine
? 'default'
: (isRequired && errorMessage) || errorMessage
? 'error'
: 'success';
? 'error'
: 'success';
return validated;
};
@ -125,7 +127,9 @@ const getValidationState = (
export const ErrorMessage = ({ errorMessage }: ErrorMessageProps) => {
return (
<HelperText>
<HelperTextItem variant='error'>{errorMessage}</HelperTextItem>
<HelperTextItem variant="error" hasIcon>
{errorMessage}
</HelperTextItem>
</HelperText>
);
};
@ -138,7 +142,6 @@ export const ValidatedInput = ({
value,
placeholder,
onChange,
...props
}: ValidatedTextInputPropTypes) => {
const [isPristine, setIsPristine] = useState(!value ? true : false);
@ -159,17 +162,18 @@ export const ValidatedInput = ({
<TextInput
value={value}
data-testid={dataTestId}
type='text'
type="text"
onChange={onChange!}
validated={handleValidation()}
aria-label={ariaLabel || ''}
onBlur={handleBlur}
placeholder={placeholder || ''}
{...props}
/>
{!isPristine && !validator(value) && (
<HelperText>
<HelperTextItem variant='error'>{helperText}</HelperTextItem>
<HelperTextItem variant="error" hasIcon>
{helperText}
</HelperTextItem>
</HelperText>
)}
</>

View file

@ -1,178 +0,0 @@
import React from 'react';
import {
Checkbox,
DropEvent,
FileUpload,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeAapCallbackUrl,
changeAapHostConfigKey,
changeAapTlsCertificateAuthority,
changeAapTlsConfirmation,
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
} from '../../../../../store/wizardSlice';
import { useAAPValidation } from '../../../utilities/useValidation';
import { ValidatedInputAndTextArea } from '../../../ValidatedInput';
import { validateMultipleCertificates } from '../../../validators';
const AAPRegistration = () => {
const dispatch = useAppDispatch();
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const tlsConfirmation = useAppSelector(selectAapTlsConfirmation);
const [isRejected, setIsRejected] = React.useState(false);
const stepValidation = useAAPValidation();
const isHttpsUrl = callbackUrl?.toLowerCase().startsWith('https://') || false;
const shouldShowCaInput = !isHttpsUrl || (isHttpsUrl && !tlsConfirmation);
const validated = stepValidation.errors['certificate']
? 'error'
: stepValidation.errors['certificate'] === undefined &&
tlsCertificateAuthority &&
validateMultipleCertificates(tlsCertificateAuthority).validCertificates
.length > 0
? 'success'
: 'default';
const handleCallbackUrlChange = (value: string) => {
dispatch(changeAapCallbackUrl(value));
};
const handleHostConfigKeyChange = (value: string) => {
dispatch(changeAapHostConfigKey(value));
};
const handleClear = () => {
dispatch(changeAapTlsCertificateAuthority(''));
};
const handleTextChange = (
_event: React.ChangeEvent<HTMLTextAreaElement>,
value: string,
) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleDataChange = (_: DropEvent, value: string) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleFileRejected = () => {
dispatch(changeAapTlsCertificateAuthority(''));
setIsRejected(true);
};
const handleTlsConfirmationChange = (checked: boolean) => {
dispatch(changeAapTlsConfirmation(checked));
};
return (
<>
<FormGroup label='Ansible Callback URL' isRequired>
<ValidatedInputAndTextArea
value={callbackUrl || ''}
onChange={(_event, value) => handleCallbackUrlChange(value.trim())}
ariaLabel='ansible callback url'
isRequired
stepValidation={stepValidation}
fieldName='callbackUrl'
/>
</FormGroup>
<FormGroup label='Host Config Key' isRequired>
<ValidatedInputAndTextArea
value={hostConfigKey || ''}
onChange={(_event, value) => handleHostConfigKeyChange(value.trim())}
ariaLabel='host config key'
isRequired
stepValidation={stepValidation}
fieldName='hostConfigKey'
/>
</FormGroup>
{shouldShowCaInput && (
<FormGroup label='Certificate authority (CA) for Ansible Controller'>
<FileUpload
id='aap-certificate-upload'
type='text'
value={tlsCertificateAuthority || ''}
filename={tlsCertificateAuthority ? 'CA detected' : ''}
onDataChange={handleDataChange}
onTextChange={handleTextChange}
onClearClick={handleClear}
dropzoneProps={{
accept: {
'application/x-pem-file': ['.pem'],
'application/x-x509-ca-cert': ['.cer', '.crt'],
'application/pkix-cert': ['.der'],
},
maxSize: 512000,
onDropRejected: handleFileRejected,
}}
validated={isRejected ? 'error' : validated}
browseButtonText='Upload'
allowEditingUploadedText={true}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
variant={
isRejected || validated === 'error'
? 'error'
: validated === 'success'
? 'success'
: 'default'
}
>
{isRejected
? 'Must be a .PEM/.CER/.CRT file'
: validated === 'error'
? stepValidation.errors['certificate']
: validated === 'success'
? 'Certificate was uploaded'
: 'Drag and drop a valid certificate file or upload one'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
)}
{isHttpsUrl && (
<FormGroup>
<Checkbox
id='tls-confirmation-checkbox'
label='Insecure'
isChecked={tlsConfirmation || false}
onChange={(_event, checked) => handleTlsConfirmationChange(checked)}
/>
{stepValidation.errors['tlsConfirmation'] && (
<FormHelperText>
<HelperText>
<HelperTextItem variant='error'>
{stepValidation.errors['tlsConfirmation']}
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
)}
</>
);
};
export default AAPRegistration;

Some files were not shown because too many files have changed in this diff Show more