Add Docker pages #2
1
ui/cockpit-docker/.cockpit-ci/container
Normal file
1
ui/cockpit-docker/.cockpit-ci/container
Normal file
@ -0,0 +1 @@
|
|||||||
|
ghcr.io/cockpit-project/tasks:2024-04-08
|
1
ui/cockpit-docker/.cockpit-ci/run
Symbolic link
1
ui/cockpit-docker/.cockpit-ci/run
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
cockpit-docker/../test/run
|
2
ui/cockpit-docker/.eslintignore
Normal file
2
ui/cockpit-docker/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/*
|
||||||
|
pkg/lib/*
|
55
ui/cockpit-docker/.eslintrc.json
Normal file
55
ui/cockpit-docker/.eslintrc.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended", "standard", "standard-jsx", "standard-react", "plugin:jsx-a11y/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022
|
||||||
|
},
|
||||||
|
"plugins": ["react", "react-hooks", "jsx-a11y"],
|
||||||
|
"rules": {
|
||||||
|
"indent": ["error", 4,
|
||||||
|
{
|
||||||
|
"ObjectExpression": "first",
|
||||||
|
"CallExpression": {"arguments": "first"},
|
||||||
|
"MemberExpression": 2,
|
||||||
|
"ignoredNodes": [ "JSXAttribute" ]
|
||||||
|
}],
|
||||||
|
"newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }],
|
||||||
|
"no-var": "error",
|
||||||
|
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||||
|
"prefer-promise-reject-errors": ["error", { "allowEmptyReject": true }],
|
||||||
|
"react/jsx-indent": ["error", 4],
|
||||||
|
"semi": ["error", "always", { "omitLastInOneLineBlock": true }],
|
||||||
|
|
||||||
|
"camelcase": "off",
|
||||||
|
"comma-dangle": "off",
|
||||||
|
"curly": "off",
|
||||||
|
"jsx-quotes": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-undef": "error",
|
||||||
|
"quotes": "off",
|
||||||
|
"react/jsx-curly-spacing": "off",
|
||||||
|
"react/jsx-indent-props": "off",
|
||||||
|
"react/jsx-closing-bracket-location": "off",
|
||||||
|
"react/jsx-closing-tag-location": "off",
|
||||||
|
"react/jsx-first-prop-new-line": "off",
|
||||||
|
"react/jsx-curly-newline": "off",
|
||||||
|
"react/jsx-handler-names": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react/jsx-no-useless-fragment": "error",
|
||||||
|
"space-before-function-paren": "off",
|
||||||
|
"standard/no-callback-literal": "off",
|
||||||
|
|
||||||
|
"jsx-a11y/anchor-is-valid": "off",
|
||||||
|
|
||||||
|
"eqeqeq": "off",
|
||||||
|
"react/jsx-no-bind": "off"
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"require": false,
|
||||||
|
"module": false
|
||||||
|
}
|
||||||
|
}
|
1
ui/cockpit-docker/.fmf/version
Normal file
1
ui/cockpit-docker/.fmf/version
Normal file
@ -0,0 +1 @@
|
|||||||
|
1
|
31
ui/cockpit-docker/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
ui/cockpit-docker/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: For bugs and general problems
|
||||||
|
title:
|
||||||
|
labels: 'bug'
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Cockpit version: xxx
|
||||||
|
Cockpit-docker version: xxx
|
||||||
|
Docker version: xxx
|
||||||
|
OS:
|
||||||
|
|
||||||
|
<!--- Issue description -->
|
||||||
|
|
||||||
|
<!---
|
||||||
|
Please help us out by explaining how to reproduce it.
|
||||||
|
|
||||||
|
Relevant parts of the system log are also useful:
|
||||||
|
`journalctl --since -10m` if the issue happened in the last 10 minutes
|
||||||
|
`journalctl -u docker`
|
||||||
|
-->
|
||||||
|
|
||||||
|
Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
<!--- In case the issue is clearly visible, screenshots are very helpful -->
|
38
ui/cockpit-docker/.github/dependabot.yml
vendored
Normal file
38
ui/cockpit-docker/.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
# run these when most of our developers don't work, don't DoS our CI over the day
|
||||||
|
time: "22:00"
|
||||||
|
timezone: "Europe/Berlin"
|
||||||
|
open-pull-requests-limit: 3
|
||||||
|
commit-message:
|
||||||
|
prefix: "[no-test]"
|
||||||
|
labels:
|
||||||
|
- "node_modules"
|
||||||
|
groups:
|
||||||
|
eslint:
|
||||||
|
patterns:
|
||||||
|
- "eslint*"
|
||||||
|
esbuild:
|
||||||
|
patterns:
|
||||||
|
- "esbuild*"
|
||||||
|
stylelint:
|
||||||
|
patterns:
|
||||||
|
- "stylelint*"
|
||||||
|
xterm:
|
||||||
|
patterns:
|
||||||
|
- "xterm*"
|
||||||
|
patternfly:
|
||||||
|
patterns:
|
||||||
|
- "@patternfly*"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
open-pull-requests-limit: 3
|
||||||
|
labels:
|
||||||
|
- "no-test"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
82
ui/cockpit-docker/.github/workflows/dependabot.yml
vendored
Normal file
82
ui/cockpit-docker/.github/workflows/dependabot.yml
vendored
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
name: update node_modules
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize, labeled]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependabot:
|
||||||
|
environment: npm-update
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
timeout-minutes: 5
|
||||||
|
# 22.04's podman has issues with piping and causes tar errors
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
if: ${{ contains(github.event.pull_request.labels.*.name, 'node_modules') }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Clear node_modules label
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
name: 'node_modules'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name == 'HttpError' && e.status == 404) {
|
||||||
|
/* expected: 404 if label is unset */
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Update node_modules for package.json changes
|
||||||
|
run: |
|
||||||
|
make tools/node-modules
|
||||||
|
git config --global user.name "GitHub Workflow"
|
||||||
|
git config --global user.email "cockpituous@cockpit-project.org"
|
||||||
|
eval $(ssh-agent)
|
||||||
|
ssh-add - <<< '${{ secrets.NODE_CACHE_DEPLOY_KEY }}'
|
||||||
|
./tools/node-modules install
|
||||||
|
./tools/node-modules push
|
||||||
|
git add node_modules
|
||||||
|
ssh-add -D
|
||||||
|
ssh-agent -k
|
||||||
|
|
||||||
|
- name: Clear [no-test] prefix from PR title
|
||||||
|
if: ${{ contains(github.event.pull_request.title, '[no-test]') }}
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
TITLE: '${{ github.event.pull_request.title }}'
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = process.env['TITLE'].replace(/\[no-test\]\W+ /, '')
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Force push node_modules update
|
||||||
|
run: |
|
||||||
|
# Dependabot prefixes the commit with [no-test] which we don't want to keep in the commit
|
||||||
|
title=$(git show --pretty="%s" -s | sed -E "s/\[no-test\]\W+ //")
|
||||||
|
body=$(git show -s --pretty="%b")
|
||||||
|
git commit --amend -m "${title}" -m "${body}" --no-edit node_modules
|
||||||
|
eval $(ssh-agent)
|
||||||
|
ssh-add - <<< '${{ secrets.SELF_DEPLOY_KEY }}'
|
||||||
|
git push --force 'git@github.com:${{ github.repository }}' '${{ github.head_ref }}'
|
||||||
|
ssh-add -D
|
||||||
|
ssh-agent -k
|
22
ui/cockpit-docker/.github/workflows/nightly.yml
vendored
Normal file
22
ui/cockpit-docker/.github/workflows/nightly.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: nightly
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 1 * * *'
|
||||||
|
# can be run manually on https://github.com/cockpit-project/cockpit-podman/actions
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
permissions:
|
||||||
|
statuses: write
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Trigger updates-testing scenario
|
||||||
|
run: |
|
||||||
|
make bots
|
||||||
|
mkdir -p ~/.config/cockpit-dev
|
||||||
|
echo "${{ github.token }}" >> ~/.config/cockpit-dev/github-token
|
||||||
|
TEST_OS=$(PYTHONPATH=bots python3 -c 'from lib.constants import TEST_OS_DEFAULT; print(TEST_OS_DEFAULT)')
|
||||||
|
bots/tests-trigger --force "-" "${TEST_OS}/updates-testing" "${TEST_OS}/docker-next"
|
35
ui/cockpit-docker/.github/workflows/release.yml
vendored
Normal file
35
ui/cockpit-docker/.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
# this is a glob, not a regexp
|
||||||
|
- '[0-9]*'
|
||||||
|
jobs:
|
||||||
|
source:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cockpit-project/tasks:latest
|
||||||
|
options: --user root
|
||||||
|
permissions:
|
||||||
|
# create GitHub release
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# https://github.blog/2022-04-12-git-security-vulnerability-announced/
|
||||||
|
- name: Pacify git's permission check
|
||||||
|
run: git config --global --add safe.directory /__w/cockpit-docker/cockpit-docker
|
||||||
|
|
||||||
|
- name: Workaround for https://github.com/actions/checkout/pull/697
|
||||||
|
run: git fetch --force origin $(git describe --tags):refs/tags/$(git describe --tags)
|
||||||
|
|
||||||
|
- name: Build release
|
||||||
|
run: make dist
|
||||||
|
|
||||||
|
- name: Publish GitHub release
|
||||||
|
uses: cockpit-project/action-release@7d2e2657382e8d34f88a24b5987f2b81ea165785
|
||||||
|
with:
|
||||||
|
filename: "cockpit-docker-${{ github.ref_name }}.tar.xz"
|
37
ui/cockpit-docker/.github/workflows/tasks-container-update.yml
vendored
Normal file
37
ui/cockpit-docker/.github/workflows/tasks-container-update.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
name: tasks-container-update
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * 1'
|
||||||
|
# can be run manually on https://github.com/cockpit-project/cockpit-podman/actions
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
tasks-container-update:
|
||||||
|
environment: self
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
statuses: write
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cockpit-project/tasks
|
||||||
|
options: --user root
|
||||||
|
steps:
|
||||||
|
- name: Set up configuration and secrets
|
||||||
|
run: |
|
||||||
|
printf '[user]\n\tname = Cockpit Project\n\temail=cockpituous@gmail.com\n' > ~/.gitconfig
|
||||||
|
mkdir -p ~/.config
|
||||||
|
echo '${{ secrets.GITHUB_TOKEN }}' > ~/.config/github-token
|
||||||
|
|
||||||
|
- name: Clone repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ssh-key: ${{ secrets.DEPLOY_KEY }}
|
||||||
|
|
||||||
|
# https://github.blog/2022-04-12-git-security-vulnerability-announced/
|
||||||
|
- name: Pacify git's permission check
|
||||||
|
run: git config --global --add safe.directory /__w/cockpit-docker/cockpit-docker
|
||||||
|
|
||||||
|
- name: Run tasks-container-update
|
||||||
|
run: |
|
||||||
|
make bots
|
||||||
|
bots/tasks-container-update
|
37
ui/cockpit-docker/.gitignore
vendored
Normal file
37
ui/cockpit-docker/.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Please keep this file sorted (LC_COLLATE=C.UTF-8),
|
||||||
|
# grouped into the 3 categories below:
|
||||||
|
# - general patterns (match in all directories)
|
||||||
|
# - patterns to match files at the toplevel
|
||||||
|
# - patterns to match files in subdirs
|
||||||
|
|
||||||
|
# general patterns
|
||||||
|
*.pyc
|
||||||
|
*.rpm
|
||||||
|
|
||||||
|
# toplevel (/...)
|
||||||
|
/Test*.html
|
||||||
|
/Test*.json
|
||||||
|
/Test*.log
|
||||||
|
/Test*.log.gz
|
||||||
|
/Test*.png
|
||||||
|
/*.whl
|
||||||
|
/bots
|
||||||
|
/cockpit-*.tar.xz
|
||||||
|
/cockpit-docker.spec
|
||||||
|
/dist/
|
||||||
|
/package-lock.json
|
||||||
|
/pkg/
|
||||||
|
/tmp/
|
||||||
|
/tools/
|
||||||
|
/node_modules/
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
|
||||||
|
# subdirs (/subdir/...)
|
||||||
|
/packaging/arch/PKGBUILD
|
||||||
|
/packaging/debian/changelog
|
||||||
|
/po/*.pot
|
||||||
|
/po/LINGUAS
|
||||||
|
/test/common/
|
||||||
|
/test/images/
|
||||||
|
/test/static-code
|
7
ui/cockpit-docker/.gitmodules
vendored
Normal file
7
ui/cockpit-docker/.gitmodules
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[submodule "test/reference"]
|
||||||
|
path = test/reference
|
||||||
|
url = https://github.com/cockpit-project/pixel-test-reference
|
||||||
|
branch = empty
|
||||||
|
[submodule "node_modules"]
|
||||||
|
path = node_modules
|
||||||
|
url = https://github.com/cockpit-project/node-cache.git
|
39
ui/cockpit-docker/.stylelintrc.json
Normal file
39
ui/cockpit-docker/.stylelintrc.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"extends": "stylelint-config-standard-scss",
|
||||||
|
"plugins": [
|
||||||
|
"stylelint-use-logical-spec"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"at-rule-empty-line-before": null,
|
||||||
|
"declaration-empty-line-before": null,
|
||||||
|
"custom-property-empty-line-before": null,
|
||||||
|
"comment-empty-line-before": null,
|
||||||
|
"scss/double-slash-comment-empty-line-before": null,
|
||||||
|
"scss/dollar-variable-colon-space-after": null,
|
||||||
|
|
||||||
|
"custom-property-pattern": null,
|
||||||
|
"declaration-block-no-duplicate-properties": null,
|
||||||
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
|
"declaration-block-no-shorthand-property-overrides": null,
|
||||||
|
"declaration-block-single-line-max-declarations": null,
|
||||||
|
"font-family-no-duplicate-names": null,
|
||||||
|
"function-url-quotes": null,
|
||||||
|
"keyframes-name-pattern": null,
|
||||||
|
"media-feature-range-notation": "prefix",
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"no-duplicate-selectors": null,
|
||||||
|
"scss/at-extend-no-missing-placeholder": null,
|
||||||
|
"scss/at-import-partial-extension": null,
|
||||||
|
"scss/at-import-no-partial-leading-underscore": null,
|
||||||
|
"scss/load-no-partial-leading-underscore": true,
|
||||||
|
"scss/at-mixin-pattern": null,
|
||||||
|
"scss/comment-no-empty": null,
|
||||||
|
"scss/dollar-variable-pattern": null,
|
||||||
|
"scss/double-slash-comment-whitespace-inside": null,
|
||||||
|
"scss/no-global-function-names": null,
|
||||||
|
"scss/operator-no-unspaced": null,
|
||||||
|
"selector-class-pattern": null,
|
||||||
|
"selector-id-pattern": null,
|
||||||
|
"liberty/use-logical-spec": "always"
|
||||||
|
}
|
||||||
|
}
|
107
ui/cockpit-docker/HACKING.md
Normal file
107
ui/cockpit-docker/HACKING.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# Hacking on Cockpit docker
|
||||||
|
|
||||||
|
The commands here assume you're in the top level of the Cockpit docker git
|
||||||
|
repository checkout.
|
||||||
|
|
||||||
|
## Running out of git checkout
|
||||||
|
|
||||||
|
For development, you usually want to run your module straight out of the git
|
||||||
|
tree. To do that, run `make devel-install`, which links your checkout to the
|
||||||
|
location were `cockpit-bridge` looks for packages. If you prefer to do this
|
||||||
|
manually:
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p ~/.local/share/cockpit
|
||||||
|
ln -s `pwd`/dist ~/.local/share/cockpit/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing the code and running `make` again, reload the Cockpit page in
|
||||||
|
your browser.
|
||||||
|
|
||||||
|
You can also use
|
||||||
|
[watch mode](https://esbuild.github.io/api/#watch) to
|
||||||
|
automatically update the bundle on every code change with
|
||||||
|
|
||||||
|
$ make watch
|
||||||
|
|
||||||
|
When developing against a virtual machine, watch mode can also automatically upload
|
||||||
|
the code changes by setting the `RSYNC` environment variable to
|
||||||
|
the remote hostname.
|
||||||
|
|
||||||
|
$ RSYNC=c make watch
|
||||||
|
|
||||||
|
When developing against a remote host as a normal user, `RSYNC_DEVEL` can be
|
||||||
|
set to upload code changes to `~/.local/share/cockpit/` instead of
|
||||||
|
`/usr/local`.
|
||||||
|
|
||||||
|
$ RSYNC_DEVEL=example.com make watch
|
||||||
|
|
||||||
|
## Running eslint
|
||||||
|
|
||||||
|
Cockpit docker uses [ESLint](https://eslint.org/) to automatically check
|
||||||
|
JavaScript code style in `.jsx` and `.js` files.
|
||||||
|
|
||||||
|
eslint is executed as part of `test/static-code`, aka. `make codecheck`.
|
||||||
|
|
||||||
|
For developer convenience, the ESLint can be started explicitly by:
|
||||||
|
|
||||||
|
$ npm run eslint
|
||||||
|
|
||||||
|
Violations of some rules can be fixed automatically by:
|
||||||
|
|
||||||
|
$ npm run eslint:fix
|
||||||
|
|
||||||
|
Rules configuration can be found in the `.eslintrc.json` file.
|
||||||
|
|
||||||
|
## Running stylelint
|
||||||
|
|
||||||
|
Cockpit uses [Stylelint](https://stylelint.io/) to automatically check CSS code
|
||||||
|
style in `.css` and `scss` files.
|
||||||
|
|
||||||
|
styleint is executed as part of `test/static-code`, aka. `make codecheck`.
|
||||||
|
|
||||||
|
For developer convenience, the Stylelint can be started explicitly by:
|
||||||
|
|
||||||
|
$ npm run stylelint
|
||||||
|
|
||||||
|
Violations of some rules can be fixed automatically by:
|
||||||
|
|
||||||
|
$ npm run stylelint:fix
|
||||||
|
|
||||||
|
Rules configuration can be found in the `.stylelintrc.json` file.
|
||||||
|
|
||||||
|
# Running tests locally
|
||||||
|
|
||||||
|
Run `make vm` to build an RPM and install it into a standard Cockpit test VM.
|
||||||
|
This will be `fedora-39` by default. You can set `$TEST_OS` to use a different
|
||||||
|
image, for example
|
||||||
|
|
||||||
|
TEST_OS=centos-8-stream make vm
|
||||||
|
|
||||||
|
Then run
|
||||||
|
|
||||||
|
make test/common
|
||||||
|
|
||||||
|
to pull in [Cockpit's shared test API](https://github.com/cockpit-project/cockpit/tree/main/test/common)
|
||||||
|
for running Chrome DevTools Protocol based browser tests.
|
||||||
|
|
||||||
|
With this preparation, you can manually run a single test without
|
||||||
|
rebuilding the VM, possibly with extra options for tracing and halting on test
|
||||||
|
failures (for interactive debugging):
|
||||||
|
|
||||||
|
TEST_OS=... test/check-application TestApplication.testRunImageSystem -stv
|
||||||
|
|
||||||
|
Use this command to list all known tests:
|
||||||
|
|
||||||
|
test/check-application -l
|
||||||
|
|
||||||
|
You can also run all of the tests:
|
||||||
|
|
||||||
|
TEST_OS=centos-8-stream make check
|
||||||
|
|
||||||
|
However, this is rather expensive, and most of the time it's better to let the
|
||||||
|
CI machinery do this on a draft pull request.
|
||||||
|
|
||||||
|
Please see [Cockpit's test documentation](https://github.com/cockpit-project/cockpit/blob/main/test/README.md)
|
||||||
|
for details how to run against existing VMs, interactive browser window,
|
||||||
|
interacting with the test VM, and more.
|
502
ui/cockpit-docker/LICENSE
Normal file
502
ui/cockpit-docker/LICENSE
Normal file
@ -0,0 +1,502 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 2.1, February 1999
|
||||||
|
|
||||||
|
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
[This is the first released version of the Lesser GPL. It also counts
|
||||||
|
as the successor of the GNU Library Public License, version 2, hence
|
||||||
|
the version number 2.1.]
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
Licenses are intended to guarantee your freedom to share and change
|
||||||
|
free software--to make sure the software is free for all its users.
|
||||||
|
|
||||||
|
This license, the Lesser General Public License, applies to some
|
||||||
|
specially designated software packages--typically libraries--of the
|
||||||
|
Free Software Foundation and other authors who decide to use it. You
|
||||||
|
can use it too, but we suggest you first think carefully about whether
|
||||||
|
this license or the ordinary General Public License is the better
|
||||||
|
strategy to use in any particular case, based on the explanations below.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom of use,
|
||||||
|
not price. Our General Public Licenses are designed to make sure that
|
||||||
|
you have the freedom to distribute copies of free software (and charge
|
||||||
|
for this service if you wish); that you receive source code or can get
|
||||||
|
it if you want it; that you can change the software and use pieces of
|
||||||
|
it in new free programs; and that you are informed that you can do
|
||||||
|
these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
distributors to deny you these rights or to ask you to surrender these
|
||||||
|
rights. These restrictions translate to certain responsibilities for
|
||||||
|
you if you distribute copies of the library or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of the library, whether gratis
|
||||||
|
or for a fee, you must give the recipients all the rights that we gave
|
||||||
|
you. You must make sure that they, too, receive or can get the source
|
||||||
|
code. If you link other code with the library, you must provide
|
||||||
|
complete object files to the recipients, so that they can relink them
|
||||||
|
with the library after making changes to the library and recompiling
|
||||||
|
it. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
We protect your rights with a two-step method: (1) we copyright the
|
||||||
|
library, and (2) we offer you this license, which gives you legal
|
||||||
|
permission to copy, distribute and/or modify the library.
|
||||||
|
|
||||||
|
To protect each distributor, we want to make it very clear that
|
||||||
|
there is no warranty for the free library. Also, if the library is
|
||||||
|
modified by someone else and passed on, the recipients should know
|
||||||
|
that what they have is not the original version, so that the original
|
||||||
|
author's reputation will not be affected by problems that might be
|
||||||
|
introduced by others.
|
||||||
|
|
||||||
|
Finally, software patents pose a constant threat to the existence of
|
||||||
|
any free program. We wish to make sure that a company cannot
|
||||||
|
effectively restrict the users of a free program by obtaining a
|
||||||
|
restrictive license from a patent holder. Therefore, we insist that
|
||||||
|
any patent license obtained for a version of the library must be
|
||||||
|
consistent with the full freedom of use specified in this license.
|
||||||
|
|
||||||
|
Most GNU software, including some libraries, is covered by the
|
||||||
|
ordinary GNU General Public License. This license, the GNU Lesser
|
||||||
|
General Public License, applies to certain designated libraries, and
|
||||||
|
is quite different from the ordinary General Public License. We use
|
||||||
|
this license for certain libraries in order to permit linking those
|
||||||
|
libraries into non-free programs.
|
||||||
|
|
||||||
|
When a program is linked with a library, whether statically or using
|
||||||
|
a shared library, the combination of the two is legally speaking a
|
||||||
|
combined work, a derivative of the original library. The ordinary
|
||||||
|
General Public License therefore permits such linking only if the
|
||||||
|
entire combination fits its criteria of freedom. The Lesser General
|
||||||
|
Public License permits more lax criteria for linking other code with
|
||||||
|
the library.
|
||||||
|
|
||||||
|
We call this license the "Lesser" General Public License because it
|
||||||
|
does Less to protect the user's freedom than the ordinary General
|
||||||
|
Public License. It also provides other free software developers Less
|
||||||
|
of an advantage over competing non-free programs. These disadvantages
|
||||||
|
are the reason we use the ordinary General Public License for many
|
||||||
|
libraries. However, the Lesser license provides advantages in certain
|
||||||
|
special circumstances.
|
||||||
|
|
||||||
|
For example, on rare occasions, there may be a special need to
|
||||||
|
encourage the widest possible use of a certain library, so that it becomes
|
||||||
|
a de-facto standard. To achieve this, non-free programs must be
|
||||||
|
allowed to use the library. A more frequent case is that a free
|
||||||
|
library does the same job as widely used non-free libraries. In this
|
||||||
|
case, there is little to gain by limiting the free library to free
|
||||||
|
software only, so we use the Lesser General Public License.
|
||||||
|
|
||||||
|
In other cases, permission to use a particular library in non-free
|
||||||
|
programs enables a greater number of people to use a large body of
|
||||||
|
free software. For example, permission to use the GNU C Library in
|
||||||
|
non-free programs enables many more people to use the whole GNU
|
||||||
|
operating system, as well as its variant, the GNU/Linux operating
|
||||||
|
system.
|
||||||
|
|
||||||
|
Although the Lesser General Public License is Less protective of the
|
||||||
|
users' freedom, it does ensure that the user of a program that is
|
||||||
|
linked with the Library has the freedom and the wherewithal to run
|
||||||
|
that program using a modified version of the Library.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow. Pay close attention to the difference between a
|
||||||
|
"work based on the library" and a "work that uses the library". The
|
||||||
|
former contains code derived from the library, whereas the latter must
|
||||||
|
be combined with the library in order to run.
|
||||||
|
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License Agreement applies to any software library or other
|
||||||
|
program which contains a notice placed by the copyright holder or
|
||||||
|
other authorized party saying it may be distributed under the terms of
|
||||||
|
this Lesser General Public License (also called "this License").
|
||||||
|
Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
A "library" means a collection of software functions and/or data
|
||||||
|
prepared so as to be conveniently linked with application programs
|
||||||
|
(which use some of those functions and data) to form executables.
|
||||||
|
|
||||||
|
The "Library", below, refers to any such software library or work
|
||||||
|
which has been distributed under these terms. A "work based on the
|
||||||
|
Library" means either the Library or any derivative work under
|
||||||
|
copyright law: that is to say, a work containing the Library or a
|
||||||
|
portion of it, either verbatim or with modifications and/or translated
|
||||||
|
straightforwardly into another language. (Hereinafter, translation is
|
||||||
|
included without limitation in the term "modification".)
|
||||||
|
|
||||||
|
"Source code" for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For a library, complete source code means
|
||||||
|
all the source code for all modules it contains, plus any associated
|
||||||
|
interface definition files, plus the scripts used to control compilation
|
||||||
|
and installation of the library.
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running a program using the Library is not restricted, and output from
|
||||||
|
such a program is covered only if its contents constitute a work based
|
||||||
|
on the Library (independent of the use of the Library in a tool for
|
||||||
|
writing it). Whether that is true depends on what the Library does
|
||||||
|
and what the program that uses the Library does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Library's
|
||||||
|
complete source code as you receive it, in any medium, provided that
|
||||||
|
you conspicuously and appropriately publish on each copy an
|
||||||
|
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||||
|
all the notices that refer to this License and to the absence of any
|
||||||
|
warranty; and distribute a copy of this License along with the
|
||||||
|
Library.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy,
|
||||||
|
and you may at your option offer warranty protection in exchange for a
|
||||||
|
fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Library or any portion
|
||||||
|
of it, thus forming a work based on the Library, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The modified work must itself be a software library.
|
||||||
|
|
||||||
|
b) You must cause the files modified to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
c) You must cause the whole of the work to be licensed at no
|
||||||
|
charge to all third parties under the terms of this License.
|
||||||
|
|
||||||
|
d) If a facility in the modified Library refers to a function or a
|
||||||
|
table of data to be supplied by an application program that uses
|
||||||
|
the facility, other than as an argument passed when the facility
|
||||||
|
is invoked, then you must make a good faith effort to ensure that,
|
||||||
|
in the event an application does not supply such function or
|
||||||
|
table, the facility still operates, and performs whatever part of
|
||||||
|
its purpose remains meaningful.
|
||||||
|
|
||||||
|
(For example, a function in a library to compute square roots has
|
||||||
|
a purpose that is entirely well-defined independent of the
|
||||||
|
application. Therefore, Subsection 2d requires that any
|
||||||
|
application-supplied function or table used by this function must
|
||||||
|
be optional: if the application does not supply it, the square
|
||||||
|
root function must still compute square roots.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Library,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Library, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote
|
||||||
|
it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Library.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Library
|
||||||
|
with the Library (or with a work based on the Library) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||||
|
License instead of this License to a given copy of the Library. To do
|
||||||
|
this, you must alter all the notices that refer to this License, so
|
||||||
|
that they refer to the ordinary GNU General Public License, version 2,
|
||||||
|
instead of to this License. (If a newer version than version 2 of the
|
||||||
|
ordinary GNU General Public License has appeared, then you can specify
|
||||||
|
that version instead if you wish.) Do not make any other change in
|
||||||
|
these notices.
|
||||||
|
|
||||||
|
Once this change is made in a given copy, it is irreversible for
|
||||||
|
that copy, so the ordinary GNU General Public License applies to all
|
||||||
|
subsequent copies and derivative works made from that copy.
|
||||||
|
|
||||||
|
This option is useful when you wish to copy part of the code of
|
||||||
|
the Library into a program that is not a library.
|
||||||
|
|
||||||
|
4. You may copy and distribute the Library (or a portion or
|
||||||
|
derivative of it, under Section 2) in object code or executable form
|
||||||
|
under the terms of Sections 1 and 2 above provided that you accompany
|
||||||
|
it with the complete corresponding machine-readable source code, which
|
||||||
|
must be distributed under the terms of Sections 1 and 2 above on a
|
||||||
|
medium customarily used for software interchange.
|
||||||
|
|
||||||
|
If distribution of object code is made by offering access to copy
|
||||||
|
from a designated place, then offering equivalent access to copy the
|
||||||
|
source code from the same place satisfies the requirement to
|
||||||
|
distribute the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
5. A program that contains no derivative of any portion of the
|
||||||
|
Library, but is designed to work with the Library by being compiled or
|
||||||
|
linked with it, is called a "work that uses the Library". Such a
|
||||||
|
work, in isolation, is not a derivative work of the Library, and
|
||||||
|
therefore falls outside the scope of this License.
|
||||||
|
|
||||||
|
However, linking a "work that uses the Library" with the Library
|
||||||
|
creates an executable that is a derivative of the Library (because it
|
||||||
|
contains portions of the Library), rather than a "work that uses the
|
||||||
|
library". The executable is therefore covered by this License.
|
||||||
|
Section 6 states terms for distribution of such executables.
|
||||||
|
|
||||||
|
When a "work that uses the Library" uses material from a header file
|
||||||
|
that is part of the Library, the object code for the work may be a
|
||||||
|
derivative work of the Library even though the source code is not.
|
||||||
|
Whether this is true is especially significant if the work can be
|
||||||
|
linked without the Library, or if the work is itself a library. The
|
||||||
|
threshold for this to be true is not precisely defined by law.
|
||||||
|
|
||||||
|
If such an object file uses only numerical parameters, data
|
||||||
|
structure layouts and accessors, and small macros and small inline
|
||||||
|
functions (ten lines or less in length), then the use of the object
|
||||||
|
file is unrestricted, regardless of whether it is legally a derivative
|
||||||
|
work. (Executables containing this object code plus portions of the
|
||||||
|
Library will still fall under Section 6.)
|
||||||
|
|
||||||
|
Otherwise, if the work is a derivative of the Library, you may
|
||||||
|
distribute the object code for the work under the terms of Section 6.
|
||||||
|
Any executables containing that work also fall under Section 6,
|
||||||
|
whether or not they are linked directly with the Library itself.
|
||||||
|
|
||||||
|
6. As an exception to the Sections above, you may also combine or
|
||||||
|
link a "work that uses the Library" with the Library to produce a
|
||||||
|
work containing portions of the Library, and distribute that work
|
||||||
|
under terms of your choice, provided that the terms permit
|
||||||
|
modification of the work for the customer's own use and reverse
|
||||||
|
engineering for debugging such modifications.
|
||||||
|
|
||||||
|
You must give prominent notice with each copy of the work that the
|
||||||
|
Library is used in it and that the Library and its use are covered by
|
||||||
|
this License. You must supply a copy of this License. If the work
|
||||||
|
during execution displays copyright notices, you must include the
|
||||||
|
copyright notice for the Library among them, as well as a reference
|
||||||
|
directing the user to the copy of this License. Also, you must do one
|
||||||
|
of these things:
|
||||||
|
|
||||||
|
a) Accompany the work with the complete corresponding
|
||||||
|
machine-readable source code for the Library including whatever
|
||||||
|
changes were used in the work (which must be distributed under
|
||||||
|
Sections 1 and 2 above); and, if the work is an executable linked
|
||||||
|
with the Library, with the complete machine-readable "work that
|
||||||
|
uses the Library", as object code and/or source code, so that the
|
||||||
|
user can modify the Library and then relink to produce a modified
|
||||||
|
executable containing the modified Library. (It is understood
|
||||||
|
that the user who changes the contents of definitions files in the
|
||||||
|
Library will not necessarily be able to recompile the application
|
||||||
|
to use the modified definitions.)
|
||||||
|
|
||||||
|
b) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (1) uses at run time a
|
||||||
|
copy of the library already present on the user's computer system,
|
||||||
|
rather than copying library functions into the executable, and (2)
|
||||||
|
will operate properly with a modified version of the library, if
|
||||||
|
the user installs one, as long as the modified version is
|
||||||
|
interface-compatible with the version that the work was made with.
|
||||||
|
|
||||||
|
c) Accompany the work with a written offer, valid for at
|
||||||
|
least three years, to give the same user the materials
|
||||||
|
specified in Subsection 6a, above, for a charge no more
|
||||||
|
than the cost of performing this distribution.
|
||||||
|
|
||||||
|
d) If distribution of the work is made by offering access to copy
|
||||||
|
from a designated place, offer equivalent access to copy the above
|
||||||
|
specified materials from the same place.
|
||||||
|
|
||||||
|
e) Verify that the user has already received a copy of these
|
||||||
|
materials or that you have already sent this user a copy.
|
||||||
|
|
||||||
|
For an executable, the required form of the "work that uses the
|
||||||
|
Library" must include any data and utility programs needed for
|
||||||
|
reproducing the executable from it. However, as a special exception,
|
||||||
|
the materials to be distributed need not include anything that is
|
||||||
|
normally distributed (in either source or binary form) with the major
|
||||||
|
components (compiler, kernel, and so on) of the operating system on
|
||||||
|
which the executable runs, unless that component itself accompanies
|
||||||
|
the executable.
|
||||||
|
|
||||||
|
It may happen that this requirement contradicts the license
|
||||||
|
restrictions of other proprietary libraries that do not normally
|
||||||
|
accompany the operating system. Such a contradiction means you cannot
|
||||||
|
use both them and the Library together in an executable that you
|
||||||
|
distribute.
|
||||||
|
|
||||||
|
7. You may place library facilities that are a work based on the
|
||||||
|
Library side-by-side in a single library together with other library
|
||||||
|
facilities not covered by this License, and distribute such a combined
|
||||||
|
library, provided that the separate distribution of the work based on
|
||||||
|
the Library and of the other library facilities is otherwise
|
||||||
|
permitted, and provided that you do these two things:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work
|
||||||
|
based on the Library, uncombined with any other library
|
||||||
|
facilities. This must be distributed under the terms of the
|
||||||
|
Sections above.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library of the fact
|
||||||
|
that part of it is a work based on the Library, and explaining
|
||||||
|
where to find the accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
8. You may not copy, modify, sublicense, link with, or distribute
|
||||||
|
the Library except as expressly provided under this License. Any
|
||||||
|
attempt otherwise to copy, modify, sublicense, link with, or
|
||||||
|
distribute the Library is void, and will automatically terminate your
|
||||||
|
rights under this License. However, parties who have received copies,
|
||||||
|
or rights, from you under this License will not have their licenses
|
||||||
|
terminated so long as such parties remain in full compliance.
|
||||||
|
|
||||||
|
9. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Library or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Library (or any work based on the
|
||||||
|
Library), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Library or works based on it.
|
||||||
|
|
||||||
|
10. Each time you redistribute the Library (or any work based on the
|
||||||
|
Library), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute, link with or modify the Library
|
||||||
|
subject to these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties with
|
||||||
|
this License.
|
||||||
|
|
||||||
|
11. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Library at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Library by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Library.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under any
|
||||||
|
particular circumstance, the balance of the section is intended to apply,
|
||||||
|
and the section as a whole is intended to apply in other circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
12. If the distribution and/or use of the Library is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Library under this License may add
|
||||||
|
an explicit geographical distribution limitation excluding those countries,
|
||||||
|
so that distribution is permitted only in or among countries not thus
|
||||||
|
excluded. In such case, this License incorporates the limitation as if
|
||||||
|
written in the body of this License.
|
||||||
|
|
||||||
|
13. The Free Software Foundation may publish revised and/or new
|
||||||
|
versions of the Lesser General Public License from time to time.
|
||||||
|
Such new versions will be similar in spirit to the present version,
|
||||||
|
but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Library
|
||||||
|
specifies a version number of this License which applies to it and
|
||||||
|
"any later version", you have the option of following the terms and
|
||||||
|
conditions either of that version or of any later version published by
|
||||||
|
the Free Software Foundation. If the Library does not specify a
|
||||||
|
license version number, you may choose any version ever published by
|
||||||
|
the Free Software Foundation.
|
||||||
|
|
||||||
|
14. If you wish to incorporate parts of the Library into other free
|
||||||
|
programs whose distribution conditions are incompatible with these,
|
||||||
|
write to the author to ask for permission. For software which is
|
||||||
|
copyrighted by the Free Software Foundation, write to the Free
|
||||||
|
Software Foundation; we sometimes make exceptions for this. Our
|
||||||
|
decision will be guided by the two goals of preserving the free status
|
||||||
|
of all derivatives of our free software and of promoting the sharing
|
||||||
|
and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||||
|
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||||
|
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||||
|
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||||
|
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||||
|
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||||
|
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||||
|
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||||
|
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||||
|
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||||
|
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||||
|
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||||
|
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||||
|
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||||
|
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||||
|
DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Libraries
|
||||||
|
|
||||||
|
If you develop a new library, and you want it to be of the greatest
|
||||||
|
possible use to the public, we recommend making it free software that
|
||||||
|
everyone can redistribute and change. You can do so by permitting
|
||||||
|
redistribution under these terms (or, alternatively, under the terms of the
|
||||||
|
ordinary General Public License).
|
||||||
|
|
||||||
|
To apply these terms, attach the following notices to the library. It is
|
||||||
|
safest to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least the
|
||||||
|
"copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the library's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License as published by the Free Software Foundation; either
|
||||||
|
version 2.1 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public
|
||||||
|
License along with this library; if not, write to the Free Software
|
||||||
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||||
|
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1990
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
That's all there is to it!
|
217
ui/cockpit-docker/Makefile
Normal file
217
ui/cockpit-docker/Makefile
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# extract name from package.json
|
||||||
|
PACKAGE_NAME := $(shell awk '/"name":/ {gsub(/[",]/, "", $$2); print $$2}' package.json)
|
||||||
|
RPM_NAME := cockpit-$(PACKAGE_NAME)
|
||||||
|
VERSION := $(shell T=$$(git describe --tags 2>/dev/null) || T=1; echo $$T | tr '-' '.')
|
||||||
|
ifeq ($(TEST_OS),)
|
||||||
|
TEST_OS = fedora-39
|
||||||
|
endif
|
||||||
|
export TEST_OS
|
||||||
|
TARFILE=$(RPM_NAME)-$(VERSION).tar.xz
|
||||||
|
NODE_CACHE=$(RPM_NAME)-node-$(VERSION).tar.xz
|
||||||
|
SPEC=$(RPM_NAME).spec
|
||||||
|
PREFIX ?= /usr/local
|
||||||
|
APPSTREAMFILE=me.chabad360.$(PACKAGE_NAME).metainfo.xml
|
||||||
|
VM_IMAGE=$(CURDIR)/test/images/$(TEST_OS)
|
||||||
|
# stamp file to check for node_modules/
|
||||||
|
NODE_MODULES_TEST=package-lock.json
|
||||||
|
# one example file in dist/ from bundler to check if that already ran
|
||||||
|
DIST_TEST=dist/manifest.json
|
||||||
|
# one example file in pkg/lib to check if it was already checked out
|
||||||
|
COCKPIT_REPO_STAMP=pkg/lib/cockpit-po-plugin.js
|
||||||
|
# common arguments for tar, mostly to make the generated tarballs reproducible
|
||||||
|
TAR_ARGS = --sort=name --mtime "@$(shell git show --no-patch --format='%at')" --mode=go=rX,u+rw,a-s --numeric-owner --owner=0 --group=0
|
||||||
|
|
||||||
|
VM_CUSTOMIZE_FLAGS =
|
||||||
|
|
||||||
|
# HACK: https://github.com/containers/podman/issues/21896
|
||||||
|
VM_CUSTOMIZE_FLAGS += --run-command 'nmcli con add type dummy con-name fake ifname fake0 ip4 1.2.3.4/24 gw4 1.2.3.1 >&2'
|
||||||
|
|
||||||
|
# the following scenarios need network access
|
||||||
|
ifeq ("$(TEST_SCENARIO)","updates-testing")
|
||||||
|
VM_CUSTOMIZE_FLAGS += --run-command 'dnf -y update --setopt=install_weak_deps=False --enablerepo=updates-testing >&2'
|
||||||
|
else ifeq ("$(TEST_SCENARIO)","docker-next")
|
||||||
|
VM_CUSTOMIZE_FLAGS += --run-command 'dnf -y copr enable rhcontainerbot/docker-next >&2; dnf -y update --repo "copr*" >&2'
|
||||||
|
else
|
||||||
|
# default scenario does not install packages
|
||||||
|
VM_CUSTOMIZE_FLAGS += --no-network
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifeq ($(TEST_COVERAGE),yes)
|
||||||
|
RUN_TESTS_OPTIONS+=--coverage
|
||||||
|
NODE_ENV=development
|
||||||
|
endif
|
||||||
|
|
||||||
|
all: $(DIST_TEST)
|
||||||
|
|
||||||
|
# checkout common files from Cockpit repository required to build this project;
|
||||||
|
# this has no API stability guarantee, so check out a stable tag when you start
|
||||||
|
# a new project, use the latest release, and update it from time to time
|
||||||
|
COCKPIT_REPO_FILES = \
|
||||||
|
pkg/lib \
|
||||||
|
test/common \
|
||||||
|
test/static-code \
|
||||||
|
tools/node-modules \
|
||||||
|
$(NULL)
|
||||||
|
|
||||||
|
COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git
|
||||||
|
COCKPIT_REPO_COMMIT = d39255f6f768cc1c37a5786be8e8dc9d8f4d5ed2 # 315 + 83 commits
|
||||||
|
|
||||||
|
$(COCKPIT_REPO_FILES): $(COCKPIT_REPO_STAMP)
|
||||||
|
COCKPIT_REPO_TREE = '$(strip $(COCKPIT_REPO_COMMIT))^{tree}'
|
||||||
|
$(COCKPIT_REPO_STAMP): Makefile
|
||||||
|
@git rev-list --quiet --objects $(COCKPIT_REPO_TREE) -- 2>/dev/null || \
|
||||||
|
git fetch --no-tags --no-write-fetch-head --depth=1 $(COCKPIT_REPO_URL) $(COCKPIT_REPO_COMMIT)
|
||||||
|
git archive $(COCKPIT_REPO_TREE) -- $(COCKPIT_REPO_FILES) | tar x
|
||||||
|
|
||||||
|
#
|
||||||
|
# i18n
|
||||||
|
#
|
||||||
|
|
||||||
|
LINGUAS=$(basename $(notdir $(wildcard po/*.po)))
|
||||||
|
|
||||||
|
po/$(PACKAGE_NAME).js.pot:
|
||||||
|
xgettext --default-domain=$(PACKAGE_NAME) --output=$@ --language=C --keyword= \
|
||||||
|
--keyword=_:1,1t --keyword=_:1c,2,2t --keyword=C_:1c,2 \
|
||||||
|
--keyword=N_ --keyword=NC_:1c,2 \
|
||||||
|
--keyword=gettext:1,1t --keyword=gettext:1c,2,2t \
|
||||||
|
--keyword=ngettext:1,2,3t --keyword=ngettext:1c,2,3,4t \
|
||||||
|
--keyword=gettextCatalog.getString:1,3c --keyword=gettextCatalog.getPlural:2,3,4c \
|
||||||
|
--from-code=UTF-8 $$(find src/ -name '*.js' -o -name '*.jsx')
|
||||||
|
|
||||||
|
po/$(PACKAGE_NAME).html.pot: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP)
|
||||||
|
pkg/lib/html2po.js -o $@ $$(find src -name '*.html')
|
||||||
|
|
||||||
|
po/$(PACKAGE_NAME).manifest.pot: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP)
|
||||||
|
pkg/lib/manifest2po.js src/manifest.json -o $@
|
||||||
|
|
||||||
|
po/$(PACKAGE_NAME).metainfo.pot: $(APPSTREAMFILE)
|
||||||
|
xgettext --default-domain=$(PACKAGE_NAME) --output=$@ $<
|
||||||
|
|
||||||
|
po/$(PACKAGE_NAME).pot: po/$(PACKAGE_NAME).html.pot po/$(PACKAGE_NAME).js.pot po/$(PACKAGE_NAME).manifest.pot po/$(PACKAGE_NAME).metainfo.pot
|
||||||
|
msgcat --sort-output --output-file=$@ $^
|
||||||
|
|
||||||
|
po/LINGUAS:
|
||||||
|
echo $(LINGUAS) | tr ' ' '\n' > $@
|
||||||
|
|
||||||
|
#
|
||||||
|
# Build/Install/dist
|
||||||
|
#
|
||||||
|
$(SPEC): packaging/$(SPEC).in $(NODE_MODULES_TEST)
|
||||||
|
provides=$$(npm ls --omit dev --package-lock-only --depth=Infinity | grep -Eo '[^[:space:]]+@[^[:space:]]+' | sort -u | sed 's/^/Provides: bundled(npm(/; s/\(.*\)@/\1)) = /'); \
|
||||||
|
awk -v p="$$provides" '{gsub(/%{VERSION}/, "$(VERSION)"); gsub(/%{NPM_PROVIDES}/, p)}1' $< > $@
|
||||||
|
|
||||||
|
packaging/arch/PKGBUILD: packaging/arch/PKGBUILD.in
|
||||||
|
sed 's/VERSION/$(VERSION)/; s/SOURCE/$(TARFILE)/' $< > $@
|
||||||
|
|
||||||
|
packaging/debian/changelog: packaging/debian/changelog.in
|
||||||
|
sed 's/VERSION/$(VERSION)/' $< > $@
|
||||||
|
|
||||||
|
$(DIST_TEST): $(COCKPIT_REPO_STAMP) $(shell find src/ -type f) package.json build.js
|
||||||
|
$(MAKE) package-lock.json && NODE_ENV=$(NODE_ENV) ./build.js
|
||||||
|
|
||||||
|
watch: $(NODE_MODULES_TEST)
|
||||||
|
NODE_ENV=$(NODE_ENV) ./build.js -w
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist/
|
||||||
|
rm -f $(SPEC) packaging/arch/PKGBUILD packaging/debian/changelog
|
||||||
|
rm -f po/LINGUAS
|
||||||
|
|
||||||
|
install: $(DIST_TEST) po/LINGUAS
|
||||||
|
mkdir -p $(DESTDIR)$(PREFIX)/share/cockpit/$(PACKAGE_NAME)
|
||||||
|
cp -r dist/* $(DESTDIR)$(PREFIX)/share/cockpit/$(PACKAGE_NAME)
|
||||||
|
mkdir -p $(DESTDIR)$(PREFIX)/share/metainfo/
|
||||||
|
msgfmt --xml -d po \
|
||||||
|
--template $(APPSTREAMFILE) \
|
||||||
|
-o $(DESTDIR)$(PREFIX)/share/metainfo/$(APPSTREAMFILE)
|
||||||
|
|
||||||
|
# this requires a built source tree and avoids having to install anything system-wide
|
||||||
|
devel-install: $(DIST_TEST)
|
||||||
|
mkdir -p ~/.local/share/cockpit
|
||||||
|
ln -s `pwd`/dist ~/.local/share/cockpit/$(PACKAGE_NAME)
|
||||||
|
|
||||||
|
# assumes that there was symlink set up using the above devel-install target,
|
||||||
|
# and removes it
|
||||||
|
devel-uninstall:
|
||||||
|
rm -f ~/.local/share/cockpit/$(PACKAGE_NAME)
|
||||||
|
|
||||||
|
print-version:
|
||||||
|
@echo "$(VERSION)"
|
||||||
|
|
||||||
|
# required for running integration tests; commander and ws are deps of chrome-remote-interface
|
||||||
|
TEST_NPMS = \
|
||||||
|
node_modules/chrome-remote-interface \
|
||||||
|
node_modules/commander \
|
||||||
|
node_modules/sizzle \
|
||||||
|
node_modules/ws \
|
||||||
|
$(NULL)
|
||||||
|
|
||||||
|
dist: $(TARFILE)
|
||||||
|
@ls -1 $(TARFILE)
|
||||||
|
|
||||||
|
# when building a distribution tarball, call bundler with a 'production' environment by default
|
||||||
|
# we don't ship most node_modules for license and compactness reasons, only the ones necessary for running tests
|
||||||
|
# we ship a pre-built dist/ (so it's not necessary) and ship package-lock.json (so that node_modules/ can be reconstructed if necessary)
|
||||||
|
$(TARFILE): export NODE_ENV ?= production
|
||||||
|
$(TARFILE): $(DIST_TEST) $(SPEC) packaging/arch/PKGBUILD packaging/debian/changelog
|
||||||
|
if type appstream-util >/dev/null 2>&1; then appstream-util validate-relax --nonet *.metainfo.xml; fi
|
||||||
|
tar --xz $(TAR_ARGS) -cf $(TARFILE) --transform 's,^,$(RPM_NAME)/,' \
|
||||||
|
--exclude '*.in' --exclude test/reference \
|
||||||
|
$$(git ls-files | grep -v node_modules) \
|
||||||
|
$(COCKPIT_REPO_FILES) $(NODE_MODULES_TEST) $(SPEC) $(TEST_NPMS) \
|
||||||
|
packaging/arch/PKGBUILD packaging/debian/changelog dist/
|
||||||
|
|
||||||
|
# convenience target for developers
|
||||||
|
rpm: $(TARFILE)
|
||||||
|
rpmbuild -tb --define "_topdir $(CURDIR)/tmp/rpmbuild" $(TARFILE)
|
||||||
|
find tmp/rpmbuild -name '*.rpm' -printf '%f\n' -exec mv {} . \;
|
||||||
|
rm -r tmp/rpmbuild
|
||||||
|
|
||||||
|
# build a VM with locally built distro pkgs installed
|
||||||
|
$(VM_IMAGE): $(TARFILE) packaging/debian/rules packaging/debian/control packaging/arch/PKGBUILD bots
|
||||||
|
# HACK for ostree images: skip the rpm build/install
|
||||||
|
if [ "$${TEST_OS%coreos}" != "$$TEST_OS" ] || [ "$${TEST_OS%bootc}" != "$$TEST_OS" ] || [ "$$TEST_OS" = "rhel4edge" ]; then \
|
||||||
|
bots/image-customize --verbose --fresh --no-network --run-command 'mkdir -p /usr/local/share/cockpit' \
|
||||||
|
--upload dist/:/usr/local/share/cockpit/docker \
|
||||||
|
--script $(CURDIR)/test/vm.install $(TEST_OS); \
|
||||||
|
else \
|
||||||
|
bots/image-customize --verbose --fresh $(VM_CUSTOMIZE_FLAGS) --build $(TARFILE) \
|
||||||
|
--script $(CURDIR)/test/vm.install $(TEST_OS); \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# convenience target for the above
|
||||||
|
vm: $(VM_IMAGE)
|
||||||
|
@echo $(VM_IMAGE)
|
||||||
|
|
||||||
|
# convenience target to print the filename of the test image
|
||||||
|
print-vm:
|
||||||
|
@echo $(VM_IMAGE)
|
||||||
|
|
||||||
|
# run static code checks for python code
|
||||||
|
PYEXEFILES=$(shell git grep -lI '^#!.*python')
|
||||||
|
|
||||||
|
codecheck: test/static-code $(NODE_MODULES_TEST)
|
||||||
|
test/static-code
|
||||||
|
|
||||||
|
# convenience target to setup all the bits needed for the integration tests
|
||||||
|
# without actually running them
|
||||||
|
prepare-check: $(NODE_MODULES_TEST) $(VM_IMAGE) test/common test/reference
|
||||||
|
|
||||||
|
# run the browser integration tests; skip check for SELinux denials
|
||||||
|
# this will run all tests/check-* and format them as TAP
|
||||||
|
check: prepare-check
|
||||||
|
TEST_AUDIT_NO_SELINUX=1 test/common/run-tests ${RUN_TESTS_OPTIONS}
|
||||||
|
|
||||||
|
bots: $(COCKPIT_REPO_STAMP)
|
||||||
|
test/common/make-bots
|
||||||
|
|
||||||
|
test/reference: test/common
|
||||||
|
test/common/pixel-tests pull
|
||||||
|
|
||||||
|
# We want tools/node-modules to run every time package-lock.json is requested
|
||||||
|
# See https://www.gnu.org/software/make/manual/html_node/Force-Targets.html
|
||||||
|
FORCE:
|
||||||
|
$(NODE_MODULES_TEST): FORCE tools/node-modules
|
||||||
|
./node-modules-fix.sh
|
||||||
|
|
||||||
|
.PHONY: all clean install devel-install devel-uninstall print-version dist rpm prepare-check check vm print-vm
|
86
ui/cockpit-docker/README.md
Normal file
86
ui/cockpit-docker/README.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# cockpit-docker
|
||||||
|
|
||||||
|
This is the [Cockpit](https://cockpit-project.org/) user interface for [docker
|
||||||
|
containers](https://docker.io/).
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
- cockpit-docker communicates to docker through its [REST API](https://docs.docker.com/engine/api/v1.43/).
|
||||||
|
|
||||||
|
- This project is based on [cockpit-podman](https://github.com/cockpit-project/cockpit-podman), I ported as much as I could to the docker API, but not everything maps (e.g. pods) and not everything is ported yet.
|
||||||
|
|
||||||
|
# Development dependencies
|
||||||
|
|
||||||
|
On Debian/Ubuntu:
|
||||||
|
|
||||||
|
$ sudo apt install gettext nodejs make
|
||||||
|
|
||||||
|
On Fedora:
|
||||||
|
|
||||||
|
$ sudo dnf install gettext nodejs make
|
||||||
|
|
||||||
|
# Getting and building the source
|
||||||
|
|
||||||
|
These commands check out the source and build it into the `dist/` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/cockpit-docker/cockpit-docker
|
||||||
|
cd cockpit-docker
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
# Installing
|
||||||
|
|
||||||
|
`sudo make install` installs the package in `/usr/local/share/cockpit/`. This depends
|
||||||
|
on the `dist` target, which generates the distribution tarball.
|
||||||
|
|
||||||
|
You can also run `make rpm` to build RPMs for local installation.
|
||||||
|
|
||||||
|
In `production` mode, source files are automatically minified and compressed.
|
||||||
|
Set `NODE_ENV=production` if you want to duplicate this behavior.
|
||||||
|
|
||||||
|
## Arch Derivatives
|
||||||
|
[AUR package](https://aur.archlinux.org/packages/cockpit-docker)
|
||||||
|
|
||||||
|
`yay -Ss cockpit-docker`
|
||||||
|
|
||||||
|
OR for Manjaro
|
||||||
|
|
||||||
|
`pamac install cockpit-docker`
|
||||||
|
|
||||||
|
# Development instructions
|
||||||
|
|
||||||
|
See [HACKING.md](./HACKING.md) for details about how to efficiently change the
|
||||||
|
code, run, and test it.
|
||||||
|
|
||||||
|
# Automated release
|
||||||
|
|
||||||
|
The intention is that the only manual step for releasing a project is to create
|
||||||
|
a signed tag for the version number, which includes a summary of the noteworthy
|
||||||
|
changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
123
|
||||||
|
|
||||||
|
- this new feature
|
||||||
|
- fix bug #123
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing the release tag triggers the [release.yml](.github/workflows/release.yml)
|
||||||
|
[GitHub action](https://github.com/features/actions) workflow. This creates the
|
||||||
|
official release tarball and publishes as upstream release to GitHub.
|
||||||
|
|
||||||
|
The Fedora and COPR releases are done with [Packit](https://packit.dev/),
|
||||||
|
see the [packit.yaml](./packit.yaml) control file.
|
||||||
|
|
||||||
|
# Automated maintenance
|
||||||
|
|
||||||
|
It is important to keep your [NPM modules](./package.json) up to date, to keep
|
||||||
|
up with security updates and bug fixes. This happens with
|
||||||
|
[dependabot](https://github.com/dependabot),
|
||||||
|
see [configuration file](.github/dependabot.yml).
|
||||||
|
|
||||||
|
Translations are refreshed every Tuesday evening (or manually) through the
|
||||||
|
[weblate-sync-po.yml](.github/workflows/weblate-sync-po.yml) action.
|
||||||
|
Conversely, the PO template is uploaded to weblate every day through the
|
||||||
|
[weblate-sync-pot.yml](.github/workflows/weblate-sync-pot.yml) action.
|
108
ui/cockpit-docker/build.js
Executable file
108
ui/cockpit-docker/build.js
Executable file
@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
import copy from 'esbuild-plugin-copy';
|
||||||
|
|
||||||
|
import { cockpitCompressPlugin } from './pkg/lib/esbuild-compress-plugin.js';
|
||||||
|
import { cockpitPoEsbuildPlugin } from './pkg/lib/cockpit-po-plugin.js';
|
||||||
|
import { cockpitRsyncEsbuildPlugin } from './pkg/lib/cockpit-rsync-plugin.js';
|
||||||
|
import { cleanPlugin } from './pkg/lib/esbuild-cleanup-plugin.js';
|
||||||
|
import { esbuildStylesPlugins } from './pkg/lib/esbuild-common.js';
|
||||||
|
|
||||||
|
const useWasm = os.arch() !== 'x64';
|
||||||
|
const esbuild = (await import(useWasm ? 'esbuild-wasm' : 'esbuild'));
|
||||||
|
|
||||||
|
const production = process.env.NODE_ENV === 'production';
|
||||||
|
/* List of directories to use when resolving import statements */
|
||||||
|
const nodePaths = ['pkg/lib'];
|
||||||
|
const outdir = 'dist';
|
||||||
|
|
||||||
|
// Obtain package name from package.json
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync('package.json'));
|
||||||
|
|
||||||
|
const parser = (await import('argparse')).default.ArgumentParser();
|
||||||
|
parser.add_argument('-r', '--rsync', { help: "rsync bundles to ssh target after build", metavar: "HOST" });
|
||||||
|
parser.add_argument('-w', '--watch', { action: 'store_true', help: "Enable watch mode", default: process.env.ESBUILD_WATCH === "true" });
|
||||||
|
const args = parser.parse_args();
|
||||||
|
|
||||||
|
if (args.rsync)
|
||||||
|
process.env.RSYNC = args.rsync;
|
||||||
|
|
||||||
|
function notifyEndPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'notify-end',
|
||||||
|
setup(build) {
|
||||||
|
let startTime;
|
||||||
|
|
||||||
|
build.onStart(() => {
|
||||||
|
startTime = new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
build.onEnd(() => {
|
||||||
|
const endTime = new Date();
|
||||||
|
const timeStamp = endTime.toTimeString().split(' ')[0];
|
||||||
|
console.log(`${timeStamp}: Build finished in ${endTime - startTime} ms`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
...!production ? { sourcemap: "linked" } : {},
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: ["./src/index.js"],
|
||||||
|
external: ['*.woff', '*.woff2', '*.jpg', '*.svg', '../../assets*'], // Allow external font files which live in ../../static/fonts
|
||||||
|
legalComments: 'external', // Move all legal comments to a .LEGAL.txt file
|
||||||
|
loader: { ".js": "jsx" },
|
||||||
|
minify: production,
|
||||||
|
nodePaths,
|
||||||
|
outdir,
|
||||||
|
target: ['es2020'],
|
||||||
|
plugins: [
|
||||||
|
cleanPlugin(),
|
||||||
|
// Esbuild will only copy assets that are explicitly imported and used
|
||||||
|
// in the code. This is a problem for index.html and manifest.json which are not imported
|
||||||
|
copy({
|
||||||
|
assets: [
|
||||||
|
{ from: ['./src/manifest.json'], to: ['./manifest.json'] },
|
||||||
|
{ from: ['./src/index.html'], to: ['./index.html'] },
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...esbuildStylesPlugins,
|
||||||
|
cockpitPoEsbuildPlugin(),
|
||||||
|
|
||||||
|
...production ? [cockpitCompressPlugin()] : [],
|
||||||
|
cockpitRsyncEsbuildPlugin({ dest: packageJson.name }),
|
||||||
|
|
||||||
|
notifyEndPlugin(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Building ${production ? "for production" : "for dev"}...`);
|
||||||
|
await context.rebuild();
|
||||||
|
} catch (e) {
|
||||||
|
if (!args.watch)
|
||||||
|
process.exit(1);
|
||||||
|
// ignore errors in watch mode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.watch) {
|
||||||
|
// Attention: this does not watch subdirectories -- if you ever introduce one, need to set up one watch per subdir
|
||||||
|
fs.watch('src', {}, async (ev, path) => {
|
||||||
|
// only listen for "change" events, as renames are noisy
|
||||||
|
if (ev !== "change")
|
||||||
|
return;
|
||||||
|
console.log("change detected:", path);
|
||||||
|
await context.cancel();
|
||||||
|
try {
|
||||||
|
await context.rebuild();
|
||||||
|
} catch (e) {} // ignore in watch mode
|
||||||
|
});
|
||||||
|
// wait forever until Control-C
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.dispose();
|
16
ui/cockpit-docker/me.chabad360.docker.metainfo.xml
Normal file
16
ui/cockpit-docker/me.chabad360.docker.metainfo.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="addon">
|
||||||
|
<id>me.chabad360.docker</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<name>Docker</name>
|
||||||
|
<summary>
|
||||||
|
Cockpit component for Docker containers
|
||||||
|
</summary>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
The Cockpit user interface for Docker containers.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<extends>org.cockpit_project.cockpit</extends>
|
||||||
|
<launchable type="cockpit-manifest">docker</launchable>
|
||||||
|
</component>
|
9
ui/cockpit-docker/node-modules-fix.sh
Executable file
9
ui/cockpit-docker/node-modules-fix.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
tools/node-modules make_package_lock_json || ( \
|
||||||
|
sed -i 's/local sha="${1-$(get_index_gitlink node_modules)}"/local sha="${2-$(get_index_gitlink node_modules)}"/' tools/node-modules && \
|
||||||
|
tools/node-modules checkout --force && \
|
||||||
|
sed -i 's/"name": "podman"/"name": "docker"/' node_modules/.package.json && \
|
||||||
|
sed -i 's/"description": "Cockpit UI for Podman Containers"/"description": "Cockpit UI for Docker Containers"/' node_modules/.package.json && \
|
||||||
|
sed -i 's/"repository": "git@github.com:cockpit-project\/cockpit-podman.git"/"repository": "https:\/\/github.com\/chabad360\/cockpit-docker.git"/' node_modules/.package.json && \
|
||||||
|
sed -i 's/"name": "podman"/"name": "docker"/' node_modules/.package-lock.json && \
|
||||||
|
tools/node-modules make_package_lock_json \
|
||||||
|
)
|
61
ui/cockpit-docker/package.json
Normal file
61
ui/cockpit-docker/package.json
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "docker",
|
||||||
|
"description": "Cockpit UI for Docker Containers",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": "https://github.com/chabad360/cockpit-docker.git",
|
||||||
|
"author": "",
|
||||||
|
"license": "LGPL-2.1",
|
||||||
|
"scripts": {
|
||||||
|
"watch": "./build.js -w",
|
||||||
|
"build": "./build.js",
|
||||||
|
"eslint": "eslint --ext .jsx --ext .js src/",
|
||||||
|
"eslint:fix": "eslint --fix --ext .jsx --ext .js src/",
|
||||||
|
"stylelint": "stylelint src/*{.css,scss}",
|
||||||
|
"stylelint:fix": "stylelint --fix src/*{.css,scss}"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"argparse": "2.0.1",
|
||||||
|
"chrome-remote-interface": "^0.33.0",
|
||||||
|
"esbuild": "0.20.2",
|
||||||
|
"esbuild-plugin-copy": "2.1.1",
|
||||||
|
"esbuild-plugin-replace": "1.4.0",
|
||||||
|
"esbuild-sass-plugin": "3.2.0",
|
||||||
|
"esbuild-wasm": "0.20.2",
|
||||||
|
"eslint": "8.57.0",
|
||||||
|
"eslint-config-standard": "17.1.0",
|
||||||
|
"eslint-config-standard-jsx": "11.0.0",
|
||||||
|
"eslint-config-standard-react": "13.0.0",
|
||||||
|
"eslint-plugin-import": "2.29.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "6.8.0",
|
||||||
|
"eslint-plugin-promise": "6.1.1",
|
||||||
|
"eslint-plugin-react": "7.34.1",
|
||||||
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
|
"gettext-parser": "8.0.0",
|
||||||
|
"htmlparser": "1.7.7",
|
||||||
|
"jed": "1.1.1",
|
||||||
|
"sass": "1.75.0",
|
||||||
|
"sizzle": "2.3.10",
|
||||||
|
"stylelint": "16.4.0",
|
||||||
|
"stylelint-config-standard-scss": "13.1.0",
|
||||||
|
"stylelint-formatter-pretty": "4.0.0",
|
||||||
|
"stylelint-use-logical-spec": "5.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@patternfly/patternfly": "5.3.0",
|
||||||
|
"@patternfly/react-core": "5.3.0",
|
||||||
|
"@patternfly/react-icons": "5.3.0",
|
||||||
|
"@patternfly/react-styles": "5.3.0",
|
||||||
|
"@patternfly/react-table": "5.3.0",
|
||||||
|
"@patternfly/react-tokens": "5.3.0",
|
||||||
|
"date-fns": "3.6.0",
|
||||||
|
"docker-names": "1.2.1",
|
||||||
|
"ipaddr.js": "2.2.0",
|
||||||
|
"prop-types": "15.8.1",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"throttle-debounce": "5.0.0",
|
||||||
|
"xterm": "5.1.0",
|
||||||
|
"xterm-addon-canvas": "0.4.0"
|
||||||
|
}
|
||||||
|
}
|
22
ui/cockpit-docker/packaging/debian/control
Normal file
22
ui/cockpit-docker/packaging/debian/control
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
Source: cockpit-docker
|
||||||
|
Section: admin
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Martin Pitt <mendel@chabad360.me>
|
||||||
|
Build-Depends: debhelper-compat (= 13),
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
Rules-Requires-Root: no
|
||||||
|
Homepage: https://github.com/chabad360/cockpit-docker
|
||||||
|
Vcs-Git: https://github.com/chabad360/cockpit-docker.git
|
||||||
|
Vcs-Browser: https://github.com/chabad360/cockpit-docker
|
||||||
|
|
||||||
|
Package: cockpit-docker
|
||||||
|
Architecture: all
|
||||||
|
Multi-Arch: foreign
|
||||||
|
Depends: ${misc:Depends},
|
||||||
|
cockpit-bridge,
|
||||||
|
docker (>= 24),
|
||||||
|
Description: Cockpit component for docker containers
|
||||||
|
The Cockpit Web Console enables users to administer GNU/Linux servers using a
|
||||||
|
web browser.
|
||||||
|
.
|
||||||
|
This package adds an user interface for docker containers.
|
38
ui/cockpit-docker/packaging/debian/copyright
Normal file
38
ui/cockpit-docker/packaging/debian/copyright
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: cockpit-docker
|
||||||
|
Source: https://github.com/chabad360/cockpit-docker
|
||||||
|
Comment:
|
||||||
|
This does not directly cover the files in dist/*. These are "minified" and
|
||||||
|
compressed JavaScript/HTML files built from src/, lib/, po/, and node_modules/
|
||||||
|
with node, npm, and a bundler. node_modules/ is not shipped as part of the
|
||||||
|
upstream release tarballs, but can be reconstructed precisely through the
|
||||||
|
shipped package-lock.json with the command "npm install". Rebuilding files in
|
||||||
|
dist/ requires internet access as that process needs to download additional
|
||||||
|
npm modules from the Internet, thus upstream ships the pre-minified bundles
|
||||||
|
as part of the upstream release tarball so that the package can be built
|
||||||
|
without internet access and lots of extra unpackaged build dependencies.
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2016-2020 Red Hat, Inc and 2023 Jewish Education Media.
|
||||||
|
License: LGPL-2.1
|
||||||
|
This package is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License as published by the Free Software Foundation; either
|
||||||
|
version 2.1 of the License, or (at your option) any later version.
|
||||||
|
.
|
||||||
|
This package is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
.
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
.
|
||||||
|
On Debian systems, the complete text of the GNU Lesser General
|
||||||
|
Public License can be found in "/usr/share/common-licenses/LGPL-2.1".
|
||||||
|
|
||||||
|
Files: *.metainfo.xml
|
||||||
|
Copyright: Copyright (C) 2018 Red Hat, Inc and 2023 Jewish Education Media.
|
||||||
|
License: CC0-1.0
|
||||||
|
On Debian systems, the complete text of the Creative Commons Zero v1.0
|
||||||
|
Universal Public License is in "/usr/share/common-licenses/LGPL-2.1".
|
13
ui/cockpit-docker/packaging/debian/rules
Executable file
13
ui/cockpit-docker/packaging/debian/rules
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
|
||||||
|
export PREFIX=/usr
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
# don't call `make clean`, in a release dist/ is precious
|
||||||
|
rm -f po/LINGUAS
|
||||||
|
|
||||||
|
override_dh_auto_test:
|
||||||
|
# don't call `make check`, these are integration tests
|
1
ui/cockpit-docker/packaging/debian/source/format
Normal file
1
ui/cockpit-docker/packaging/debian/source/format
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.0 (quilt)
|
@ -0,0 +1,2 @@
|
|||||||
|
# source contains NPM modules required for running browser integration tests
|
||||||
|
cockpit-docker source: source-is-missing *node_modules/*
|
4
ui/cockpit-docker/packaging/debian/upstream/metadata
Normal file
4
ui/cockpit-docker/packaging/debian/upstream/metadata
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
Bug-Database: https://github.com/chabad360/cockpit-docker/issues
|
||||||
|
Bug-Submit: https://github.com/chabad360/cockpit-docker/issues/new
|
||||||
|
Repository-Browse: https://github.com/chabad360/cockpit-docker
|
5
ui/cockpit-docker/packaging/debian/watch
Normal file
5
ui/cockpit-docker/packaging/debian/watch
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
version=4
|
||||||
|
opts="searchmode=plain, \
|
||||||
|
filenamemangle=s/.+\/@PACKAGE@-@ANY_VERSION@.tar.gz/@PACKAGE@-$1\.tar\.xz/" \
|
||||||
|
https://api.github.com/repos/cockpit-chabad360/@PACKAGE@/releases \
|
||||||
|
https://github.com/cockpit-chabad360/@PACKAGE@/releases/download/\d[\.\d]*/@PACKAGE@-@ANY_VERSION@.tar.xz
|
80
ui/cockpit-docker/packit.yaml
Normal file
80
ui/cockpit-docker/packit.yaml
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
upstream_project_url: https://github.com/chabad360/cockpit-docker
|
||||||
|
# enable notification of failed downstream jobs as issues
|
||||||
|
issue_repository: https://github.com/chabad360/cockpit-docker
|
||||||
|
specfile_path: cockpit-docker.spec
|
||||||
|
upstream_package_name: cockpit-docker
|
||||||
|
downstream_package_name: cockpit-docker
|
||||||
|
# use the nicely formatted release description from our upstream release, instead of git shortlog
|
||||||
|
copy_upstream_release_description: true
|
||||||
|
|
||||||
|
actions:
|
||||||
|
post-upstream-clone: make cockpit-docker.spec
|
||||||
|
create-archive: make dist
|
||||||
|
|
||||||
|
srpm_build_deps:
|
||||||
|
- make
|
||||||
|
- npm
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- job: copr_build
|
||||||
|
trigger: pull_request
|
||||||
|
targets:
|
||||||
|
- fedora-39
|
||||||
|
- fedora-40
|
||||||
|
- fedora-latest-aarch64
|
||||||
|
- fedora-development
|
||||||
|
- centos-stream-9-x86_64
|
||||||
|
- centos-stream-9-aarch64
|
||||||
|
- centos-stream-8-x86_64
|
||||||
|
|
||||||
|
- job: tests
|
||||||
|
trigger: pull_request
|
||||||
|
targets:
|
||||||
|
- fedora-39
|
||||||
|
- fedora-40
|
||||||
|
- fedora-latest-aarch64
|
||||||
|
- fedora-development
|
||||||
|
- centos-stream-9-x86_64
|
||||||
|
- centos-stream-9-aarch64
|
||||||
|
- centos-stream-8-x86_64
|
||||||
|
|
||||||
|
- job: copr_build
|
||||||
|
trigger: release
|
||||||
|
owner: "@cockpit"
|
||||||
|
project: "cockpit-preview"
|
||||||
|
preserve_project: True
|
||||||
|
actions:
|
||||||
|
post-upstream-clone: make cockpit-docker.spec
|
||||||
|
# HACK: tarball for releases (copr_build, koji, etc.), copying spec's Source0; this
|
||||||
|
# really should be the default, see https://github.com/packit/packit-service/issues/1505
|
||||||
|
create-archive:
|
||||||
|
- sh -exc "curl -L -O https://github.com/chabad360/cockpit-docker/releases/download/${PACKIT_PROJECT_VERSION}/${PACKIT_PROJECT_NAME_VERSION}.tar.xz"
|
||||||
|
- sh -exc "ls ${PACKIT_PROJECT_NAME_VERSION}.tar.xz"
|
||||||
|
|
||||||
|
- job: copr_build
|
||||||
|
trigger: commit
|
||||||
|
branch: "^main$"
|
||||||
|
owner: "@cockpit"
|
||||||
|
project: "main-builds"
|
||||||
|
preserve_project: True
|
||||||
|
|
||||||
|
- job: propose_downstream
|
||||||
|
trigger: release
|
||||||
|
dist_git_branches:
|
||||||
|
- fedora-development
|
||||||
|
- fedora-39
|
||||||
|
- fedora-40
|
||||||
|
|
||||||
|
- job: koji_build
|
||||||
|
trigger: commit
|
||||||
|
dist_git_branches:
|
||||||
|
- fedora-development
|
||||||
|
- fedora-39
|
||||||
|
- fedora-40
|
||||||
|
|
||||||
|
- job: bodhi_update
|
||||||
|
trigger: commit
|
||||||
|
dist_git_branches:
|
||||||
|
# rawhide updates are created automatically
|
||||||
|
- fedora-39
|
||||||
|
- fedora-40
|
23
ui/cockpit-docker/plans/all.fmf
Normal file
23
ui/cockpit-docker/plans/all.fmf
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
discover:
|
||||||
|
how: fmf
|
||||||
|
execute:
|
||||||
|
how: tmt
|
||||||
|
|
||||||
|
# Let's handle them upstream only, don't break Fedora/RHEL reverse dependency gating
|
||||||
|
environment:
|
||||||
|
TEST_AUDIT_NO_SELINUX: 1
|
||||||
|
|
||||||
|
/system:
|
||||||
|
summary: Run tests on system docker
|
||||||
|
discover+:
|
||||||
|
test: /test/browser/system
|
||||||
|
|
||||||
|
/user:
|
||||||
|
summary: Run tests on user docker
|
||||||
|
discover+:
|
||||||
|
test: /test/browser/user
|
||||||
|
|
||||||
|
/misc:
|
||||||
|
summary: Run other tests
|
||||||
|
discover+:
|
||||||
|
test: /test/browser/other
|
1510
ui/cockpit-docker/po/cs.po
Normal file
1510
ui/cockpit-docker/po/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
1505
ui/cockpit-docker/po/de.po
Normal file
1505
ui/cockpit-docker/po/de.po
Normal file
File diff suppressed because it is too large
Load Diff
1489
ui/cockpit-docker/po/es.po
Normal file
1489
ui/cockpit-docker/po/es.po
Normal file
File diff suppressed because it is too large
Load Diff
1484
ui/cockpit-docker/po/fi.po
Normal file
1484
ui/cockpit-docker/po/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
1529
ui/cockpit-docker/po/fr.po
Normal file
1529
ui/cockpit-docker/po/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
1467
ui/cockpit-docker/po/ja.po
Normal file
1467
ui/cockpit-docker/po/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
1408
ui/cockpit-docker/po/ka.po
Normal file
1408
ui/cockpit-docker/po/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
1461
ui/cockpit-docker/po/ko.po
Normal file
1461
ui/cockpit-docker/po/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
1508
ui/cockpit-docker/po/pl.po
Normal file
1508
ui/cockpit-docker/po/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
1553
ui/cockpit-docker/po/sk.po
Normal file
1553
ui/cockpit-docker/po/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
1484
ui/cockpit-docker/po/sv.po
Normal file
1484
ui/cockpit-docker/po/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
1501
ui/cockpit-docker/po/tr.po
Normal file
1501
ui/cockpit-docker/po/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
1522
ui/cockpit-docker/po/uk.po
Normal file
1522
ui/cockpit-docker/po/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
1479
ui/cockpit-docker/po/zh_CN.po
Normal file
1479
ui/cockpit-docker/po/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
51
ui/cockpit-docker/pyproject.toml
Normal file
51
ui/cockpit-docker/pyproject.toml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
exclude = [
|
||||||
|
".git/",
|
||||||
|
"modules/",
|
||||||
|
"node_modules/",
|
||||||
|
]
|
||||||
|
line-length = 118
|
||||||
|
preview = true
|
||||||
|
src = []
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"A", # flake8-builtins
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"D300", # pydocstyle: Forbid ''' in docstrings
|
||||||
|
"DTZ", # flake8-datetimez
|
||||||
|
"E", # pycodestyle
|
||||||
|
"EXE", # flake8-executable
|
||||||
|
"F", # pyflakes
|
||||||
|
"FBT", # flake8-boolean-trap
|
||||||
|
"G", # flake8-logging-format
|
||||||
|
"I", # isort
|
||||||
|
"ICN", # flake8-import-conventions
|
||||||
|
"ISC", # flake8-implicit-str-concat
|
||||||
|
"PLE", # pylint errors
|
||||||
|
"PGH", # pygrep-hooks
|
||||||
|
"RSE", # flake8-raise
|
||||||
|
"RUF", # ruff rules
|
||||||
|
"T10", # flake8-debugger
|
||||||
|
"TCH", # flake8-type-checking
|
||||||
|
"UP032", # f-string
|
||||||
|
"W", # warnings (mostly whitespace)
|
||||||
|
"YTT", # flake8-2020
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"FBT002", # Boolean default value in function definition
|
||||||
|
"FBT003", # Boolean positional value in function call
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-pytest-style]
|
||||||
|
fixture-parentheses = false
|
||||||
|
mark-parentheses = false
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["cockpit"]
|
||||||
|
|
||||||
|
[tool.vulture]
|
||||||
|
ignore_names = [
|
||||||
|
"test[A-Z0-9]*",
|
||||||
|
]
|
68
ui/cockpit-docker/src/ContainerCheckpointModal.jsx
Normal file
68
ui/cockpit-docker/src/ContainerCheckpointModal.jsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { Form } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerCheckpointModal = ({ containerWillCheckpoint, onAddNotification }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
const [inProgress, setProgress] = useState(false);
|
||||||
|
const [keep, setKeep] = useState(false);
|
||||||
|
const [leaveRunning, setLeaveRunning] = useState(false);
|
||||||
|
const [tcpEstablished, setTcpEstablished] = useState(false);
|
||||||
|
|
||||||
|
const handleCheckpointContainer = () => {
|
||||||
|
setProgress(true);
|
||||||
|
client.postContainer("checkpoint", containerWillCheckpoint.Id, {
|
||||||
|
keep,
|
||||||
|
leaveRunning,
|
||||||
|
tcpEstablished,
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to checkpoint container $0"), containerWillCheckpoint.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
setProgress(false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Dialogs.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
showClose={false}
|
||||||
|
position="top" variant="medium"
|
||||||
|
title={cockpit.format(_("Checkpoint container $0"), containerWillCheckpoint.Name)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="primary" isDisabled={inProgress}
|
||||||
|
isLoading={inProgress}
|
||||||
|
onClick={handleCheckpointContainer}>
|
||||||
|
{_("Checkpoint")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" isDisabled={inProgress}
|
||||||
|
onClick={Dialogs.close}>
|
||||||
|
{_("Cancel")}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Form isHorizontal>
|
||||||
|
<Checkbox label={_("Keep all temporary checkpoint files")} id="checkpoint-dialog-keep"
|
||||||
|
name="keep" isChecked={keep} onChange={(_, val) => setKeep(val)} />
|
||||||
|
<Checkbox label={_("Leave running after writing checkpoint to disk")}
|
||||||
|
id="checkpoint-dialog-leaveRunning" name="leaveRunning"
|
||||||
|
isChecked={leaveRunning} onChange={(_, val) => setLeaveRunning(val)} />
|
||||||
|
<Checkbox label={_("Support preserving established TCP connections")}
|
||||||
|
id="checkpoint-dialog-tcpEstablished" name="tcpEstablished"
|
||||||
|
isChecked={tcpEstablished} onChange={(_, val) => setTcpEstablished(val) } />
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerCheckpointModal;
|
158
ui/cockpit-docker/src/ContainerCommitModal.jsx
Normal file
158
ui/cockpit-docker/src/ContainerCommitModal.jsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import { FormHelper } from 'cockpit-components-form-helper.jsx';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import { ErrorNotification } from './Notification.jsx';
|
||||||
|
import { fmt_to_fragments } from 'utils.jsx';
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerCommitModal = ({ container, localImages }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
|
||||||
|
const [imageName, setImageName] = useState("");
|
||||||
|
const [tag, setTag] = useState("");
|
||||||
|
const [author, setAuthor] = useState("");
|
||||||
|
const [command, setCommand] = useState(utils.quote_cmdline(container.Config.Cmd));
|
||||||
|
const [pause, setPause] = useState(false);
|
||||||
|
|
||||||
|
const [dialogError, setDialogError] = useState("");
|
||||||
|
const [dialogErrorDetail, setDialogErrorDetail] = useState("");
|
||||||
|
const [commitInProgress, setCommitInProgress] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState("");
|
||||||
|
|
||||||
|
const handleCommit = (force) => {
|
||||||
|
if (!force && !imageName) {
|
||||||
|
setNameError(_("Image name is required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_name = imageName + ":" + (tag !== "" ? tag : "latest");
|
||||||
|
if (full_name.indexOf("/") < 0)
|
||||||
|
full_name = "localhost/" + full_name;
|
||||||
|
|
||||||
|
if (!force && localImages.some(image => image.Name === full_name)) {
|
||||||
|
setNameError(_("Image name is not unique"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(word) {
|
||||||
|
word = word.replace(/"/g, '\\"');
|
||||||
|
return '"' + word + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitData = {};
|
||||||
|
commitData.container = container.Id;
|
||||||
|
commitData.repo = imageName;
|
||||||
|
commitData.author = author;
|
||||||
|
commitData.pause = pause;
|
||||||
|
commitData.format = 'docker';
|
||||||
|
|
||||||
|
if (tag)
|
||||||
|
commitData.tag = tag;
|
||||||
|
|
||||||
|
commitData.changes = [];
|
||||||
|
if (command.trim() !== "") {
|
||||||
|
let cmdData = "";
|
||||||
|
const words = utils.unquote_cmdline(command.trim());
|
||||||
|
const cmdStr = words.map(quote).join(", ");
|
||||||
|
cmdData = "CMD [" + cmdStr + "]";
|
||||||
|
commitData.changes.push(cmdData);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommitInProgress(true);
|
||||||
|
setNameError("");
|
||||||
|
setDialogError("");
|
||||||
|
setDialogErrorDetail("");
|
||||||
|
client.commitContainer(commitData)
|
||||||
|
.then(() => Dialogs.close())
|
||||||
|
.catch(ex => {
|
||||||
|
setDialogError(cockpit.format(_("Failed to commit container $0"), container.Name));
|
||||||
|
setDialogErrorDetail(cockpit.format("$0: $1", ex.message, ex.reason));
|
||||||
|
setCommitInProgress(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitContent = (
|
||||||
|
<Form isHorizontal>
|
||||||
|
{dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} onDismiss={() => setDialogError("")} />}
|
||||||
|
<FormGroup fieldId="commit-dialog-image-name" label={_("New image name")}>
|
||||||
|
<TextInput id="commit-dialog-image-name"
|
||||||
|
value={imageName}
|
||||||
|
validated={nameError ? "error" : "default"}
|
||||||
|
onChange={(_, value) => { setNameError(""); setImageName(value) }} />
|
||||||
|
<FormHelper fieldId="commit-dialog-image-name" helperTextInvalid={nameError} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup fieldId="commit-dialog-image-tag" label={_("Tag")}>
|
||||||
|
<TextInput id="commit-dialog-image-tag"
|
||||||
|
placeholder="latest" // Do not translate
|
||||||
|
value={tag}
|
||||||
|
onChange={(_, value) => { setNameError(""); setTag(value) }} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup fieldId="commit-dialog-author" label={_("Author")}>
|
||||||
|
<TextInput id="commit-dialog-author"
|
||||||
|
placeholder={_("Example, Your Name <yourname@example.com>")}
|
||||||
|
value={author}
|
||||||
|
onChange={(_, value) => setAuthor(value)} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup fieldId="commit-dialog-command" label={_("Command")}>
|
||||||
|
<TextInput id="commit-dialog-command"
|
||||||
|
value={command}
|
||||||
|
onChange={(_, value) => setCommand(value)} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup fieldId="commit-dialog-pause" label={_("Options")} isStack hasNoPaddingTop>
|
||||||
|
<Checkbox id="commit-dialog-pause"
|
||||||
|
isChecked={pause}
|
||||||
|
onChange={(_, val) => setPause(val)}
|
||||||
|
label={_("Pause container when creating image")} />
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
showClose={false}
|
||||||
|
position="top" variant="medium"
|
||||||
|
title={_("Commit container")}
|
||||||
|
description={fmt_to_fragments(_("Create a new image based on the current state of the $0 container."), <b>{container.Name}</b>)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="primary"
|
||||||
|
className="btn-ctr-commit"
|
||||||
|
isLoading={commitInProgress && !nameError}
|
||||||
|
isDisabled={commitInProgress || nameError}
|
||||||
|
onClick={() => handleCommit(false)}>
|
||||||
|
{_("Commit")}
|
||||||
|
</Button>
|
||||||
|
{nameError && <Button variant="warning"
|
||||||
|
className="btn-ctr-commit-force"
|
||||||
|
isLoading={commitInProgress}
|
||||||
|
isDisabled={commitInProgress}
|
||||||
|
onClick={() => handleCommit(true)}>
|
||||||
|
{_("Force commit")}
|
||||||
|
</Button>}
|
||||||
|
<Button variant="link"
|
||||||
|
className="btn-ctr-cancel-commit"
|
||||||
|
isDisabled={commitInProgress}
|
||||||
|
onClick={Dialogs.close}>
|
||||||
|
{_("Cancel")}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{commitContent}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerCommitModal;
|
42
ui/cockpit-docker/src/ContainerDeleteModal.jsx
Normal file
42
ui/cockpit-docker/src/ContainerDeleteModal.jsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerDeleteModal = ({ containerWillDelete, onAddNotification }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
|
||||||
|
const handleRemoveContainer = () => {
|
||||||
|
const container = containerWillDelete;
|
||||||
|
const id = container ? container.Id : "";
|
||||||
|
|
||||||
|
Dialogs.close();
|
||||||
|
client.delContainer(id, false)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to remove container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
position="top" variant="medium"
|
||||||
|
titleIconVariant="warning"
|
||||||
|
onClose={Dialogs.close}
|
||||||
|
title={cockpit.format(_("Delete $0?"), containerWillDelete.Name)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="danger" className="btn-ctr-delete" onClick={handleRemoveContainer}>{_("Delete")}</Button>{' '}
|
||||||
|
<Button variant="link" onClick={Dialogs.close}>{_("Cancel")}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{_("Deleting a container will erase all data in it.")}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerDeleteModal;
|
80
ui/cockpit-docker/src/ContainerDetails.jsx
Normal file
80
ui/cockpit-docker/src/ContainerDetails.jsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList";
|
||||||
|
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const render_container_state = (container) => {
|
||||||
|
if (container.State.Status === "running") {
|
||||||
|
return cockpit.format(_("Up since $0"), utils.localize_time(Date.parse(container.State.StartedAt) / 1000));
|
||||||
|
}
|
||||||
|
return cockpit.format(_("Exited"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContainerDetails = ({ container }) => {
|
||||||
|
const networkOptions = (
|
||||||
|
[
|
||||||
|
container.NetworkSettings?.IPAddress,
|
||||||
|
container.NetworkSettings?.Gateway,
|
||||||
|
container.NetworkSettings?.MacAddress,
|
||||||
|
].some(itm => !!itm)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex>
|
||||||
|
<FlexItem>
|
||||||
|
<DescriptionList className='container-details-basic'>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("ID")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.truncate_id(container.Id)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Image")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{container.Config.Image}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Command")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.quote_cmdline(container.Config?.Cmd)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
</DescriptionList>
|
||||||
|
</FlexItem>
|
||||||
|
<FlexItem>
|
||||||
|
{networkOptions && <DescriptionList columnModifier={{ default: '2Col' }} className='container-details-networking'>
|
||||||
|
{container.NetworkSettings?.IPAddress && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("IP address")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{container.NetworkSettings.IPAddress}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{container.NetworkSettings?.Gateway && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Gateway")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{container.NetworkSettings.Gateway}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{container.NetworkSettings?.MacAddress && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("MAC address")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{container.NetworkSettings.MacAddress}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
</DescriptionList>}
|
||||||
|
</FlexItem>
|
||||||
|
<FlexItem>
|
||||||
|
<DescriptionList className='container-details-state'>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Created")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.localize_time(new Date(container.Created) / 1000)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("State")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{render_container_state(container)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
{container.State?.Checkpointed && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Latest checkpoint")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.localize_time(Date.parse(container.State.CheckpointedAt) / 1000)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
</DescriptionList>
|
||||||
|
</FlexItem>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerDetails;
|
22
ui/cockpit-docker/src/ContainerHeader.jsx
Normal file
22
ui/cockpit-docker/src/ContainerHeader.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar";
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerHeader = ({ textFilter, handleFilterChanged }) => {
|
||||||
|
return (
|
||||||
|
<Toolbar className="pf-m-page-insets">
|
||||||
|
<ToolbarContent>
|
||||||
|
<ToolbarItem>
|
||||||
|
<TextInput id="containers-filter"
|
||||||
|
placeholder={_("Type to filter…")}
|
||||||
|
value={textFilter}
|
||||||
|
onChange={(_, value) => handleFilterChanged(value)} />
|
||||||
|
</ToolbarItem>
|
||||||
|
</ToolbarContent>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerHeader;
|
158
ui/cockpit-docker/src/ContainerHealthLogs.jsx
Normal file
158
ui/cockpit-docker/src/ContainerHealthLogs.jsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022 Red Hat, Inc and 2023 Jewish Education Media.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
import { ListingTable } from "cockpit-components-table.jsx";
|
||||||
|
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList";
|
||||||
|
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { CheckCircleIcon, ErrorCircleOIcon } from "@patternfly/react-icons";
|
||||||
|
import { CodeBlock, CodeBlockAction, CodeBlockCode } from '@patternfly/react-core/dist/esm/components/CodeBlock';
|
||||||
|
import { ClipboardCopyButton } from '@patternfly/react-core/dist/esm/components/ClipboardCopy';
|
||||||
|
import { ExpandableSection, ExpandableSectionToggle } from '@patternfly/react-core/dist/esm/components/ExpandableSection';
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const format_nanoseconds = (ns) => {
|
||||||
|
const seconds = ns / 1000000000;
|
||||||
|
return cockpit.format(cockpit.ngettext("$0 second", "$0 seconds", seconds), seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HealthcheckOnFailureActionText = {
|
||||||
|
none: _("No action"),
|
||||||
|
restart: _("Restart"),
|
||||||
|
stop: _("Stop"),
|
||||||
|
kill: _("Force stop"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const HealthLogBlock = ({ log }) => {
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const toggleExpanded = () => setExpanded(!expanded);
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<CodeBlockAction>
|
||||||
|
<ClipboardCopyButton variant="plain" aria-label={_("Copy to clipboard")} text={log.Output} />
|
||||||
|
</CodeBlockAction>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = log.Output.split("\n");
|
||||||
|
let extra = null;
|
||||||
|
if (output.length > 10) {
|
||||||
|
extra = output.slice(10).join("\n");
|
||||||
|
output = output.slice(0, 10).join("\n");
|
||||||
|
} else {
|
||||||
|
output = output.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeBlock actions={actions}>
|
||||||
|
<CodeBlockCode>
|
||||||
|
{output}
|
||||||
|
{extra && <ExpandableSection isDetached contentId='log-expand' onToggle={toggleExpanded}>
|
||||||
|
{extra}
|
||||||
|
</ExpandableSection>}
|
||||||
|
</CodeBlockCode>
|
||||||
|
{ extra && <ExpandableSectionToggle isExpanded={expanded} onToggle={toggleExpanded} contentId="log-expand" direction="up">
|
||||||
|
{expanded ? 'Show Less' : 'Show More'}
|
||||||
|
</ExpandableSectionToggle>}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContainerHealthLogs = ({ container, onAddNotification, state }) => {
|
||||||
|
const healthCheck = container.Config?.Healthcheck ?? container.Config?.Health ?? {}; // not-covered: only on old version
|
||||||
|
const healthState = container.State?.Healthcheck ?? container.State?.Health ?? {}; // not-covered: only on old version
|
||||||
|
const logs = [...(healthState.Log || [])].reverse(); // not-covered: Log should always exist, belt-and-suspenders
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex alignItems={{ default: "alignItemsFlexStart" }}>
|
||||||
|
<FlexItem grow={{ default: 'grow' }}>
|
||||||
|
<DescriptionList isAutoFit id="container-details-healthcheck">
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Status")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{state}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Command")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.quote_cmdline(healthCheck.Test)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
{healthCheck.Interval && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Interval")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{format_nanoseconds(healthCheck.Interval)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{healthCheck.Retries && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Retries")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{healthCheck.Retries}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{healthCheck.StartPeriod && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Start period")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{format_nanoseconds(healthCheck.StartPeriod)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{healthCheck.Timeout && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Timeout")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{format_nanoseconds(healthCheck.Timeout)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{container.Config?.HealthcheckOnFailureAction && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("When unhealthy")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{HealthcheckOnFailureActionText[container.Config.HealthcheckOnFailureAction]}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{healthState.FailingStreak && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Failing streak")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{healthState.FailingStreak}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
</DescriptionList>
|
||||||
|
</FlexItem>
|
||||||
|
</Flex>
|
||||||
|
<ListingTable aria-label={_("Logs")}
|
||||||
|
className="health-logs"
|
||||||
|
variant='compact'
|
||||||
|
columns={[_("Last 5 runs"), _("Exit Code"), _("Started at")]}
|
||||||
|
rows={
|
||||||
|
logs.map(log => {
|
||||||
|
const id = "hc" + log.Start + container.Id;
|
||||||
|
return {
|
||||||
|
expandedContent: log.Output ? <HealthLogBlock log={log} /> : null,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: <Flex flexWrap={{ default: 'nowrap' }} spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
|
||||||
|
{log.ExitCode === 0 ? <CheckCircleIcon className="green" /> : <ErrorCircleOIcon className="red" />}
|
||||||
|
<span>{log.ExitCode === 0 ? _("Passed health run") : _("Failed health run")}</span>
|
||||||
|
</Flex>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <pre>{log.ExitCode}</pre>
|
||||||
|
},
|
||||||
|
utils.localize_time(Date.parse(log.Start) / 1000)
|
||||||
|
],
|
||||||
|
props: {
|
||||||
|
key: id,
|
||||||
|
"data-row-id": id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerHealthLogs;
|
125
ui/cockpit-docker/src/ContainerIntegration.jsx
Normal file
125
ui/cockpit-docker/src/ContainerIntegration.jsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList";
|
||||||
|
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List";
|
||||||
|
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
|
||||||
|
|
||||||
|
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
// ports is a mapping like { "5000/tcp": [{"HostIp": "", "HostPort": "6000"}] }
|
||||||
|
export const renderContainerPublishedPorts = ports => {
|
||||||
|
if (!ports)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
Object.entries(ports).forEach(([containerPort, hostBindings]) => {
|
||||||
|
(hostBindings ?? []).forEach(binding => { // not-covered: null was observed in the wild, but unknown how to reproduce
|
||||||
|
items.push(
|
||||||
|
<ListItem key={ containerPort + binding.HostIp + binding.HostPort }>
|
||||||
|
{ binding.HostIp || "0.0.0.0" }:{ binding.HostPort } → { containerPort }
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return <List isPlain>{items}</List>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderContainerVolumes = (volumes) => {
|
||||||
|
if (!volumes.length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const result = volumes.map(volume => {
|
||||||
|
let source = volume.Source;
|
||||||
|
if (volume.Source.includes(`docker/volumes/${volume.Name}`)) {
|
||||||
|
source = volume.Name;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ListItem key={volume.Source + volume.Destination}>
|
||||||
|
{source}
|
||||||
|
{volume.RW
|
||||||
|
? <Tooltip content={_("Read-write access")}><span> ↔ </span></Tooltip>
|
||||||
|
: <Tooltip content={_("Read-only access")}><span> → </span></Tooltip>}
|
||||||
|
{volume.Destination}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return <List isPlain>{result}</List>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContainerEnv = ({ containerEnv, imageEnv }) => {
|
||||||
|
// filter out some Environment variables set by docker or by image
|
||||||
|
const toRemoveEnv = [...imageEnv, 'container=docker'];
|
||||||
|
let toShow = containerEnv.filter(variable => {
|
||||||
|
if (toRemoveEnv.includes(variable)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !variable.match(/(HOME|TERM)=.*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// append filtered out variables to always shown variables when 'show more' is clicked
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
if (showMore)
|
||||||
|
toShow = toShow.concat(containerEnv.filter(variable => !toShow.includes(variable)));
|
||||||
|
|
||||||
|
if (!toShow.length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const result = toShow.map(variable => {
|
||||||
|
return (
|
||||||
|
<ListItem key={variable}>
|
||||||
|
{variable}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
<ListItem key='show-more-env-button'>
|
||||||
|
<Button variant='link' isInline
|
||||||
|
onClick={() => setShowMore(!showMore)}>
|
||||||
|
{showMore ? _("Show less") : _("Show more")}
|
||||||
|
</Button>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <List isPlain>{result}</List>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContainerIntegration = ({ container, localImages }) => {
|
||||||
|
if (localImages === null) { // not-covered: not a stable UI state
|
||||||
|
return (
|
||||||
|
<EmptyStatePanel title={_("Loading details...")} loading />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = renderContainerPublishedPorts(container.NetworkSettings.Ports);
|
||||||
|
const volumes = renderContainerVolumes(container.Mounts);
|
||||||
|
|
||||||
|
const image = localImages.filter(img => img.Id === container.Image)[0];
|
||||||
|
const env = <ContainerEnv containerEnv={container.Config.Env} imageEnv={image.Env} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DescriptionList isAutoColumnWidths columnModifier={{ md: '3Col' }} className='container-integration'>
|
||||||
|
{ports && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Ports")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{ports}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{volumes && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Volumes")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{volumes}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
{env && <DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Environment variables")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{env}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>}
|
||||||
|
</DescriptionList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerIntegration;
|
174
ui/cockpit-docker/src/ContainerLogs.jsx
Normal file
174
ui/cockpit-docker/src/ContainerLogs.jsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2020 Red Hat, Inc.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Terminal } from "xterm";
|
||||||
|
import { CanvasAddon } from 'xterm-addon-canvas';
|
||||||
|
import { ExclamationCircleIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import rest from './rest.js';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
|
||||||
|
|
||||||
|
import "./ContainerTerminal.css";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
class ContainerLogs extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.onStreamClose = this.onStreamClose.bind(this);
|
||||||
|
this.onStreamMessage = this.onStreamMessage.bind(this);
|
||||||
|
this.connectStream = this.connectStream.bind(this);
|
||||||
|
|
||||||
|
this.view = new Terminal({
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
convertEol: true,
|
||||||
|
cursorBlink: false,
|
||||||
|
disableStdin: true,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Menlo, Monaco, Consolas, monospace',
|
||||||
|
screenReaderMode: true
|
||||||
|
});
|
||||||
|
this.view._core.cursorHidden = true;
|
||||||
|
this.view.write(_("Loading logs..."));
|
||||||
|
|
||||||
|
this.logRef = React.createRef();
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
opened: false,
|
||||||
|
loading: true,
|
||||||
|
errorMessage: "",
|
||||||
|
streamer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._ismounted = true;
|
||||||
|
this.connectStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
// Connect channel when there is none and container started
|
||||||
|
if (!this.state.streamer && this.props.containerStatus === "running" && prevProps.containerStatus !== "running")
|
||||||
|
this.connectStream();
|
||||||
|
if (prevProps.width !== this.props.width) {
|
||||||
|
this.resize(this.props.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width) {
|
||||||
|
// 24 PF padding * 4
|
||||||
|
// 3 line border
|
||||||
|
// 21 inner padding of xterm.js
|
||||||
|
// xterm.js scrollbar 20
|
||||||
|
const padding = 24 * 4 + 3 + 21 + 20;
|
||||||
|
const realWidth = this.view._core._renderService.dimensions.css.cell.width;
|
||||||
|
const cols = Math.floor((width - padding) / realWidth);
|
||||||
|
this.view.resize(cols, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._ismounted = false;
|
||||||
|
if (this.state.streamer)
|
||||||
|
this.state.streamer.close();
|
||||||
|
this.view.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectStream() {
|
||||||
|
if (this.state.streamer !== null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Show the terminal. Once it was shown, do not show it again but reuse the previous one
|
||||||
|
if (!this.state.opened) {
|
||||||
|
this.view.open(this.logRef.current);
|
||||||
|
this.view.loadAddon(new CanvasAddon());
|
||||||
|
this.setState({ opened: true });
|
||||||
|
}
|
||||||
|
this.resize(this.props.width);
|
||||||
|
|
||||||
|
const connection = rest.connect(client.getAddress());
|
||||||
|
const options = {
|
||||||
|
method: "GET",
|
||||||
|
path: client.VERSION + "/containers/" + this.props.containerId + "/logs",
|
||||||
|
body: "",
|
||||||
|
binary: true,
|
||||||
|
params: {
|
||||||
|
follow: true,
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.monitor(options, this.onStreamMessage, true)
|
||||||
|
.then(this.onStreamClose)
|
||||||
|
.catch(e => {
|
||||||
|
const error = JSON.parse(new TextDecoder().decode(e.message));
|
||||||
|
this.setState({
|
||||||
|
errorMessage: error.message,
|
||||||
|
streamer: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.setState({
|
||||||
|
streamer: connection,
|
||||||
|
errorMessage: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onStreamMessage(data) {
|
||||||
|
if (data) {
|
||||||
|
if (this.state.loading) {
|
||||||
|
this.view.reset();
|
||||||
|
this.view._core.cursorHidden = true;
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
// First 8 bytes encode information about stream and frame
|
||||||
|
// See 'Stream format' on https://docs.docker.com/engine/api/v1.40/#operation/ContainerAttach
|
||||||
|
this.view.writeln(data.slice(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStreamClose() {
|
||||||
|
if (this._ismounted) {
|
||||||
|
this.setState({
|
||||||
|
streamer: null,
|
||||||
|
});
|
||||||
|
this.view.write("Streaming disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let element = <div className="container-logs" ref={this.logRef} />;
|
||||||
|
if (this.state.errorMessage)
|
||||||
|
element = <EmptyStatePanel icon={ExclamationCircleIcon} title={this.state.errorMessage} />;
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerLogs.propTypes = {
|
||||||
|
containerId: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerLogs;
|
107
ui/cockpit-docker/src/ContainerRenameModal.jsx
Normal file
107
ui/cockpit-docker/src/ContainerRenameModal.jsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
import { ErrorNotification } from './Notification.jsx';
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
import { FormHelper } from 'cockpit-components-form-helper.jsx';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerRenameModal = ({ container, updateContainer }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
const [name, setName] = useState(container.Name.replace(/^\//, ""));
|
||||||
|
const { version } = utils.useDockerInfo();
|
||||||
|
const [nameError, setNameError] = useState(null);
|
||||||
|
const [dialogError, setDialogError] = useState(null);
|
||||||
|
const [dialogErrorDetail, setDialogErrorDetail] = useState(null);
|
||||||
|
|
||||||
|
const handleInputChange = (targetName, value) => {
|
||||||
|
if (targetName === "name") {
|
||||||
|
setName(value);
|
||||||
|
if (value === "") {
|
||||||
|
setNameError(_("Container name is required."));
|
||||||
|
} else if (utils.is_valid_container_name(value)) {
|
||||||
|
setNameError(null);
|
||||||
|
} else {
|
||||||
|
setNameError(_("Invalid characters. Name can only contain letters, numbers, and certain punctuation (_ . -)."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (!name) {
|
||||||
|
setNameError(_("Container name is required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNameError(null);
|
||||||
|
setDialogError(null);
|
||||||
|
client.renameContainer(container.Id, { name })
|
||||||
|
.then(() => {
|
||||||
|
Dialogs.close();
|
||||||
|
// HACK: This is a workaround for missing API rename event in docker versions less than 4.1.
|
||||||
|
if (version.localeCompare("4.1", undefined, { numeric: true, sensitivity: 'base' }) < 0) {
|
||||||
|
updateContainer(container.Id); // not-covered: only on old version
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
setDialogError(cockpit.format(_("Failed to rename container $0"), container.Name)); // not-covered: OS error
|
||||||
|
setDialogErrorDetail(cockpit.format("$0: $1", ex.message, ex.reason));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleRename();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameContent = (
|
||||||
|
<Form isHorizontal>
|
||||||
|
<FormGroup fieldId="rename-dialog-container-name" label={_("New container name")}>
|
||||||
|
<TextInput id="rename-dialog-container-name"
|
||||||
|
value={name}
|
||||||
|
validated={nameError ? "error" : "default"}
|
||||||
|
type="text"
|
||||||
|
aria-label={nameError}
|
||||||
|
onChange={(_, value) => handleInputChange("name", value)} />
|
||||||
|
<FormHelper fieldId="commit-dialog-image-name" helperTextInvalid={nameError} />
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
position="top" variant="medium"
|
||||||
|
onClose={Dialogs.close}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
title={cockpit.format(_("Rename container $0"), container.Name)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="primary"
|
||||||
|
className="btn-ctr-rename"
|
||||||
|
id="btn-rename-dialog-container"
|
||||||
|
isDisabled={nameError}
|
||||||
|
onClick={handleRename}>
|
||||||
|
{_("Rename")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link"
|
||||||
|
className="btn-ctr-cancel-commit"
|
||||||
|
onClick={Dialogs.close}>
|
||||||
|
{_("Cancel")}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} onDismiss={() => setDialogError(null)} />}
|
||||||
|
{renameContent}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerRenameModal;
|
74
ui/cockpit-docker/src/ContainerRestoreModal.jsx
Normal file
74
ui/cockpit-docker/src/ContainerRestoreModal.jsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { Form } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerRestoreModal = ({ containerWillRestore, onAddNotification }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
|
||||||
|
const [inProgress, setInProgress] = useState(false);
|
||||||
|
const [keep, setKeep] = useState(false);
|
||||||
|
const [tcpEstablished, setTcpEstablished] = useState(false);
|
||||||
|
const [ignoreStaticIP, setIgnoreStaticIP] = useState(false);
|
||||||
|
const [ignoreStaticMAC, setIgnoreStaticMAC] = useState(false);
|
||||||
|
|
||||||
|
const handleRestoreContainer = () => {
|
||||||
|
setInProgress(true);
|
||||||
|
client.postContainer("restore", containerWillRestore.Id, {
|
||||||
|
keep,
|
||||||
|
tcpEstablished,
|
||||||
|
ignoreStaticIP,
|
||||||
|
ignoreStaticMAC,
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to restore container $0"), containerWillRestore.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
setInProgress(false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Dialogs.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
showClose={false}
|
||||||
|
position="top" variant="medium"
|
||||||
|
title={cockpit.format(_("Restore container $0"), containerWillRestore.Name)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="primary" isDisabled={inProgress}
|
||||||
|
isLoading={inProgress}
|
||||||
|
onClick={handleRestoreContainer}>
|
||||||
|
{_("Restore")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" isDisabled={inProgress}
|
||||||
|
onClick={Dialogs.close}>
|
||||||
|
{_("Cancel")}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Form isHorizontal>
|
||||||
|
<Checkbox label={_("Keep all temporary checkpoint files")} id="restore-dialog-keep" name="keep"
|
||||||
|
isChecked={keep} onChange={(_, val) => setKeep(val)} />
|
||||||
|
<Checkbox label={_("Restore with established TCP connections")}
|
||||||
|
id="restore-dialog-tcpEstablished" name="tcpEstablished"
|
||||||
|
isChecked={tcpEstablished} onChange={(_, val) => setTcpEstablished(val)} />
|
||||||
|
<Checkbox label={_("Ignore IP address if set statically")} id="restore-dialog-ignoreStaticIP"
|
||||||
|
name="ignoreStaticIP" isChecked={ignoreStaticIP}
|
||||||
|
onChange={(_, val) => setIgnoreStaticIP(val)} />
|
||||||
|
<Checkbox label={_("Ignore MAC address if set statically")} id="restore-dialog-ignoreStaticMAC"
|
||||||
|
name="ignoreStaticMAC" isChecked={ignoreStaticMAC}
|
||||||
|
onChange={(_, val) => setIgnoreStaticMAC(val)} />
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerRestoreModal;
|
7
ui/cockpit-docker/src/ContainerTerminal.css
Normal file
7
ui/cockpit-docker/src/ContainerTerminal.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@import "xterm/css/xterm.css";
|
||||||
|
|
||||||
|
.terminal {
|
||||||
|
/* 5px all around and on right +11 since the scrollbar is 11px wide */
|
||||||
|
padding-block: 5px;
|
||||||
|
padding-inline: 5px 16px;
|
||||||
|
}
|
265
ui/cockpit-docker/src/ContainerTerminal.jsx
Normal file
265
ui/cockpit-docker/src/ContainerTerminal.jsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 Red Hat, Inc and 2023 Jewish Education Media.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { Terminal } from "xterm";
|
||||||
|
import { CanvasAddon } from 'xterm-addon-canvas';
|
||||||
|
import { ErrorNotification } from './Notification.jsx';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
|
||||||
|
|
||||||
|
import "./ContainerTerminal.css";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
const decoder = cockpit.utf8_decoder();
|
||||||
|
const encoder = cockpit.utf8_encoder();
|
||||||
|
|
||||||
|
function sequence_find(seq, find) {
|
||||||
|
let f;
|
||||||
|
const fl = find.length;
|
||||||
|
let s;
|
||||||
|
const sl = (seq.length - fl) + 1;
|
||||||
|
for (s = 0; s < sl; s++) {
|
||||||
|
for (f = 0; f < fl; f++) {
|
||||||
|
if (seq[s + f] !== find[f])
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (f == fl)
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContainerTerminal extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.onChannelClose = this.onChannelClose.bind(this);
|
||||||
|
this.onChannelMessage = this.onChannelMessage.bind(this);
|
||||||
|
this.disconnectChannel = this.disconnectChannel.bind(this);
|
||||||
|
this.connectChannel = this.connectChannel.bind(this);
|
||||||
|
this.resize = this.resize.bind(this);
|
||||||
|
this.connectToTty = this.connectToTty.bind(this);
|
||||||
|
this.execAndConnect = this.execAndConnect.bind(this);
|
||||||
|
this.setUpBuffer = this.setUpBuffer.bind(this);
|
||||||
|
|
||||||
|
this.terminalRef = React.createRef();
|
||||||
|
|
||||||
|
this.term = new Terminal({
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
screenKeys: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Menlo, Monaco, Consolas, monospace',
|
||||||
|
screenReaderMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
container: props.containerId,
|
||||||
|
sessionId: props.containerId,
|
||||||
|
channel: null,
|
||||||
|
buffer: null,
|
||||||
|
opened: false,
|
||||||
|
errorMessage: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.connectChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
// Connect channel when there is none and either container started or tty was resolved
|
||||||
|
if (!this.state.channel && (
|
||||||
|
(this.props.containerStatus === "running" && prevProps.containerStatus !== "running") ||
|
||||||
|
(this.props.tty !== undefined && prevProps.tty === undefined)))
|
||||||
|
this.connectChannel();
|
||||||
|
if (prevProps.width !== this.props.width) {
|
||||||
|
this.resize(this.props.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width) {
|
||||||
|
// 24 PF padding * 4
|
||||||
|
// 3 line border
|
||||||
|
// 21 inner padding of xterm.js
|
||||||
|
// xterm.js scrollbar 20
|
||||||
|
const padding = 24 * 4 + 3 + 21 + 20;
|
||||||
|
const realWidth = this.term._core._renderService.dimensions.css.cell.width;
|
||||||
|
const cols = Math.floor((width - padding) / realWidth);
|
||||||
|
this.term.resize(cols, 24);
|
||||||
|
client.resizeContainersTTY(this.state.sessionId, this.props.tty, cols, 24)
|
||||||
|
.catch(e => this.setState({ errorMessage: e.message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
connectChannel() {
|
||||||
|
if (this.state.channel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.props.containerStatus !== "running")
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.props.tty === undefined)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.props.tty)
|
||||||
|
this.connectToTty();
|
||||||
|
else
|
||||||
|
this.execAndConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpBuffer(channel) {
|
||||||
|
const buffer = channel.buffer();
|
||||||
|
|
||||||
|
// Parse the full HTTP response
|
||||||
|
buffer.callback = (data) => {
|
||||||
|
let ret = 0;
|
||||||
|
let pos = 0;
|
||||||
|
// let headers = "";
|
||||||
|
|
||||||
|
// Double line break separates header from body
|
||||||
|
pos = sequence_find(data, [13, 10, 13, 10]);
|
||||||
|
if (pos == -1)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
if (data.subarray) {
|
||||||
|
data = data.subarray(pos + 4);
|
||||||
|
ret += pos + 4;
|
||||||
|
} else {
|
||||||
|
data = data.slice(pos + 4);
|
||||||
|
ret += pos + 4;
|
||||||
|
}
|
||||||
|
// Set up callback for new incoming messages and if the first response
|
||||||
|
// contained any body, pass it into the callback
|
||||||
|
buffer.callback = this.onChannelMessage;
|
||||||
|
const consumed = this.onChannelMessage(data);
|
||||||
|
return ret + consumed;
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.addEventListener('close', this.onChannelClose);
|
||||||
|
|
||||||
|
// Show the terminal. Once it was shown, do not show it again but reuse the previous one
|
||||||
|
if (!this.state.opened) {
|
||||||
|
this.term.open(this.terminalRef.current);
|
||||||
|
this.term.loadAddon(new CanvasAddon());
|
||||||
|
this.setState({ opened: true });
|
||||||
|
|
||||||
|
this.term.onData((data) => {
|
||||||
|
if (this.state.channel)
|
||||||
|
this.state.channel.send(encoder.encode(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
channel.send(String.fromCharCode(12)); // Send SIGWINCH to show prompt on attaching
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
execAndConnect() {
|
||||||
|
client.execContainer(this.state.container)
|
||||||
|
.then(r => {
|
||||||
|
const channel = cockpit.channel({
|
||||||
|
payload: "stream",
|
||||||
|
unix: client.getAddress(),
|
||||||
|
binary: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.stringify({ Detach: false, Tty: false });
|
||||||
|
channel.send("POST " + client.VERSION + "/exec/" + encodeURIComponent(r.Id) +
|
||||||
|
"/start HTTP/1.0\r\n" +
|
||||||
|
"Content-Type: application/json; charset=utf-8\r\n" +
|
||||||
|
"Content-Length: " + body.length + "\r\n\r\n" + body);
|
||||||
|
|
||||||
|
const buffer = this.setUpBuffer(channel);
|
||||||
|
this.setState({ channel, errorMessage: "", buffer, sessionId: r.Id }, () => { console.log(this.props.width); this.resize(this.props.width) });
|
||||||
|
})
|
||||||
|
.catch(e => this.setState({ errorMessage: e.message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
connectToTty() {
|
||||||
|
const channel = cockpit.channel({
|
||||||
|
payload: "stream",
|
||||||
|
unix: client.getAddress(),
|
||||||
|
binary: true
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.send("POST " + client.VERSION + "/containers/" + encodeURIComponent(this.state.container) +
|
||||||
|
"/attach?stdin=true&stdout=true&stderr=true&stream=true HTTP/1.0\r\n" +
|
||||||
|
"Upgrade: tcp\r\nConnection: Upgrade\r\n\r\n");
|
||||||
|
|
||||||
|
const buffer = this.setUpBuffer(channel);
|
||||||
|
this.setState({ channel, errorMessage: "", buffer });
|
||||||
|
this.resize(this.props.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.disconnectChannel();
|
||||||
|
if (this.state.channel)
|
||||||
|
this.state.channel.close();
|
||||||
|
this.term.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChannelMessage(buffer) {
|
||||||
|
if (buffer)
|
||||||
|
this.term.write(decoder.decode(buffer));
|
||||||
|
return buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChannelClose(event, options) {
|
||||||
|
this.term.write('\x1b[31m disconnected \x1b[m\r\n');
|
||||||
|
this.disconnectChannel();
|
||||||
|
this.setState({ channel: null });
|
||||||
|
this.term.cursorHidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectChannel() {
|
||||||
|
if (this.state.buffer)
|
||||||
|
this.state.buffer.callback = null; // eslint-disable-line react/no-direct-mutation-state
|
||||||
|
if (this.state.channel) {
|
||||||
|
this.state.channel.removeEventListener('close', this.onChannelClose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let element = <div className="container-terminal" ref={this.terminalRef} />;
|
||||||
|
|
||||||
|
if (this.props.containerStatus !== "running" && !this.state.opened)
|
||||||
|
element = <EmptyStatePanel title={_("Container is not running")} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{this.state.errorMessage && <ErrorNotification errorMessage={_("Error occurred while connecting console")} errorDetail={this.state.errorMessage} onDismiss={() => this.setState({ errorMessage: "" })} />}
|
||||||
|
{element}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerTerminal.propTypes = {
|
||||||
|
containerId: PropTypes.string.isRequired,
|
||||||
|
containerStatus: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number.isRequired,
|
||||||
|
tty: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContainerTerminal;
|
611
ui/cockpit-docker/src/Containers.jsx
Normal file
611
ui/cockpit-docker/src/Containers.jsx
Normal file
@ -0,0 +1,611 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge } from "@patternfly/react-core/dist/esm/components/Badge";
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
|
||||||
|
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
|
||||||
|
import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
|
||||||
|
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { LabelGroup } from "@patternfly/react-core/dist/esm/components/Label";
|
||||||
|
import { Text, TextVariants } from "@patternfly/react-core/dist/esm/components/Text";
|
||||||
|
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
|
||||||
|
import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar";
|
||||||
|
import { cellWidth, SortByDirection } from '@patternfly/react-table';
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { ListingTable } from "cockpit-components-table.jsx";
|
||||||
|
import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
|
||||||
|
import ContainerDetails from './ContainerDetails.jsx';
|
||||||
|
import ContainerIntegration from './ContainerIntegration.jsx';
|
||||||
|
import ContainerTerminal from './ContainerTerminal.jsx';
|
||||||
|
import ContainerLogs from './ContainerLogs.jsx';
|
||||||
|
import ContainerHealthLogs from './ContainerHealthLogs.jsx';
|
||||||
|
import ContainerDeleteModal from './ContainerDeleteModal.jsx';
|
||||||
|
import ForceRemoveModal from './ForceRemoveModal.jsx';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import ContainerCommitModal from './ContainerCommitModal.jsx';
|
||||||
|
import ContainerRenameModal from './ContainerRenameModal.jsx';
|
||||||
|
import { useDialogs, DialogsContext } from "dialogs.jsx";
|
||||||
|
|
||||||
|
import './Containers.scss';
|
||||||
|
import '@patternfly/patternfly/utilities/Accessibility/accessibility.css';
|
||||||
|
import { ImageRunModal } from './ImageRunModal.jsx';
|
||||||
|
import PruneUnusedContainersModal from './PruneUnusedContainersModal.jsx';
|
||||||
|
|
||||||
|
import { KebabDropdown } from "cockpit-components-dropdown.jsx";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ContainerActions = ({ container, healthcheck, onAddNotification, localImages, updateContainer }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
const { version } = utils.useDockerInfo();
|
||||||
|
const isRunning = container.State.Status === "running";
|
||||||
|
const isPaused = container.State.Status === "paused";
|
||||||
|
const isRestarting = container.State.Status === "restarting";
|
||||||
|
|
||||||
|
const deleteContainer = (event) => {
|
||||||
|
if (container.State.Status == "running") {
|
||||||
|
const handleForceRemoveContainer = () => {
|
||||||
|
const id = container ? container.Id : "";
|
||||||
|
|
||||||
|
return client.delContainer(id, true)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to force remove container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
throw ex;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Dialogs.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Dialogs.show(<ForceRemoveModal name={container.Name}
|
||||||
|
handleForceRemove={handleForceRemoveContainer}
|
||||||
|
reason={_("Deleting a running container will erase all data in it.")} />);
|
||||||
|
} else {
|
||||||
|
Dialogs.show(<ContainerDeleteModal containerWillDelete={container}
|
||||||
|
onAddNotification={onAddNotification} />);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopContainer = (force) => {
|
||||||
|
const args = {};
|
||||||
|
|
||||||
|
if (force)
|
||||||
|
args.t = 0;
|
||||||
|
client.postContainer("stop", container.Id, args)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to stop container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startContainer = () => {
|
||||||
|
client.postContainer("start", container.Id, {})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to start container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeContainer = () => {
|
||||||
|
client.postContainer("unpause", container.Id, {})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to resume container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseContainer = () => {
|
||||||
|
client.postContainer("pause", container.Id, {})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to pause container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitContainer = () => {
|
||||||
|
Dialogs.show(<ContainerCommitModal container={container}
|
||||||
|
localImages={localImages} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartContainer = (force) => {
|
||||||
|
const args = {};
|
||||||
|
|
||||||
|
if (force)
|
||||||
|
args.t = 0;
|
||||||
|
client.postContainer("restart", container.Id, args)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to restart container $0"), container.Name); // not-covered: OS error
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameContainer = () => {
|
||||||
|
if (container.State.Status !== "running" ||
|
||||||
|
version.localeCompare("3.0.1", undefined, { numeric: true, sensitivity: 'base' }) >= 0) {
|
||||||
|
Dialogs.show(<ContainerRenameModal container={container}
|
||||||
|
updateContainer={updateContainer} />);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRenameAction = () => {
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="rename"
|
||||||
|
onClick={() => renameContainer()}>
|
||||||
|
{_("Rename")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
if (isRunning || isPaused || isRestarting) {
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="stop"
|
||||||
|
onClick={() => stopContainer()}>
|
||||||
|
{_("Stop")}
|
||||||
|
</DropdownItem>,
|
||||||
|
<DropdownItem key="force-stop"
|
||||||
|
onClick={() => stopContainer(true)}>
|
||||||
|
{_("Force stop")}
|
||||||
|
</DropdownItem>,
|
||||||
|
<DropdownItem key="restart"
|
||||||
|
onClick={() => restartContainer()}>
|
||||||
|
{_("Restart")}
|
||||||
|
</DropdownItem>,
|
||||||
|
<DropdownItem key="force-restart"
|
||||||
|
onClick={() => restartContainer(true)}>
|
||||||
|
{_("Force restart")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isPaused) {
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="pause"
|
||||||
|
onClick={() => pauseContainer()}>
|
||||||
|
{_("Pause")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="resume"
|
||||||
|
onClick={() => resumeContainer()}>
|
||||||
|
{_("Resume")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRunning && !isPaused) {
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="start"
|
||||||
|
onClick={() => startContainer()}>
|
||||||
|
{_("Start")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
actions.push(<Divider key="separator-0" />);
|
||||||
|
if (version.localeCompare("3", undefined, { numeric: true, sensitivity: 'base' }) >= 0) {
|
||||||
|
addRenameAction();
|
||||||
|
}
|
||||||
|
} else { // running or paused
|
||||||
|
actions.push(<Divider key="separator-0" />);
|
||||||
|
if (version.localeCompare("3.0.1", undefined, { numeric: true, sensitivity: 'base' }) >= 0) {
|
||||||
|
addRenameAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(<Divider key="separator-1" />);
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="commit"
|
||||||
|
onClick={() => commitContainer()}>
|
||||||
|
{_("Commit")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
actions.push(<Divider key="separator-2" />);
|
||||||
|
actions.push(
|
||||||
|
<DropdownItem key="delete"
|
||||||
|
className="pf-m-danger"
|
||||||
|
onClick={deleteContainer}>
|
||||||
|
{_("Delete")}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <KebabDropdown position="right" dropdownItems={actions} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export let onDownloadContainer = function funcOnDownloadContainer(container) {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
downloadingContainers: [...prevState.downloadingContainers, container]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export let onDownloadContainerFinished = function funcOnDownloadContainerFinished(container) {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
downloadingContainers: prevState.downloadingContainers.filter(entry => entry.name !== container.name),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const localize_health = (state) => {
|
||||||
|
if (state === "healthy")
|
||||||
|
return _("Healthy");
|
||||||
|
else if (state === "unhealthy")
|
||||||
|
return _("Unhealthy");
|
||||||
|
else if (state === "starting")
|
||||||
|
return _("Checking health");
|
||||||
|
else
|
||||||
|
console.error("Unexpected health check status", state);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContainerOverActions = ({ handlePruneUnusedContainers, unusedContainers }) => {
|
||||||
|
const actions = [
|
||||||
|
<DropdownItem key="prune-unused-containers"
|
||||||
|
id="prune-unused-containers-button"
|
||||||
|
component="button"
|
||||||
|
className="pf-m-danger btn-delete"
|
||||||
|
onClick={() => handlePruneUnusedContainers()}
|
||||||
|
isDisabled={unusedContainers.length === 0}>
|
||||||
|
{_("Prune unused containers")}
|
||||||
|
</DropdownItem>,
|
||||||
|
];
|
||||||
|
|
||||||
|
return <KebabDropdown toggleButtonId="containers-actions-dropdown" position="right" dropdownItems={actions} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Containers extends React.Component {
|
||||||
|
static contextType = DialogsContext;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
width: 0,
|
||||||
|
downloadingContainers: [],
|
||||||
|
showPruneUnusedContainersModal: false,
|
||||||
|
};
|
||||||
|
this.renderRow = this.renderRow.bind(this);
|
||||||
|
this.onWindowResize = this.onWindowResize.bind(this);
|
||||||
|
|
||||||
|
this.cardRef = React.createRef();
|
||||||
|
|
||||||
|
onDownloadContainer = onDownloadContainer.bind(this);
|
||||||
|
onDownloadContainerFinished = onDownloadContainerFinished.bind(this);
|
||||||
|
|
||||||
|
window.addEventListener('resize', this.onWindowResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.onWindowResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener('resize', this.onWindowResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(containersStats, container, localImages) {
|
||||||
|
const containerStats = containersStats[container.Id];
|
||||||
|
// if (containerStats?.name === "/build-jaeger-1") {
|
||||||
|
// console.log(container);
|
||||||
|
// console.log(containerStats);
|
||||||
|
// }
|
||||||
|
const image = container.Config?.Image || container.Image;
|
||||||
|
const isToolboxContainer = container.Config?.Labels?.["com.github.containers.toolbox"] === "true";
|
||||||
|
const isDistroboxContainer = container.Config?.Labels?.manager === "distrobox";
|
||||||
|
let localized_health = null;
|
||||||
|
|
||||||
|
// this needs to get along with stub containers from image run dialog, where most properties don't exist yet
|
||||||
|
const healthcheck = container.State?.Health?.Status ?? container.State?.Healthcheck?.Status; // not-covered: only on old version
|
||||||
|
const status = container.State?.Status ?? ""; // not-covered: race condition
|
||||||
|
|
||||||
|
let proc_text = "";
|
||||||
|
let mem_text = "";
|
||||||
|
let proc = 0;
|
||||||
|
let mem = 0;
|
||||||
|
if (this.props.cgroupVersion === 'v1' && status === 'running') { // not-covered: only on old version
|
||||||
|
proc_text = <div><abbr title={_("not available")}>{_("n/a")}</abbr></div>;
|
||||||
|
mem_text = <div><abbr title={_("not available")}>{_("n/a")}</abbr></div>;
|
||||||
|
}
|
||||||
|
if (containerStats && status === "running") {
|
||||||
|
[proc_text, proc] = utils.format_cpu_usage(containerStats);
|
||||||
|
[mem_text, mem] = utils.format_memory_and_limit(containerStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info_block = (
|
||||||
|
<div className="container-block">
|
||||||
|
<Flex alignItems={{ default: 'alignItemsCenter' }}>
|
||||||
|
<span className="container-name">{container.Name}</span>
|
||||||
|
{isToolboxContainer && <Badge className='ct-badge-toolbox'>toolbox</Badge>}
|
||||||
|
{isDistroboxContainer && <Badge className='ct-badge-distrobox'>distrobox</Badge>}
|
||||||
|
</Flex>
|
||||||
|
<small>{image.includes("sha256:") ? utils.truncate_id(image) : image}</small>
|
||||||
|
<small>{utils.quote_cmdline(container.Config?.Cmd)}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let containerStateClass = "ct-badge-container-" + status.toLowerCase();
|
||||||
|
if (container.isDownloading)
|
||||||
|
containerStateClass += " downloading";
|
||||||
|
|
||||||
|
const containerState = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
|
||||||
|
const state = [<Badge key={containerState} isRead className={containerStateClass}>{_(containerState)}</Badge>]; // States are defined in util.js
|
||||||
|
if (healthcheck) {
|
||||||
|
localized_health = localize_health(healthcheck);
|
||||||
|
if (localized_health)
|
||||||
|
state.push(<Badge key={healthcheck} isRead className={"ct-badge-container-" + healthcheck}>{localized_health}</Badge>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: info_block, sortKey: container.Name },
|
||||||
|
{ title: proc_text, props: { modifier: "nowrap" }, sortKey: containerState === "Running" ? proc ?? -1 : -1 },
|
||||||
|
{ title: mem_text, props: { modifier: "nowrap" }, sortKey: mem ?? -1 },
|
||||||
|
{ title: <LabelGroup isVertical>{state}</LabelGroup>, sortKey: containerState },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!container.isDownloading) {
|
||||||
|
columns.push({
|
||||||
|
title: <ContainerActions container={container}
|
||||||
|
healthcheck={healthcheck}
|
||||||
|
onAddNotification={this.props.onAddNotification}
|
||||||
|
localImages={localImages}
|
||||||
|
updateContainer={this.props.updateContainer} />,
|
||||||
|
props: { className: "pf-v5-c-table__action" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tty = !!container.Config?.Tty;
|
||||||
|
|
||||||
|
const tabs = [];
|
||||||
|
if (container.State) {
|
||||||
|
tabs.push({
|
||||||
|
name: _("Details"),
|
||||||
|
renderer: ContainerDetails,
|
||||||
|
data: { container }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!container.isDownloading) {
|
||||||
|
tabs.push({
|
||||||
|
name: _("Integration"),
|
||||||
|
renderer: ContainerIntegration,
|
||||||
|
data: { container, localImages }
|
||||||
|
});
|
||||||
|
tabs.push({
|
||||||
|
name: _("Logs"),
|
||||||
|
renderer: ContainerLogs,
|
||||||
|
data: { containerId: container.Id, containerStatus: container.State.Status, width: this.state.width }
|
||||||
|
});
|
||||||
|
tabs.push({
|
||||||
|
name: _("Console"),
|
||||||
|
renderer: ContainerTerminal,
|
||||||
|
data: { containerId: container.Id, containerStatus: container.State?.Status, width: this.state.width, tty }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthcheck) {
|
||||||
|
tabs.push({
|
||||||
|
name: _("Health check"),
|
||||||
|
renderer: ContainerHealthLogs,
|
||||||
|
data: { container, onAddNotification: this.props.onAddNotification, state: localized_health }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expandedContent: <ListingPanel colSpan='4' tabRenderers={tabs} />,
|
||||||
|
columns,
|
||||||
|
initiallyExpanded: document.location.hash.substr(1) === container.Id,
|
||||||
|
props: {
|
||||||
|
key: container.Id,
|
||||||
|
"data-row-id": container.Id,
|
||||||
|
"data-started-at": container.StartedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowResize() {
|
||||||
|
this.setState({ width: this.cardRef.current.clientWidth });
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenPruneUnusedContainersDialog = () => {
|
||||||
|
this.setState({ showPruneUnusedContainersModal: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const Dialogs = this.context;
|
||||||
|
const columnTitles = [
|
||||||
|
{ title: _("Container"), transforms: [cellWidth(20)], sortable: true },
|
||||||
|
{ title: _("CPU"), sortable: true },
|
||||||
|
{ title: _("Memory"), sortable: true },
|
||||||
|
{ title: _("State"), sortable: true },
|
||||||
|
''
|
||||||
|
];
|
||||||
|
let filtered = [];
|
||||||
|
const unusedContainers = [];
|
||||||
|
|
||||||
|
let emptyCaption = _("No containers");
|
||||||
|
if (this.props.containers === null)
|
||||||
|
emptyCaption = _("Loading...");
|
||||||
|
else if (this.props.textFilter.length > 0)
|
||||||
|
emptyCaption = _("No containers that match the current filter");
|
||||||
|
else if (this.props.filter === "running")
|
||||||
|
emptyCaption = _("No running containers");
|
||||||
|
|
||||||
|
if (this.props.containers !== null) {
|
||||||
|
filtered = Object.keys(this.props.containers).filter(id => !(this.props.filter === "running") || ["running", "restarting"].includes(this.props.containers[id].State?.Status));
|
||||||
|
|
||||||
|
const getHealth = id => {
|
||||||
|
const state = this.props.containers[id]?.State;
|
||||||
|
return state?.Health?.Status || state?.Healthcheck?.Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
// Show unhealthy containers first
|
||||||
|
const a_health = getHealth(a);
|
||||||
|
const b_health = getHealth(b);
|
||||||
|
if (a_health !== b_health) {
|
||||||
|
if (a_health === "unhealthy")
|
||||||
|
return -1;
|
||||||
|
if (b_health === "unhealthy")
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return this.props.containers[a].Name > this.props.containers[b].Name ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const prune_states = ["created", "configured", "stopped", "exited"];
|
||||||
|
for (const containerid of Object.keys(this.props.containers)) {
|
||||||
|
const container = this.props.containers[containerid];
|
||||||
|
// Ignore pods and running containers
|
||||||
|
if (!prune_states.includes(container.State))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
unusedContainers.push({
|
||||||
|
id: container.Id,
|
||||||
|
name: container.Name,
|
||||||
|
created: container.Created,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to the search result output
|
||||||
|
let localImages = null;
|
||||||
|
let nonIntermediateImages = null;
|
||||||
|
if (this.props.images) {
|
||||||
|
localImages = Object.keys(this.props.images).map(id => {
|
||||||
|
const img = this.props.images[id];
|
||||||
|
img.Index = img.RepoTags?.[0] ? img.RepoTags[0].split('/')[0] : "";
|
||||||
|
img.Name = utils.image_name(img);
|
||||||
|
img.toString = function imgToString() { return this.Name };
|
||||||
|
return img;
|
||||||
|
}, []);
|
||||||
|
nonIntermediateImages = localImages.filter(img => img.Index !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createContainer = (inPod) => {
|
||||||
|
if (nonIntermediateImages)
|
||||||
|
Dialogs.show(
|
||||||
|
<utils.DockerInfoContext.Consumer>
|
||||||
|
{(dockerInfo) => (
|
||||||
|
<DialogsContext.Consumer>
|
||||||
|
{(Dialogs) => (
|
||||||
|
<ImageRunModal user={this.props.user}
|
||||||
|
localImages={nonIntermediateImages}
|
||||||
|
serviceAvailable={this.props.serviceAvailable}
|
||||||
|
onAddNotification={this.props.onAddNotification}
|
||||||
|
dockerInfo={dockerInfo}
|
||||||
|
dialogs={Dialogs} />
|
||||||
|
)}
|
||||||
|
</DialogsContext.Consumer>
|
||||||
|
)}
|
||||||
|
</utils.DockerInfoContext.Consumer>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRunning = (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarContent className="containers-containers-toolbarcontent">
|
||||||
|
<ToolbarItem variant="label" htmlFor="containers-containers-filter">
|
||||||
|
{_("Show")}
|
||||||
|
</ToolbarItem>
|
||||||
|
<ToolbarItem>
|
||||||
|
<FormSelect id="containers-containers-filter" value={this.props.filter} onChange={(_, value) => this.props.handleFilterChange(value)}>
|
||||||
|
<FormSelectOption value='all' label={_("All")} />
|
||||||
|
<FormSelectOption value='running' label={_("Only running")} />
|
||||||
|
</FormSelect>
|
||||||
|
</ToolbarItem>
|
||||||
|
<Divider orientation={{ default: "vertical" }} />
|
||||||
|
<ToolbarItem>
|
||||||
|
<Button variant="primary" key="get-new-image-action"
|
||||||
|
id="containers-containers-create-container-btn"
|
||||||
|
isDisabled={nonIntermediateImages === null}
|
||||||
|
onClick={() => createContainer(null)}>
|
||||||
|
{_("Create container")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
|
<ToolbarItem>
|
||||||
|
<ContainerOverActions unusedContainers={unusedContainers} handlePruneUnusedContainers={this.onOpenPruneUnusedContainersDialog} />
|
||||||
|
</ToolbarItem>
|
||||||
|
</ToolbarContent>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortRows = (rows, direction, idx) => {
|
||||||
|
// CPU / Memory /States
|
||||||
|
const isNumeric = idx == 1 || idx == 2 || idx == 3;
|
||||||
|
const stateOrderMapping = {};
|
||||||
|
utils.states.forEach((elem, index) => {
|
||||||
|
stateOrderMapping[elem] = index;
|
||||||
|
});
|
||||||
|
const sortedRows = rows.sort((a, b) => {
|
||||||
|
let aitem = a.columns[idx].sortKey ?? a.columns[idx].title;
|
||||||
|
let bitem = b.columns[idx].sortKey ?? b.columns[idx].title;
|
||||||
|
// Sort the states based on the order defined in utils. so Running first.
|
||||||
|
if (idx === 3) {
|
||||||
|
aitem = stateOrderMapping[aitem];
|
||||||
|
bitem = stateOrderMapping[bitem];
|
||||||
|
}
|
||||||
|
if (isNumeric) {
|
||||||
|
return bitem - aitem;
|
||||||
|
} else {
|
||||||
|
return aitem.localeCompare(bitem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = (
|
||||||
|
<Card id="containers-containers" className="containers-containers" isClickable isSelectable>
|
||||||
|
<CardHeader actions={{ actions: filterRunning }}>
|
||||||
|
<CardTitle><Text component={TextVariants.h2}>{_("Containers")}</Text></CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Flex direction={{ default: 'column' }}>
|
||||||
|
{(this.props.containers === null)
|
||||||
|
? <ListingTable variant='compact'
|
||||||
|
aria-label={_("Containers")}
|
||||||
|
emptyCaption={emptyCaption}
|
||||||
|
columns={columnTitles}
|
||||||
|
sortMethod={sortRows}
|
||||||
|
rows={[]}
|
||||||
|
sortBy={{ index: 0, direction: SortByDirection.asc }} />
|
||||||
|
: <Card key="table-containers"
|
||||||
|
id="table-containers"
|
||||||
|
isPlain
|
||||||
|
// isFlat={section != "no-pod"}
|
||||||
|
className="container-pod"
|
||||||
|
isClickable
|
||||||
|
isSelectable>
|
||||||
|
{/* {caption && <CardHeader actions={{ actions, className: "panel-actions" }}> */}
|
||||||
|
{/* <CardTitle> */}
|
||||||
|
{/* <Flex justifyContent={{ default: 'justifyContentFlexStart' }}> */}
|
||||||
|
{/* <h3 className='pod-name'>{caption}</h3> */}
|
||||||
|
{/* <span>{_("pod group")}</span> */}
|
||||||
|
{/* </Flex> */}
|
||||||
|
{/* </CardTitle> */}
|
||||||
|
{/* </CardHeader>} */}
|
||||||
|
<ListingTable variant='compact'
|
||||||
|
emptyCaption={emptyCaption}
|
||||||
|
columns={columnTitles}
|
||||||
|
sortMethod={sortRows}
|
||||||
|
rows={filtered.map(container => {
|
||||||
|
return this.renderRow(this.props.containersStats, this.props.containers[container],
|
||||||
|
localImages);
|
||||||
|
})}
|
||||||
|
aria-label={_("Containers")} />
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
{this.state.showPruneUnusedContainersModal &&
|
||||||
|
<PruneUnusedContainersModal
|
||||||
|
close={() => this.setState({ showPruneUnusedContainersModal: false })}
|
||||||
|
unusedContainers={unusedContainers}
|
||||||
|
onAddNotification={this.props.onAddNotification}
|
||||||
|
serviceAvailable={this.props.serviceAvailable}
|
||||||
|
user={this.props.user} /> }
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <div ref={this.cardRef}>{card}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Containers;
|
93
ui/cockpit-docker/src/Containers.scss
Normal file
93
ui/cockpit-docker/src/Containers.scss
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
@import "global-variables";
|
||||||
|
|
||||||
|
.container-pod {
|
||||||
|
.pf-v5-c-card__header {
|
||||||
|
border-color: #ddd;
|
||||||
|
padding-block-start: var(--pf-v5-global--spacer--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pod-header-details {
|
||||||
|
border-color: #ddd;
|
||||||
|
margin-block-start: var(--pf-v5-global--spacer--md);
|
||||||
|
margin-inline: var(--pf-v5-global--spacer--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pod-details-button {
|
||||||
|
padding-inline: 0;
|
||||||
|
margin-inline-end: var(--pf-v5-global--spacer--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pod-details-button-color {
|
||||||
|
color: var(--pf-v5-c-button--m-secondary--Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-card__title {
|
||||||
|
padding: 0;
|
||||||
|
font-weight: var(--pf-v5-global--FontWeight--normal);
|
||||||
|
font-size: var(--pf-v5-global--FontSize--sm);
|
||||||
|
|
||||||
|
.pod-name {
|
||||||
|
font-weight: var(--pf-v5-global--FontWeight--bold);
|
||||||
|
font-size: var(--pf-v5-global--FontSize--md);
|
||||||
|
padding-inline-end: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .pf-v5-c-card__header {
|
||||||
|
&:not(:last-child) {
|
||||||
|
padding-block-end: var(--pf-v5-global--spacer-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce vertical padding of pod header items
|
||||||
|
> .pf-v5-c-card__title > .pf-l-flex {
|
||||||
|
row-gap: var(--pf-v5-global--spacer--sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// override ct-card font size, so cpu/ram don't look absurdly big
|
||||||
|
#app .pf-v5-c-card.container-pod div.pf-v5-c-card__title-text {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: var(--pf-v5-global--FontSize--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pod-stat {
|
||||||
|
@media (max-width: $pf-v5-global--breakpoint--sm - 1) {
|
||||||
|
// Place each pod stat on its own row
|
||||||
|
flex-basis: 100%;
|
||||||
|
display: grid;
|
||||||
|
// Give labels to the same space
|
||||||
|
grid-template-columns: minmax(auto, 4rem) 1fr;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
// Hide icons in mobile to be consistent with container lists
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the icons for proper vertical alignment
|
||||||
|
> svg {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-table-empty td {
|
||||||
|
padding-block: var(--pf-v5-global--spacer--sm) var(--pf-v5-global--spacer--lg);
|
||||||
|
padding-inline: var(--pf-v5-global--spacer--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HACK - force DescriptionList to wrap but not fill the width */
|
||||||
|
#container-details-healthcheck {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upstream issue https://github.com/patternfly/patternfly/pull/3714 */
|
||||||
|
.containers-containers .pf-v5-c-toolbar__content-section {
|
||||||
|
gap: var(--pf-v5-global--spacer--sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop the excessive margin for a Dropdown button */
|
||||||
|
.containers-containers .pf-v5-c-toolbar__content-section > :nth-last-child(2) {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
96
ui/cockpit-docker/src/Env.jsx
Normal file
96
ui/cockpit-docker/src/Env.jsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { FormHelper } from "cockpit-components-form-helper.jsx";
|
||||||
|
import { Grid } from "@patternfly/react-core/dist/esm/layouts/Grid";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import { TrashIcon } from '@patternfly/react-icons';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
export function validateEnvVar(env, key) {
|
||||||
|
switch (key) {
|
||||||
|
case "envKey":
|
||||||
|
if (!env)
|
||||||
|
return _("Key must not be empty");
|
||||||
|
break;
|
||||||
|
case "envValue":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`Unknown key "${key}"`); // not-covered: unreachable assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnvValue = (key, value, idx, onChange, additem, itemCount, companionField) => {
|
||||||
|
// Allow the input of KEY=VALUE separated value pairs for bulk import only if the other
|
||||||
|
// field is not empty.
|
||||||
|
if (value.includes('=') && !companionField) {
|
||||||
|
const parts = value.trim().split(" ");
|
||||||
|
let index = idx;
|
||||||
|
for (const part of parts) {
|
||||||
|
const [envKey, ...envVar] = part.split('=');
|
||||||
|
if (!envKey || !envVar) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index !== idx) {
|
||||||
|
additem();
|
||||||
|
}
|
||||||
|
onChange(index, 'envKey', envKey);
|
||||||
|
onChange(index, 'envValue', envVar.join('='));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(idx, key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvVar = ({ id, item, onChange, idx, removeitem, additem, itemCount, validationFailed, onValidationChange }) =>
|
||||||
|
(
|
||||||
|
<Grid hasGutter id={id}>
|
||||||
|
<FormGroup className="pf-m-6-col-on-md"
|
||||||
|
id={id + "-key-group"}
|
||||||
|
label={_("Key")}
|
||||||
|
fieldId={id + "-key-address"}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
<TextInput id={id + "-key"}
|
||||||
|
value={item.envKey || ''}
|
||||||
|
validated={validationFailed?.envKey ? "error" : "default"}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "envKey", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, envKey: validateEnvVar(value, "envKey") }));
|
||||||
|
handleEnvValue('envKey', value, idx, onChange, additem, itemCount, item.envValue);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.envKey} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-6-col-on-md"
|
||||||
|
id={id + "-value-group"}
|
||||||
|
label={_("Value")}
|
||||||
|
fieldId={id + "-value-address"}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
<TextInput id={id + "-value"}
|
||||||
|
value={item.envValue || ''}
|
||||||
|
validated={validationFailed?.envValue ? "error" : "default"}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "envValue", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, envValue: validateEnvVar(value, "envValue") }));
|
||||||
|
handleEnvValue('envValue', value, idx, onChange, additem, itemCount, item.envValue);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.envValue} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-1-col-on-md remove-button-group">
|
||||||
|
<Button variant='plain'
|
||||||
|
className="btn-close"
|
||||||
|
id={id + "-btn-close"}
|
||||||
|
size="sm"
|
||||||
|
aria-label={_("Remove item")}
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
onClick={() => removeitem(idx)} />
|
||||||
|
</FormGroup>
|
||||||
|
</Grid>
|
||||||
|
);
|
33
ui/cockpit-docker/src/ForceRemoveModal.jsx
Normal file
33
ui/cockpit-docker/src/ForceRemoveModal.jsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ForceRemoveModal = ({ name, reason, handleForceRemove }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
const [inProgress, setInProgress] = useState(false);
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
showClose={false}
|
||||||
|
position="top" variant="medium"
|
||||||
|
titleIconVariant="warning"
|
||||||
|
onClose={Dialogs.close}
|
||||||
|
title={cockpit.format(_("Delete $0?"), name)}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="danger" isDisabled={inProgress} isLoading={inProgress}
|
||||||
|
onClick={() => { setInProgress(true); handleForceRemove().catch(() => setInProgress(false)) }}
|
||||||
|
>
|
||||||
|
{_("Force delete")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" isDisabled={inProgress} onClick={Dialogs.close}>{_("Cancel")}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForceRemoveModal;
|
121
ui/cockpit-docker/src/ImageDeleteModal.jsx
Normal file
121
ui/cockpit-docker/src/ImageDeleteModal.jsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { List, ListItem } from '@patternfly/react-core/dist/esm/components/List';
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack";
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import ForceRemoveModal from './ForceRemoveModal.jsx';
|
||||||
|
import * as client from './client.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
function sortTags(a, b) {
|
||||||
|
if (a.endsWith(":latest"))
|
||||||
|
return -1;
|
||||||
|
if (b.endsWith(":latest"))
|
||||||
|
return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageDeleteModal = ({ imageWillDelete, onAddNotification }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
const repoTags = imageWillDelete.RepoTags ? imageWillDelete.RepoTags : [];
|
||||||
|
const isIntermediateImage = repoTags.length === 0;
|
||||||
|
|
||||||
|
const [tags, setTags] = useState(repoTags.sort(sortTags).reduce((acc, item, i) => {
|
||||||
|
acc[item] = (i === 0);
|
||||||
|
return acc;
|
||||||
|
}, {}));
|
||||||
|
|
||||||
|
const checkedTags = Object.keys(tags).sort(sortTags)
|
||||||
|
.filter(x => tags[x]);
|
||||||
|
|
||||||
|
const onValueChanged = (item, value) => {
|
||||||
|
setTags(prevState => ({
|
||||||
|
...prevState,
|
||||||
|
[item]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = (tags, all) => {
|
||||||
|
const handleForceRemoveImage = () => {
|
||||||
|
Dialogs.close();
|
||||||
|
return client.delImage(imageWillDelete.Id, true)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to force remove image $0"), imageWillDelete.RepoTags[0]);
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
throw ex;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Dialogs.close();
|
||||||
|
if (all)
|
||||||
|
client.delImage(imageWillDelete.Id, false)
|
||||||
|
.catch(ex => {
|
||||||
|
Dialogs.show(<ForceRemoveModal name={isIntermediateImage ? _("intermediate image") : repoTags[0]}
|
||||||
|
handleForceRemove={handleForceRemoveImage}
|
||||||
|
reason={ex.message} />);
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
// Call another untag once previous one resolved. Calling all at once can result in undefined behavior
|
||||||
|
const tag = tags.shift();
|
||||||
|
const i = tag.lastIndexOf(":");
|
||||||
|
client.untagImage(imageWillDelete.Id, tag.substring(0, i), tag.substring(i + 1, tag.length))
|
||||||
|
.then(() => {
|
||||||
|
if (tags.length > 0)
|
||||||
|
handleRemoveImage(tags, all);
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to remove image $0"), tag);
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageName = repoTags[0]?.split(":")[0].split("/").at(-1) ?? _("intermediate");
|
||||||
|
|
||||||
|
let isAllSelected = null;
|
||||||
|
if (checkedTags.length === repoTags.length)
|
||||||
|
isAllSelected = true;
|
||||||
|
else if (checkedTags.length === 0)
|
||||||
|
isAllSelected = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
position="top" variant="medium"
|
||||||
|
titleIconVariant="warning"
|
||||||
|
onClose={Dialogs.close}
|
||||||
|
title={cockpit.format(_("Delete $0 image?"), imageName)}
|
||||||
|
footer={<>
|
||||||
|
<Button id="btn-img-delete" variant="danger" isDisabled={!isIntermediateImage && checkedTags.length === 0}
|
||||||
|
onClick={() => handleRemoveImage(checkedTags, checkedTags.length === repoTags.length)}>
|
||||||
|
{isIntermediateImage ? _("Delete image") : _("Delete tagged images")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" onClick={Dialogs.close}>{_("Cancel")}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Stack hasGutter>
|
||||||
|
{ repoTags.length > 1 && <StackItem>{_("Multiple tags exist for this image. Select the tagged images to delete.")}</StackItem> }
|
||||||
|
<StackItem isFilled>
|
||||||
|
{repoTags.length > 1 && <Checkbox isChecked={isAllSelected} id='delete-all' label={_("All")} aria-label='All'
|
||||||
|
onChange={(_event, checked) => repoTags.forEach(item => onValueChanged(item, checked))}
|
||||||
|
body={
|
||||||
|
repoTags.map(x => (
|
||||||
|
<Checkbox isChecked={checkedTags.indexOf(x) > -1}
|
||||||
|
id={"delete-" + x}
|
||||||
|
aria-label={x}
|
||||||
|
key={x}
|
||||||
|
label={x}
|
||||||
|
onChange={(_event, checked) => onValueChanged(x, checked)} />
|
||||||
|
))
|
||||||
|
} />}
|
||||||
|
{repoTags.length === 1 && <List><ListItem>{repoTags[0]}</ListItem></List>}
|
||||||
|
</StackItem>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
47
ui/cockpit-docker/src/ImageDetails.jsx
Normal file
47
ui/cockpit-docker/src/ImageDetails.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList";
|
||||||
|
|
||||||
|
import ImageUsedBy from './ImageUsedBy.jsx';
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ImageDetails = ({ containers, image, showAll }) => {
|
||||||
|
return (
|
||||||
|
<DescriptionList className='image-details' isAutoFit>
|
||||||
|
{image.Command !== "" &&
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Command")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{utils.quote_cmdline(image.Command)}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
}
|
||||||
|
{image.Entrypoint &&
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Entrypoint")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{image.Entrypoint.join(" ")}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
}
|
||||||
|
{image.RepoTags &&
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Tags")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{image.RepoTags ? image.RepoTags.join(" ") : ""}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
}
|
||||||
|
{containers &&
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Used by")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription><ImageUsedBy containers={containers} showAll={showAll} /></DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
}
|
||||||
|
{image.Ports.length !== 0 &&
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>{_("Ports")}</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>{image.Ports.join(', ')}</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
}
|
||||||
|
</DescriptionList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageDetails;
|
64
ui/cockpit-docker/src/ImageHistory.jsx
Normal file
64
ui/cockpit-docker/src/ImageHistory.jsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
import * as client from './client.js';
|
||||||
|
|
||||||
|
import { ListingTable } from "cockpit-components-table.jsx";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const IdColumn = Id => {
|
||||||
|
Id = utils.truncate_id(Id);
|
||||||
|
// Not an id but <missing> or something else
|
||||||
|
if (/<[a-z]+>/.test(Id)) {
|
||||||
|
return <div className="pf-v5-u-disabled-color-100">{Id}</div>;
|
||||||
|
}
|
||||||
|
return Id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageDetails = ({ image }) => {
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const id = image.Id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.imageHistory(id).then(setHistory)
|
||||||
|
.catch(ex => {
|
||||||
|
console.error("Cannot get image history", ex);
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const columns = ["ID", _("Created"), _("Created by"), _("Size"), _("Comments")];
|
||||||
|
let showComments = false;
|
||||||
|
const rows = history.map(layer => {
|
||||||
|
const row = {
|
||||||
|
columns: [
|
||||||
|
{ title: IdColumn(layer.Id), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: utils.localize_time(layer.Created), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: layer.CreatedBy, props: { className: "ignore-pixels" } },
|
||||||
|
{ title: cockpit.format_bytes(layer.Size), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: layer.Comment, props: { className: "ignore-pixels" } },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
if (layer.Comment) {
|
||||||
|
showComments = true;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showComments) {
|
||||||
|
columns.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListingTable
|
||||||
|
variant='compact'
|
||||||
|
isStickyHeader
|
||||||
|
emptyCaption={error ? _("Unable to load image history") : _("Loading details...")}
|
||||||
|
columns={columns}
|
||||||
|
rows={rows} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageDetails;
|
1114
ui/cockpit-docker/src/ImageRunModal.jsx
Normal file
1114
ui/cockpit-docker/src/ImageRunModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
47
ui/cockpit-docker/src/ImageRunModal.scss
Normal file
47
ui/cockpit-docker/src/ImageRunModal.scss
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
@import "global-variables";
|
||||||
|
|
||||||
|
// Ensure the width fits within the screen boundaries (with padding on the sides)
|
||||||
|
.pf-v5-c-select__menu {
|
||||||
|
// 3xl is the left+right padding for an iPhone SE;
|
||||||
|
// this works on other screen sizes as well
|
||||||
|
max-inline-size: calc(100vw - var(--pf-v5-global--spacer--3xl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the footer is visible with more then 5 results.
|
||||||
|
.pf-c-select__menu-list {
|
||||||
|
// 35% viewport height is for 1280x720;
|
||||||
|
// since it picks the min of the two, it works everywhere
|
||||||
|
max-block-size: min(20rem, 35vh);
|
||||||
|
overflow: hidden scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix the dot next to spinner: https://github.com/patternfly/patternfly-react/issues/6383
|
||||||
|
.pf-v5-c-select__list-item.pf-m-loading {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-search-footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.pf-v5-c-toggle-group__text {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PF4 does not yet support multiple form fields for the same label
|
||||||
|
.ct-input-group-spacer-sm.pf-v5-l-flex {
|
||||||
|
// Limit width for select entries and inputs in the input groups otherwise they take up the whole space
|
||||||
|
> .pf-v5-c-select, .pf-v5-c-form-control:not(.pf-v5-c-select__toggle-typeahead) {
|
||||||
|
max-inline-size: 8ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: A local copy of pf-m-horizontal (as ct-m-horizontal),
|
||||||
|
// but applied at the FormGroup level instead of Form
|
||||||
|
@media (min-width: $pf-v5-global--breakpoint--md) {
|
||||||
|
.pf-v5-c-form__group.ct-m-horizontal {
|
||||||
|
display: grid;
|
||||||
|
grid-column-gap: var(--pf-v5-c-form--m-horizontal__group-label--md--GridColumnGap);
|
||||||
|
grid-template-columns: var(--pf-v5-c-form--m-horizontal__group-label--md--GridColumnWidth) var(--pf-v5-c-form--m-horizontal__group-control--md--GridColumnWidth);
|
||||||
|
}
|
||||||
|
}
|
59
ui/cockpit-docker/src/ImageSearchModal.css
Normal file
59
ui/cockpit-docker/src/ImageSearchModal.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.docker-search .pf-v5-c-modal-box__body {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
grid-gap: var(--pf-v5-global--spacer--sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item + .image-list-item {
|
||||||
|
border-block-start: 1px solid var(--pf-v5-global--BorderColor--200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item > .image-name {
|
||||||
|
color: var(--pf-v5-global--Color--100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.image-list-item {
|
||||||
|
grid-template-columns: 1fr max-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-search .image-search-modal-footer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
grid-gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-tag-entry {
|
||||||
|
max-inline-size: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 340px) {
|
||||||
|
/* Shrink buttons to accommodate iPhone 5/SE */
|
||||||
|
.docker-search .modal-footer > .btn {
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-search-tag-form {
|
||||||
|
margin-block-end: var(--pf-v5-global--spacer--md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-search .pf-v5-c-modal-box__footer {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-search .pf-v5-c-data-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-search .pf-v5-l-flex .pf-v5-c-form__group:nth-child(2) {
|
||||||
|
grid-template-columns: 2rem var(--pf-v5-c-form--m-horizontal__group-control--md--GridColumnWidth);
|
||||||
|
}
|
212
ui/cockpit-docker/src/ImageSearchModal.jsx
Normal file
212
ui/cockpit-docker/src/ImageSearchModal.jsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList";
|
||||||
|
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import { ExclamationCircleIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
|
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
|
||||||
|
import { ErrorNotification } from './Notification.jsx';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import rest from './rest.js';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import { fallbackRegistries, useDockerInfo } from './util.js';
|
||||||
|
import { useDialogs } from "dialogs.jsx";
|
||||||
|
|
||||||
|
import './ImageSearchModal.css';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
export const ImageSearchModal = ({ downloadImage }) => {
|
||||||
|
const [searchInProgress, setSearchInProgress] = useState(false);
|
||||||
|
const [searchFinished, setSearchFinished] = useState(false);
|
||||||
|
const [imageIdentifier, setImageIdentifier] = useState('');
|
||||||
|
const [imageList, setImageList] = useState([]);
|
||||||
|
const [imageTag, setImageTag] = useState("");
|
||||||
|
const [selectedRegistry, setSelectedRegistry] = useState("");
|
||||||
|
const [selected, setSelected] = useState("");
|
||||||
|
const [dialogError, setDialogError] = useState("");
|
||||||
|
const [dialogErrorDetail, setDialogErrorDetail] = useState("");
|
||||||
|
const [typingTimeout, setTypingTimeout] = useState(null);
|
||||||
|
|
||||||
|
let activeConnection = null;
|
||||||
|
const { registries } = useDockerInfo();
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
// Registries to use for searching
|
||||||
|
const searchRegistries = registries.search && registries.length !== 0 ? registries.search : fallbackRegistries;
|
||||||
|
|
||||||
|
// Don't use on selectedRegistry state variable for finding out the
|
||||||
|
// registry to search in as with useState we can only call something after a
|
||||||
|
// state update with useEffect but as onSearchTriggered also changes state we
|
||||||
|
// can't use that so instead we pass the selected registry.
|
||||||
|
const onSearchTriggered = (searchRegistry = "", forceSearch = false) => {
|
||||||
|
// When search re-triggers close any existing active connection
|
||||||
|
activeConnection = rest.connect(client.getAddress());
|
||||||
|
if (activeConnection)
|
||||||
|
activeConnection.close();
|
||||||
|
setSearchFinished(false);
|
||||||
|
|
||||||
|
// Do not call the SearchImage API if the input string is not at least 2 chars,
|
||||||
|
// unless Enter is pressed, which should force start the search.
|
||||||
|
// The comparison was done considering the fact that we miss always one letter due to delayed setState
|
||||||
|
if (imageIdentifier.length < 2 && !forceSearch)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setSearchInProgress(true);
|
||||||
|
|
||||||
|
let queryRegistries = searchRegistries;
|
||||||
|
if (searchRegistry !== "") {
|
||||||
|
queryRegistries = [searchRegistry];
|
||||||
|
}
|
||||||
|
// if a user searches for `docker.io/cockpit` let docker search in the user specified registry.
|
||||||
|
if (imageIdentifier.includes('/')) {
|
||||||
|
queryRegistries = [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
const searches = queryRegistries.map(rr => {
|
||||||
|
const registry = rr.length < 1 || rr[rr.length - 1] === "/" ? rr : rr + "/";
|
||||||
|
return activeConnection.call({
|
||||||
|
method: "GET",
|
||||||
|
path: client.VERSION + "/images/search",
|
||||||
|
body: "",
|
||||||
|
params: {
|
||||||
|
term: registry + imageIdentifier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled(searches)
|
||||||
|
.then(reply => {
|
||||||
|
if (reply) {
|
||||||
|
let results = [];
|
||||||
|
|
||||||
|
for (const result of reply) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
results = results.concat(JSON.parse(result.value));
|
||||||
|
// console.log(results);
|
||||||
|
} else {
|
||||||
|
setDialogError(_("Failed to search for new images"));
|
||||||
|
setDialogErrorDetail(result.reason ? cockpit.format(_("Failed to search for images: $0"), result.reason.message) : _("Failed to search for images."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageList(results || []);
|
||||||
|
setSearchInProgress(false);
|
||||||
|
setSearchFinished(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key != ' ') { // Space should not trigger search
|
||||||
|
const forceSearch = e.key == 'Enter';
|
||||||
|
if (forceSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the timer, to make the http call after 250MS
|
||||||
|
clearTimeout(typingTimeout);
|
||||||
|
setTypingTimeout(setTimeout(() => onSearchTriggered(selectedRegistry, forceSearch), 250));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadClicked = () => {
|
||||||
|
const selectedImageName = imageList[selected].name;
|
||||||
|
if (activeConnection)
|
||||||
|
activeConnection.close();
|
||||||
|
Dialogs.close();
|
||||||
|
downloadImage(selectedImageName, imageTag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (activeConnection)
|
||||||
|
activeConnection.close();
|
||||||
|
Dialogs.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen className="docker-search"
|
||||||
|
position="top" variant="large"
|
||||||
|
onClose={handleClose}
|
||||||
|
title={_("Search for an image")}
|
||||||
|
footer={<>
|
||||||
|
<Form isHorizontal className="image-search-tag-form">
|
||||||
|
<FormGroup fieldId="image-search-tag" label={_("Tag")}>
|
||||||
|
<TextInput className="image-tag-entry"
|
||||||
|
id="image-search-tag"
|
||||||
|
type='text'
|
||||||
|
placeholder="latest"
|
||||||
|
value={imageTag || 'latest'}
|
||||||
|
onChange={(_event, value) => setImageTag(value)} />
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
<Button variant='primary' isDisabled={selected === ""} onClick={onDownloadClicked}>
|
||||||
|
{_("Download")}
|
||||||
|
</Button>
|
||||||
|
<Button variant='link' className='btn-cancel' onClick={handleClose}>
|
||||||
|
{_("Cancel")}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Form isHorizontal>
|
||||||
|
{dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} />}
|
||||||
|
<Flex spaceItems={{ default: 'inlineFlex', modifier: 'spaceItemsXl' }}>
|
||||||
|
<FormGroup fieldId="search-image-dialog-name" label={_("Search for")}>
|
||||||
|
<TextInput id='search-image-dialog-name'
|
||||||
|
type='text'
|
||||||
|
placeholder={_("Search by name or description")}
|
||||||
|
value={imageIdentifier}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onChange={(_event, value) => setImageIdentifier(value)} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup fieldId="registry-select" label={_("in")}>
|
||||||
|
<FormSelect id='registry-select'
|
||||||
|
value={selectedRegistry}
|
||||||
|
onChange={(_ev, value) => { setSelectedRegistry(value); clearTimeout(typingTimeout); onSearchTriggered(value, false) }}>
|
||||||
|
<FormSelectOption value="" key="all" label={_("All registries")} />
|
||||||
|
{(searchRegistries || []).map(r => <FormSelectOption value={r} key={r} label={r} />)}
|
||||||
|
</FormSelect>
|
||||||
|
</FormGroup>
|
||||||
|
</Flex>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{searchInProgress && <EmptyStatePanel loading title={_("Searching...")} /> }
|
||||||
|
|
||||||
|
{((!searchInProgress && !searchFinished) || imageIdentifier == "") && <EmptyStatePanel title={_("No images found")} paragraph={_("Start typing to look for images.")} /> }
|
||||||
|
|
||||||
|
{searchFinished && imageIdentifier !== '' && <>
|
||||||
|
{imageList.length == 0 && <EmptyStatePanel icon={ExclamationCircleIcon}
|
||||||
|
title={cockpit.format(_("No results for $0"), imageIdentifier)}
|
||||||
|
paragraph={_("Retry another term.")}
|
||||||
|
/>}
|
||||||
|
{imageList.length > 0 &&
|
||||||
|
<DataList isCompact
|
||||||
|
selectedDataListItemId={"image-list-item-" + selected}
|
||||||
|
onSelectDataListItem={(_, key) => setSelected(key.split('-').slice(-1)[0])}>
|
||||||
|
{imageList.map((image, iter) => {
|
||||||
|
return (
|
||||||
|
<DataListItem id={"image-list-item-" + iter} key={iter}>
|
||||||
|
<DataListItemRow>
|
||||||
|
<DataListItemCells
|
||||||
|
dataListCells={[
|
||||||
|
<DataListCell key="primary content">
|
||||||
|
<span className='image-name'>{image.name}</span>
|
||||||
|
</DataListCell>,
|
||||||
|
<DataListCell key="secondary content" wrapModifier="truncate">
|
||||||
|
<span className='image-description'>{image.description}</span>
|
||||||
|
</DataListCell>
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DataListItemRow>
|
||||||
|
</DataListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DataList>}
|
||||||
|
</>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
44
ui/cockpit-docker/src/ImageUsedBy.jsx
Normal file
44
ui/cockpit-docker/src/ImageUsedBy.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Badge } from "@patternfly/react-core/dist/esm/components/Badge";
|
||||||
|
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const ImageUsedBy = ({ containers, showAll }) => {
|
||||||
|
if (containers === null)
|
||||||
|
return _("Loading...");
|
||||||
|
if (containers === undefined)
|
||||||
|
return _("No containers are using this image");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List isPlain>
|
||||||
|
{containers.map(c => {
|
||||||
|
const container = c.container;
|
||||||
|
const isRunning = container.State?.Status === "running";
|
||||||
|
return (
|
||||||
|
<ListItem key={container.Id}>
|
||||||
|
<Flex>
|
||||||
|
<Button variant="link"
|
||||||
|
isInline
|
||||||
|
onClick={() => {
|
||||||
|
const loc = document.location.toString().split('#')[0];
|
||||||
|
document.location = loc + '#' + container.Id;
|
||||||
|
|
||||||
|
if (!isRunning)
|
||||||
|
showAll();
|
||||||
|
}}>
|
||||||
|
{container.Name}
|
||||||
|
</Button>
|
||||||
|
{isRunning && <Badge className="ct-badge-container-running">{_("Running")}</Badge>}
|
||||||
|
</Flex>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUsedBy;
|
23
ui/cockpit-docker/src/Images.css
Normal file
23
ui/cockpit-docker/src/Images.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#containers-images div.download-in-progress {
|
||||||
|
color: grey;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger dropdown items should be red */
|
||||||
|
.pf-v5-c-dropdown__menu-item.pf-m-danger:not(.pf-m-disabled, .pf-m-aria-disabled) {
|
||||||
|
color: var(--pf-v5-global--danger-color--200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#containers-images .pf-v5-c-table.pf-m-compact .pf-v5-c-table__action {
|
||||||
|
--pf-v5-c-table__action--PaddingTop: 0.5rem;
|
||||||
|
--pf-v5-c-table__action--PaddingBottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containers-images .pf-v5-c-expandable-section__content {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override font-size due to h2 being wrapped in a Flex */
|
||||||
|
.containers-images-title {
|
||||||
|
font-size: var(--pf-v5-global--FontSize--2xl);
|
||||||
|
}
|
398
ui/cockpit-docker/src/Images.jsx
Normal file
398
ui/cockpit-docker/src/Images.jsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Card, CardBody, CardFooter, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
|
||||||
|
import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
|
||||||
|
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection";
|
||||||
|
import { Text, TextVariants } from "@patternfly/react-core/dist/esm/components/Text";
|
||||||
|
import { cellWidth } from '@patternfly/react-table';
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { ListingTable } from "cockpit-components-table.jsx";
|
||||||
|
import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
|
||||||
|
import ImageDetails from './ImageDetails.jsx';
|
||||||
|
import ImageHistory from './ImageHistory.jsx';
|
||||||
|
import { ImageRunModal } from './ImageRunModal.jsx';
|
||||||
|
import { ImageSearchModal } from './ImageSearchModal.jsx';
|
||||||
|
import { ImageDeleteModal } from './ImageDeleteModal.jsx';
|
||||||
|
import PruneUnusedImagesModal from './PruneUnusedImagesModal.jsx';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
import { useDialogs, DialogsContext } from "dialogs.jsx";
|
||||||
|
|
||||||
|
import './Images.css';
|
||||||
|
import '@patternfly/react-styles/css/utilities/Sizing/sizing.css';
|
||||||
|
|
||||||
|
import { KebabDropdown } from "cockpit-components-dropdown.jsx";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
class Images extends React.Component {
|
||||||
|
static contextType = DialogsContext;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
intermediateOpened: false,
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.downloadImage = this.downloadImage.bind(this);
|
||||||
|
this.renderRow = this.renderRow.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadImage(imageName, imageTag) {
|
||||||
|
let pullImageId = imageName;
|
||||||
|
if (!imageTag)
|
||||||
|
imageTag = "latest";
|
||||||
|
|
||||||
|
pullImageId += ":" + imageTag;
|
||||||
|
|
||||||
|
this.setState({ imageDownloadInProgress: imageName });
|
||||||
|
client.pullImage(pullImageId)
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ imageDownloadInProgress: undefined });
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
const error = cockpit.format(_("Failed to download image $0:$1"), imageName, imageTag || "latest");
|
||||||
|
const errorDetail = (
|
||||||
|
<p> {_("Error message")}:
|
||||||
|
<samp>{cockpit.format("$0 $1", ex.message, ex.reason)}</samp>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
this.setState({ imageDownloadInProgress: undefined });
|
||||||
|
this.props.onAddNotification({ type: 'danger', error, errorDetail });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenNewImagesDialog = () => {
|
||||||
|
const Dialogs = this.context;
|
||||||
|
Dialogs.show(
|
||||||
|
<ImageSearchModal downloadImage={this.downloadImage} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onOpenPruneUnusedImagesDialog = () => {
|
||||||
|
this.setState({ showPruneUnusedImagesModal: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
getUsedByText(image) {
|
||||||
|
const { imageContainerList } = this.props;
|
||||||
|
if (imageContainerList === null) {
|
||||||
|
return { title: _("unused"), count: 0 };
|
||||||
|
}
|
||||||
|
const containers = imageContainerList[image.Id];
|
||||||
|
if (containers !== undefined) {
|
||||||
|
const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers.length), containers.length);
|
||||||
|
return { title, count: containers.length };
|
||||||
|
} else {
|
||||||
|
return { title: _("unused"), count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateStats = () => {
|
||||||
|
const { images, imageContainerList } = this.props;
|
||||||
|
const unusedImages = [];
|
||||||
|
const imageStats = {
|
||||||
|
imagesTotal: 0,
|
||||||
|
imagesSize: 0,
|
||||||
|
unusedTotal: 0,
|
||||||
|
unusedSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageContainerList === null) {
|
||||||
|
return { imageStats, unusedImages };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images !== null) {
|
||||||
|
Object.keys(images).forEach(id => {
|
||||||
|
const image = images[id];
|
||||||
|
imageStats.imagesTotal += 1;
|
||||||
|
imageStats.imagesSize += image.Size;
|
||||||
|
|
||||||
|
const usedBy = imageContainerList[image.Id];
|
||||||
|
if (image.Containers === 0 || usedBy === undefined) {
|
||||||
|
imageStats.unusedTotal += 1;
|
||||||
|
imageStats.unusedSize += image.Size;
|
||||||
|
unusedImages.push(image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageStats, unusedImages };
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRow(image) {
|
||||||
|
const tabs = [];
|
||||||
|
const { title: usedByText, count: usedByCount } = this.getUsedByText(image);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: utils.image_name(image), header: true, props: { modifier: "breakWord" } },
|
||||||
|
{ title: utils.localize_time(image.Created), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: utils.truncate_id(image.Id), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: cockpit.format_bytes(image.Size), props: { className: "ignore-pixels", modifier: "nowrap" } },
|
||||||
|
{ title: <span className={usedByCount === 0 ? "ct-grey-text" : ""}>{usedByText}</span>, props: { className: "ignore-pixels", modifier: "nowrap" } },
|
||||||
|
{
|
||||||
|
title: <ImageActions image={image} onAddNotification={this.props.onAddNotification} />,
|
||||||
|
props: { className: 'pf-v5-c-table__action content-action' }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
tabs.push({
|
||||||
|
name: _("Details"),
|
||||||
|
renderer: ImageDetails,
|
||||||
|
data: {
|
||||||
|
image,
|
||||||
|
containers: this.props.imageContainerList !== null ? this.props.imageContainerList[image.Id] : null,
|
||||||
|
showAll: this.props.showAll,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tabs.push({
|
||||||
|
name: _("History"),
|
||||||
|
renderer: ImageHistory,
|
||||||
|
data: {
|
||||||
|
image,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
expandedContent: <ListingPanel
|
||||||
|
colSpan='8'
|
||||||
|
tabRenderers={tabs} />,
|
||||||
|
columns,
|
||||||
|
props: {
|
||||||
|
key: image.Id,
|
||||||
|
"data-row-id": image.Id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const columnTitles = [
|
||||||
|
{ title: _("Image"), transforms: [cellWidth(20)] },
|
||||||
|
{ title: _("Created"), props: { className: "ignore-pixels", width: 15 } },
|
||||||
|
{ title: _("ID"), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: _("Disk space"), props: { className: "ignore-pixels" } },
|
||||||
|
{ title: _("Used by"), props: { className: "ignore-pixels" } },
|
||||||
|
];
|
||||||
|
let emptyCaption = _("No images");
|
||||||
|
if (this.props.images === null)
|
||||||
|
emptyCaption = "Loading...";
|
||||||
|
else if (this.props.textFilter.length > 0)
|
||||||
|
emptyCaption = _("No images that match the current filter");
|
||||||
|
|
||||||
|
const intermediateOpened = this.state.intermediateOpened;
|
||||||
|
|
||||||
|
let filtered = [];
|
||||||
|
if (this.props.images !== null) {
|
||||||
|
filtered = Object.keys(this.props.images).filter(id => {
|
||||||
|
const tags = this.props.images[id].RepoTags || [];
|
||||||
|
if (!intermediateOpened && tags.length < 1)
|
||||||
|
return false;
|
||||||
|
if (this.props.textFilter.length > 0)
|
||||||
|
return tags.some(tag => tag.toLowerCase().indexOf(this.props.textFilter.toLowerCase()) >= 0);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const name_a = this.props.images[a].RepoTags.length > 0 ? this.props.images[a].RepoTags[0] : "";
|
||||||
|
const name_b = this.props.images[b].RepoTags.length > 0 ? this.props.images[b].RepoTags[0] : "";
|
||||||
|
if (name_a === "")
|
||||||
|
return 1;
|
||||||
|
if (name_b === "")
|
||||||
|
return -1;
|
||||||
|
return name_a > name_b ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageRows = filtered.map(id => this.renderRow(this.props.images[id]));
|
||||||
|
|
||||||
|
const interim = this.props.images && Object.keys(this.props.images).some(id => {
|
||||||
|
// Intermediate image does not have any tags
|
||||||
|
if (this.props.images[id].RepoTags && this.props.images[id].RepoTags.length > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Any text filter hides all images
|
||||||
|
if (this.props.textFilter.length > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let toggleIntermediate = "";
|
||||||
|
if (interim) {
|
||||||
|
toggleIntermediate = (
|
||||||
|
<span className="listing-action">
|
||||||
|
<Button variant="link" onClick={() => this.setState({ intermediateOpened: !intermediateOpened, isExpanded: true })}>
|
||||||
|
{intermediateOpened ? _("Hide intermediate images") : _("Show intermediate images")}</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cardBody = (
|
||||||
|
<>
|
||||||
|
<ListingTable aria-label={_("Images")}
|
||||||
|
variant='compact'
|
||||||
|
emptyCaption={emptyCaption}
|
||||||
|
columns={columnTitles}
|
||||||
|
rows={imageRows} />
|
||||||
|
{toggleIntermediate}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { imageStats, unusedImages } = this.calculateStats();
|
||||||
|
const imageTitleStats = (
|
||||||
|
<>
|
||||||
|
<Text component={TextVariants.h5}>
|
||||||
|
{cockpit.format(cockpit.ngettext("$0 image total, $1", "$0 images total, $1", imageStats.imagesTotal), imageStats.imagesTotal, cockpit.format_bytes(imageStats.imagesSize))}
|
||||||
|
</Text>
|
||||||
|
{imageStats.unusedTotal !== 0 &&
|
||||||
|
<Text component={TextVariants.h5}>
|
||||||
|
{cockpit.format(cockpit.ngettext("$0 unused image, $1", "$0 unused images, $1", imageStats.unusedTotal), imageStats.unusedTotal, cockpit.format_bytes(imageStats.unusedSize))}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card id="containers-images" key="images" className="containers-images">
|
||||||
|
<CardHeader>
|
||||||
|
<Flex flexWrap={{ default: 'nowrap' }} className="pf-v5-u-w-100">
|
||||||
|
<FlexItem grow={{ default: 'grow' }}>
|
||||||
|
<Flex>
|
||||||
|
<CardTitle>
|
||||||
|
<Text component={TextVariants.h2} className="containers-images-title">{_("Images")}</Text>
|
||||||
|
</CardTitle>
|
||||||
|
<Flex className="ignore-pixels" style={{ rowGap: "var(--pf-v5-global--spacer--xs)" }}>{imageTitleStats}</Flex>
|
||||||
|
</Flex>
|
||||||
|
</FlexItem>
|
||||||
|
<FlexItem>
|
||||||
|
<ImageOverActions handleDownloadNewImage={this.onOpenNewImagesDialog}
|
||||||
|
handlePruneUsedImages={this.onOpenPruneUnusedImagesDialog}
|
||||||
|
unusedImages={unusedImages} />
|
||||||
|
</FlexItem>
|
||||||
|
</Flex>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{this.props.images && Object.keys(this.props.images).length
|
||||||
|
? <ExpandableSection toggleText={this.state.isExpanded ? _("Hide images") : _("Show images")}
|
||||||
|
onToggle={() => this.setState(prevState => ({ isExpanded: !prevState.isExpanded }))}
|
||||||
|
isExpanded={this.state.isExpanded}>
|
||||||
|
{cardBody}
|
||||||
|
</ExpandableSection>
|
||||||
|
: cardBody}
|
||||||
|
</CardBody>
|
||||||
|
{/* The PruneUnusedImagesModal dialog needs to keep
|
||||||
|
* its list of unused images in sync with reality at
|
||||||
|
* all times since the API call will delete whatever
|
||||||
|
* is unused at the exact time of call, and the
|
||||||
|
* dialog better be showing the correct list of
|
||||||
|
* unused images at that time. Thus, we can't use
|
||||||
|
* Dialog.show for it but include it here in the
|
||||||
|
* DOM. */}
|
||||||
|
{this.state.showPruneUnusedImagesModal &&
|
||||||
|
<PruneUnusedImagesModal
|
||||||
|
close={() => this.setState({ showPruneUnusedImagesModal: false })}
|
||||||
|
unusedImages={unusedImages}
|
||||||
|
onAddNotification={this.props.onAddNotification} /> }
|
||||||
|
{this.state.imageDownloadInProgress && <CardFooter>
|
||||||
|
<div className='download-in-progress'> {_("Pulling")} {this.state.imageDownloadInProgress}... </div>
|
||||||
|
</CardFooter>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageOverActions = ({ handleDownloadNewImage, handlePruneUsedImages, unusedImages }) => {
|
||||||
|
const actions = [
|
||||||
|
<DropdownItem
|
||||||
|
key="download-new-image"
|
||||||
|
component="button"
|
||||||
|
onClick={() => handleDownloadNewImage()}
|
||||||
|
>
|
||||||
|
{_("Download new image")}
|
||||||
|
</DropdownItem>,
|
||||||
|
<DropdownItem
|
||||||
|
key="prune-unused-images"
|
||||||
|
id="prune-unused-images-button"
|
||||||
|
component="button"
|
||||||
|
className="pf-m-danger btn-delete"
|
||||||
|
onClick={() => handlePruneUsedImages()}
|
||||||
|
isDisabled={unusedImages.length === 0}
|
||||||
|
isAriaDisabled={unusedImages.length === 0}
|
||||||
|
>
|
||||||
|
{_("Prune unused images")}
|
||||||
|
</DropdownItem>
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KebabDropdown
|
||||||
|
toggleButtonId="image-actions-dropdown"
|
||||||
|
position="right"
|
||||||
|
dropdownItems={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageActions = ({ image, onAddNotification }) => {
|
||||||
|
const Dialogs = useDialogs();
|
||||||
|
|
||||||
|
const runImage = () => {
|
||||||
|
Dialogs.show(
|
||||||
|
<utils.DockerInfoContext.Consumer>
|
||||||
|
{(dockerInfo) => (
|
||||||
|
<DialogsContext.Consumer>
|
||||||
|
{(Dialogs) => (
|
||||||
|
<ImageRunModal
|
||||||
|
image={image}
|
||||||
|
onAddNotification={onAddNotification}
|
||||||
|
dockerInfo={dockerInfo}
|
||||||
|
dialogs={Dialogs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogsContext.Consumer>
|
||||||
|
)}
|
||||||
|
</utils.DockerInfoContext.Consumer>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
Dialogs.show(<ImageDeleteModal imageWillDelete={image}
|
||||||
|
onAddNotification={onAddNotification} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runImageAction = (
|
||||||
|
<Button key={image.Id + "create"}
|
||||||
|
className="ct-container-create show-only-when-wide"
|
||||||
|
variant='secondary'
|
||||||
|
onClick={ e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
runImage();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
data-image={image.Id}>
|
||||||
|
{_("Create container")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownActions = [
|
||||||
|
<DropdownItem key={image.Id + "create-menu"}
|
||||||
|
component="button"
|
||||||
|
className="show-only-when-narrow"
|
||||||
|
onClick={runImage}>
|
||||||
|
{_("Create container")}
|
||||||
|
</DropdownItem>,
|
||||||
|
<DropdownItem key={image.Id + "delete"}
|
||||||
|
component="button"
|
||||||
|
className="pf-m-danger btn-delete"
|
||||||
|
onClick={removeImage}>
|
||||||
|
{_("Delete")}
|
||||||
|
</DropdownItem>
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{runImageAction}
|
||||||
|
<KebabDropdown position="right" dropdownItems={dropdownActions} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Images;
|
45
ui/cockpit-docker/src/Notification.jsx
Normal file
45
ui/cockpit-docker/src/Notification.jsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 Red Hat, Inc.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert, AlertActionCloseButton } from "@patternfly/react-core/dist/esm/components/Alert";
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
let last_error = "";
|
||||||
|
|
||||||
|
function log_error_if_changed(error) {
|
||||||
|
// Put the error in the browser log, for easier debugging and
|
||||||
|
// matching of known issues in the integration tests.
|
||||||
|
if (error != last_error) {
|
||||||
|
last_error = error;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorNotification = ({ errorMessage, errorDetail, onDismiss }) => {
|
||||||
|
log_error_if_changed(errorMessage + (errorDetail ? ": " + errorDetail : ""));
|
||||||
|
return (
|
||||||
|
<Alert isInline variant='danger' title={errorMessage}
|
||||||
|
actionClose={onDismiss ? <AlertActionCloseButton onClose={onDismiss} /> : null}>
|
||||||
|
{ errorDetail && <p> {_("Error message")}: <samp>{errorDetail}</samp> </p> }
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
96
ui/cockpit-docker/src/PruneUnusedContainersModal.jsx
Normal file
96
ui/cockpit-docker/src/PruneUnusedContainersModal.jsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import { SortByDirection } from "@patternfly/react-table";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { ListingTable } from 'cockpit-components-table.jsx';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const getContainerRow = (container, selected) => {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: container.name,
|
||||||
|
sortKey: container.name,
|
||||||
|
props: { width: 25, },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: utils.localize_time(Date.parse(container.created) / 1000),
|
||||||
|
props: { width: 20, },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return { columns, selected, props: { key: container.id } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const PruneUnusedContainersModal = ({ close, unusedContainers, onAddNotification }) => {
|
||||||
|
const [isPruning, setPruning] = useState(false);
|
||||||
|
const [selectedContainerIds, setSelectedContainerIds] = React.useState(unusedContainers.map(u => u.id));
|
||||||
|
|
||||||
|
const handlePruneUnusedContainers = () => {
|
||||||
|
setPruning(true);
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
for (const id of selectedContainerIds) {
|
||||||
|
if (id.endsWith("true")) {
|
||||||
|
actions.push(client.delContainer(true, id.replace(/true$/, ""), true));
|
||||||
|
} else {
|
||||||
|
actions.push(client.delContainer(false, id.replace(/false$/, ""), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Promise.all(actions).then(close)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = _("Failed to prune unused containers");
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: _("Name"), sortable: true },
|
||||||
|
{ title: _("Created"), sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectAllContainers = isSelecting => setSelectedContainerIds(isSelecting ? unusedContainers.map(c => c.id) : []);
|
||||||
|
const isContainerSelected = container => selectedContainerIds.includes(container.id);
|
||||||
|
const setContainerSelected = (container, isSelecting) => setSelectedContainerIds(prevSelected => {
|
||||||
|
const otherSelectedContainerName = prevSelected.filter(r => r !== container.id);
|
||||||
|
return isSelecting ? [...otherSelectedContainerName, container.id] : otherSelectedContainerName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSelectContainer = (id, _rowIndex, isSelecting) => {
|
||||||
|
const container = unusedContainers.filter(u => u.id === id)[0];
|
||||||
|
setContainerSelected(container, isSelecting);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
onClose={close}
|
||||||
|
position="top" variant="medium"
|
||||||
|
title={cockpit.format(_("Prune unused containers"))}
|
||||||
|
footer={<>
|
||||||
|
<Button id="btn-img-delete" variant="danger"
|
||||||
|
spinnerAriaValueText={isPruning ? _("Pruning containers") : undefined}
|
||||||
|
isLoading={isPruning}
|
||||||
|
isDisabled={isPruning || selectedContainerIds.length === 0}
|
||||||
|
onClick={handlePruneUnusedContainers}>
|
||||||
|
{isPruning ? _("Pruning containers") : _("Prune")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" onClick={() => close()}>{_("Cancel")}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<p>{_("Removes selected non-running containers")}</p>
|
||||||
|
<ListingTable columns={columns}
|
||||||
|
onSelect={(_event, isSelecting, rowIndex, rowData) => onSelectContainer(rowData.props.id, rowIndex, isSelecting)}
|
||||||
|
onHeaderSelect={(_event, isSelecting) => selectAllContainers(isSelecting)}
|
||||||
|
id="unused-container-list"
|
||||||
|
rows={unusedContainers.map(container => getContainerRow(container, isContainerSelected(container))) }
|
||||||
|
variant="compact" sortBy={{ index: 0, direction: SortByDirection.asc }} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PruneUnusedContainersModal;
|
101
ui/cockpit-docker/src/PruneUnusedImagesModal.jsx
Normal file
101
ui/cockpit-docker/src/PruneUnusedImagesModal.jsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
|
||||||
|
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List";
|
||||||
|
import { Modal } from "@patternfly/react-core/dist/esm/components/Modal";
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as client from './client.js';
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
import "@patternfly/patternfly/utilities/Spacing/spacing.css";
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
function ImageOptions({ images, checked, handleChange, name, showCheckbox }) {
|
||||||
|
const [isExpanded, onToggle] = useState(false);
|
||||||
|
let shownImages = images;
|
||||||
|
if (!isExpanded) {
|
||||||
|
shownImages = shownImages.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shownImages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const listNameId = "list-" + name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex flex={{ default: 'column' }}>
|
||||||
|
{showCheckbox &&
|
||||||
|
<Checkbox
|
||||||
|
label={_("Delete unused images:")}
|
||||||
|
isChecked={checked}
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
onChange={(_, val) => handleChange(val)}
|
||||||
|
aria-owns={listNameId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<List id={listNameId}>
|
||||||
|
{shownImages.map((image, index) =>
|
||||||
|
<ListItem className="pf-v5-u-ml-md" key={index}>
|
||||||
|
{utils.image_name(image)}
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{!isExpanded && images.length > 5 &&
|
||||||
|
<Button onClick={onToggle} variant="link" isInline>
|
||||||
|
{_("Show more")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</List>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PruneUnusedImagesModal = ({ close, unusedImages, onAddNotification }) => {
|
||||||
|
const [isPruning, setPruning] = useState(false);
|
||||||
|
const [deleteImages, setDeleteImages] = React.useState(true);
|
||||||
|
|
||||||
|
const handlePruneUnusedImages = () => {
|
||||||
|
setPruning(true);
|
||||||
|
|
||||||
|
client.pruneUnusedImages().then(close)
|
||||||
|
.catch(ex => {
|
||||||
|
const error = _("Failed to prune unused images");
|
||||||
|
onAddNotification({ type: 'danger', error, errorDetail: ex.message });
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCheckboxes = unusedImages.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen
|
||||||
|
onClose={close}
|
||||||
|
position="top" variant="medium"
|
||||||
|
title={cockpit.format(_("Prune unused images"))}
|
||||||
|
footer={<>
|
||||||
|
<Button id="btn-img-delete" variant="danger"
|
||||||
|
spinnerAriaValueText={isPruning ? _("Pruning images") : undefined}
|
||||||
|
isLoading={isPruning}
|
||||||
|
onClick={handlePruneUnusedImages}>
|
||||||
|
{isPruning ? _("Pruning images") : _("Prune")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" onClick={() => close()}>{_("Cancel")}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<Flex flex={{ default: 'column' }}>
|
||||||
|
<ImageOptions
|
||||||
|
images={unusedImages}
|
||||||
|
name="deleteImages"
|
||||||
|
checked={deleteImages}
|
||||||
|
handleChange={setDeleteImages}
|
||||||
|
showCheckbox={showCheckboxes}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PruneUnusedImagesModal;
|
142
ui/cockpit-docker/src/PublishPort.jsx
Normal file
142
ui/cockpit-docker/src/PublishPort.jsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
|
||||||
|
import { Grid } from "@patternfly/react-core/dist/esm/layouts/Grid";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
|
||||||
|
import { OutlinedQuestionCircleIcon, TrashIcon } from '@patternfly/react-icons';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import ipaddr from "ipaddr.js";
|
||||||
|
|
||||||
|
import { FormHelper } from "cockpit-components-form-helper.jsx";
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
const MAX_PORT = 65535;
|
||||||
|
|
||||||
|
export function validatePublishPort(value, key) {
|
||||||
|
switch (key) {
|
||||||
|
case "IP":
|
||||||
|
if (value && !ipaddr.isValid(value))
|
||||||
|
return _("Must be a valid IP address");
|
||||||
|
break;
|
||||||
|
case "hostPort": {
|
||||||
|
if (value) {
|
||||||
|
const hostPort = parseInt(value);
|
||||||
|
if (hostPort < 1 || hostPort > MAX_PORT)
|
||||||
|
return _("1 to 65535");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "containerPort": {
|
||||||
|
if (!value)
|
||||||
|
return _("Container port must not be empty");
|
||||||
|
|
||||||
|
const containerPort = parseInt(value);
|
||||||
|
if (containerPort < 1 || containerPort > MAX_PORT)
|
||||||
|
return _("1 to 65535");
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown key "${key}"`); // not-covered: unreachable assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublishPort = ({ id, item, onChange, idx, removeitem, itemCount, validationFailed, onValidationChange }) =>
|
||||||
|
(
|
||||||
|
<Grid hasGutter id={id}>
|
||||||
|
<FormGroup className="pf-m-6-col-on-md"
|
||||||
|
id={id + "-ip-address-group"}
|
||||||
|
label={_("IP address")}
|
||||||
|
fieldId={id + "-ip-address"}
|
||||||
|
labelIcon={
|
||||||
|
<Popover aria-label={_("IP address help")}
|
||||||
|
enableFlip
|
||||||
|
bodyContent={_("If host IP is set to 0.0.0.0 or not set at all, the port will be bound on all IPs on the host.")}>
|
||||||
|
<button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help">
|
||||||
|
<OutlinedQuestionCircleIcon />
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
}>
|
||||||
|
<TextInput id={id + "-ip-address"}
|
||||||
|
value={item.IP || ''}
|
||||||
|
validated={validationFailed?.IP ? "error" : "default"}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "IP", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, IP: validatePublishPort(value, "IP") }));
|
||||||
|
onChange(idx, 'IP', value);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.IP} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-2-col-on-md"
|
||||||
|
id={id + "-host-port-group"}
|
||||||
|
label={_("Host port")}
|
||||||
|
fieldId={id + "-host-port"}
|
||||||
|
labelIcon={
|
||||||
|
<Popover aria-label={_("Host port help")}
|
||||||
|
enableFlip
|
||||||
|
bodyContent={_("If the host port is not set the container port will be randomly assigned a port on the host.")}>
|
||||||
|
<button onClick={e => e.preventDefault()} className="pf-v5-c-form__group-label-help">
|
||||||
|
<OutlinedQuestionCircleIcon />
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
}>
|
||||||
|
<TextInput id={id + "-host-port"}
|
||||||
|
type='number'
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={MAX_PORT}
|
||||||
|
value={item.hostPort || ''}
|
||||||
|
validated={validationFailed?.hostPort ? "error" : "default"}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "hostPort", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, hostPort: validatePublishPort(value, "hostPort") }));
|
||||||
|
onChange(idx, 'hostPort', value);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.hostPort} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-2-col-on-md"
|
||||||
|
id={id + "-container-port-group"}
|
||||||
|
label={_("Container port")}
|
||||||
|
fieldId={id + "-container-port"} isRequired>
|
||||||
|
<TextInput id={id + "-container-port"}
|
||||||
|
type='number'
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={MAX_PORT}
|
||||||
|
validated={validationFailed?.containerPort ? "error" : "default"}
|
||||||
|
value={item.containerPort || ''}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "containerPort", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, containerPort: validatePublishPort(value, "containerPort") }));
|
||||||
|
onChange(idx, 'containerPort', value);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.containerPort} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-2-col-on-md"
|
||||||
|
label={_("Protocol")}
|
||||||
|
fieldId={id + "-protocol"}>
|
||||||
|
<FormSelect className='pf-v5-c-form-control container-port-protocol'
|
||||||
|
id={id + "-protocol"}
|
||||||
|
value={item.protocol}
|
||||||
|
onChange={(_event, value) => onChange(idx, 'protocol', value)}>
|
||||||
|
<FormSelectOption value='tcp' key='tcp' label={_("TCP")} />
|
||||||
|
<FormSelectOption value='udp' key='udp' label={_("UDP")} />
|
||||||
|
</FormSelect>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-1-col-on-md remove-button-group">
|
||||||
|
<Button variant='plain'
|
||||||
|
className="btn-close"
|
||||||
|
id={id + "-btn-close"}
|
||||||
|
size="sm"
|
||||||
|
aria-label={_("Remove item")}
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
onClick={() => removeitem(idx)} />
|
||||||
|
</FormGroup>
|
||||||
|
</Grid>
|
||||||
|
);
|
90
ui/cockpit-docker/src/Volume.jsx
Normal file
90
ui/cockpit-docker/src/Volume.jsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
|
||||||
|
import { FormHelper } from "cockpit-components-form-helper.jsx";
|
||||||
|
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
|
||||||
|
import { Grid } from "@patternfly/react-core/dist/esm/layouts/Grid";
|
||||||
|
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
|
||||||
|
import { TrashIcon } from '@patternfly/react-icons';
|
||||||
|
import { FileAutoComplete } from 'cockpit-components-file-autocomplete.jsx';
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import * as utils from './util.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
export function validateVolume(value, key) {
|
||||||
|
switch (key) {
|
||||||
|
case "hostPath":
|
||||||
|
break;
|
||||||
|
case "containerPath":
|
||||||
|
if (!value)
|
||||||
|
return _("Container path must not be empty");
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`Unknown key "${key}"`); // not-covered: unreachable assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Volume = ({ id, item, onChange, idx, removeitem, additem, options, itemCount, validationFailed, onValidationChange }) =>
|
||||||
|
(
|
||||||
|
<Grid hasGutter id={id}>
|
||||||
|
<FormGroup className="pf-m-4-col-on-md"
|
||||||
|
id={id + "-host-path-group"}
|
||||||
|
label={_("Host path")}
|
||||||
|
fieldId={id + "-host-path"}
|
||||||
|
>
|
||||||
|
<FileAutoComplete id={id + "-host-path"}
|
||||||
|
value={item.hostPath || ''}
|
||||||
|
onChange={value => {
|
||||||
|
utils.validationClear(validationFailed, "hostPath", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, hostPath: validateVolume(value, "hostPath") }));
|
||||||
|
onChange(idx, 'hostPath', value);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.hostPath} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-3-col-on-md"
|
||||||
|
id={id + "-container-path-group"}
|
||||||
|
label={_("Container path")}
|
||||||
|
fieldId={id + "-container-path"}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
<TextInput id={id + "-container-path"}
|
||||||
|
value={item.containerPath || ''}
|
||||||
|
validated={validationFailed?.containerPath ? "error" : "default"}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
utils.validationClear(validationFailed, "containerPath", onValidationChange);
|
||||||
|
utils.validationDebounce(() => onValidationChange({ ...validationFailed, containerPath: validateVolume(value, "containerPath") }));
|
||||||
|
onChange(idx, 'containerPath', value);
|
||||||
|
}} />
|
||||||
|
<FormHelper helperTextInvalid={validationFailed?.containerPath} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="pf-m-2-col-on-md" label={_("Mode")} fieldId={id + "-mode"}>
|
||||||
|
<Checkbox id={id + "-rw"}
|
||||||
|
label={_("Writable")}
|
||||||
|
isChecked={!item.readOnly}
|
||||||
|
onChange={(_event, value) => onChange(idx, 'readOnly', !value)} />
|
||||||
|
</FormGroup>
|
||||||
|
{ options && options.selinuxAvailable &&
|
||||||
|
<FormGroup className="pf-m-3-col-on-md" label={_("SELinux")} fieldId={id + "-selinux"}>
|
||||||
|
<FormSelect id={id + "-selinux"} className='pf-v5-c-form-control'
|
||||||
|
value={item.selinux}
|
||||||
|
onChange={(_event, value) => onChange(idx, 'selinux', value)}>
|
||||||
|
<FormSelectOption value='' key='' label={_("No label")} />
|
||||||
|
<FormSelectOption value='z' key='z' label={_("Shared")} />
|
||||||
|
<FormSelectOption value='Z' key='Z' label={_("Private")} />
|
||||||
|
</FormSelect>
|
||||||
|
</FormGroup> }
|
||||||
|
<FormGroup className="pf-m-1-col-on-md remove-button-group">
|
||||||
|
<Button variant='plain'
|
||||||
|
className="btn-close"
|
||||||
|
id={id + "-btn-close"}
|
||||||
|
aria-label={_("Remove item")}
|
||||||
|
size="sm"
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
onClick={() => removeitem(idx)} />
|
||||||
|
</FormGroup>
|
||||||
|
</Grid>
|
||||||
|
);
|
617
ui/cockpit-docker/src/app.jsx
Normal file
617
ui/cockpit-docker/src/app.jsx
Normal file
@ -0,0 +1,617 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2017 Red Hat, Inc.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Page, PageSection, PageSectionVariants } from "@patternfly/react-core/dist/esm/components/Page";
|
||||||
|
import { Alert, AlertActionCloseButton, AlertActionLink, AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert";
|
||||||
|
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
|
||||||
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
||||||
|
import { EmptyState, EmptyStateHeader, EmptyStateFooter, EmptyStateIcon, EmptyStateActions, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState";
|
||||||
|
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
|
||||||
|
import { ExclamationCircleIcon } from '@patternfly/react-icons';
|
||||||
|
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
|
||||||
|
import { WithDialogs } from "dialogs.jsx";
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
import { superuser } from "superuser";
|
||||||
|
import ContainerHeader from './ContainerHeader.jsx';
|
||||||
|
import Containers from './Containers.jsx';
|
||||||
|
import Images from './Images.jsx';
|
||||||
|
import * as client from './client.js';
|
||||||
|
import { WithDockerInfo } from './util.js';
|
||||||
|
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
class Application extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
serviceAvailable: null,
|
||||||
|
enableService: true,
|
||||||
|
images: null,
|
||||||
|
imagesLoaded: false,
|
||||||
|
containers: null,
|
||||||
|
containersFilter: "all",
|
||||||
|
containersStats: {},
|
||||||
|
containersLoaded: null,
|
||||||
|
textFilter: "",
|
||||||
|
ownerFilter: "all",
|
||||||
|
dropDownValue: 'Everything',
|
||||||
|
notifications: [],
|
||||||
|
showStartService: true,
|
||||||
|
version: '1.3.0',
|
||||||
|
selinuxAvailable: false,
|
||||||
|
dockerRestartAvailable: false,
|
||||||
|
currentUser: _("User"),
|
||||||
|
privileged: false,
|
||||||
|
hasDockerGroup: false,
|
||||||
|
location: {},
|
||||||
|
};
|
||||||
|
this.onAddNotification = this.onAddNotification.bind(this);
|
||||||
|
this.onDismissNotification = this.onDismissNotification.bind(this);
|
||||||
|
this.onFilterChanged = this.onFilterChanged.bind(this);
|
||||||
|
this.onContainerFilterChanged = this.onContainerFilterChanged.bind(this);
|
||||||
|
this.updateContainer = this.updateContainer.bind(this);
|
||||||
|
this.startService = this.startService.bind(this);
|
||||||
|
this.goToServicePage = this.goToServicePage.bind(this);
|
||||||
|
this.onNavigate = this.onNavigate.bind(this);
|
||||||
|
|
||||||
|
this.pendingUpdateContainer = {}; // id → promise
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddNotification(notification) {
|
||||||
|
notification.index = this.state.notifications.length;
|
||||||
|
|
||||||
|
this.setState(prevState => ({
|
||||||
|
notifications: [
|
||||||
|
...prevState.notifications,
|
||||||
|
notification
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onDismissNotification(notificationIndex) {
|
||||||
|
const notificationsArray = this.state.notifications.concat();
|
||||||
|
const index = notificationsArray.findIndex(current => current.index == notificationIndex);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
notificationsArray.splice(index, 1);
|
||||||
|
this.setState({ notifications: notificationsArray });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUrl(options) {
|
||||||
|
cockpit.location.go([], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChanged(value) {
|
||||||
|
this.setState({
|
||||||
|
textFilter: value
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = this.state.location;
|
||||||
|
if (value === "") {
|
||||||
|
delete options.name;
|
||||||
|
this.updateUrl(Object.assign(options));
|
||||||
|
} else {
|
||||||
|
this.updateUrl(Object.assign(this.state.location, { name: value }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onContainerFilterChanged(value) {
|
||||||
|
this.setState({
|
||||||
|
containersFilter: value
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = this.state.location;
|
||||||
|
if (value == "running") {
|
||||||
|
delete options.container;
|
||||||
|
this.updateUrl(Object.assign(options));
|
||||||
|
} else {
|
||||||
|
this.updateUrl(Object.assign(options, { container: value }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(state, id, newValue) {
|
||||||
|
this.setState(prevState => {
|
||||||
|
return {
|
||||||
|
[state]: { ...prevState[state], [id]: newValue }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContainerStats(id) {
|
||||||
|
client.streamContainerStats(id, reply => {
|
||||||
|
if (reply.Error != null) // executed when container stop
|
||||||
|
console.warn("Failed to update container stats:", JSON.stringify(reply.message));
|
||||||
|
else {
|
||||||
|
this.updateState("containersStats", id, reply);
|
||||||
|
}
|
||||||
|
}).catch(ex => {
|
||||||
|
if (ex.cause == "no support for CGroups V1 in rootless environments" || ex.cause == "Container stats resource only available for cgroup v2") {
|
||||||
|
console.log("This OS does not support CgroupsV2. Some information may be missing.");
|
||||||
|
} else
|
||||||
|
console.warn("Failed to update container stats:", JSON.stringify(ex.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initContainers() {
|
||||||
|
return client.getContainers()
|
||||||
|
.then(containerList => Promise.all(
|
||||||
|
containerList.map(container => client.inspectContainer(container.Id))
|
||||||
|
))
|
||||||
|
.then(containerDetails => {
|
||||||
|
this.setState(prevState => {
|
||||||
|
const copyContainers = prevState.containers || {};
|
||||||
|
for (const detail of containerDetails) {
|
||||||
|
copyContainers[detail.Id] = detail;
|
||||||
|
this.updateContainerStats(detail.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
containers: copyContainers,
|
||||||
|
containersLoaded: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.log);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImages() {
|
||||||
|
client.getImages()
|
||||||
|
.then(reply => {
|
||||||
|
this.setState(prevState => {
|
||||||
|
return {
|
||||||
|
images: reply,
|
||||||
|
imagesLoaded: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
console.warn("Failed to do Update Images:", JSON.stringify(ex));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContainer(id, event) {
|
||||||
|
/* when firing off multiple calls in parallel, docker can return them in a random order.
|
||||||
|
* This messes up the state. So we need to serialize them for a particular container. */
|
||||||
|
const idx = id;
|
||||||
|
const wait = this.pendingUpdateContainer[idx] ?? Promise.resolve();
|
||||||
|
|
||||||
|
const new_wait = wait.then(() => client.inspectContainer(id))
|
||||||
|
.then(details => {
|
||||||
|
// HACK: during restart State never changes from "running"
|
||||||
|
// override it to reconnect console after restart
|
||||||
|
if (event?.Action === "restart")
|
||||||
|
details.State.Status = "restarting";
|
||||||
|
this.updateState("containers", idx, details);
|
||||||
|
})
|
||||||
|
.catch(console.log);
|
||||||
|
this.pendingUpdateContainer[idx] = new_wait;
|
||||||
|
new_wait.finally(() => { delete this.pendingUpdateContainer[idx] });
|
||||||
|
|
||||||
|
return new_wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImage(id) {
|
||||||
|
client.getImages(id)
|
||||||
|
.then(reply => {
|
||||||
|
const image = reply[id];
|
||||||
|
this.updateState("images", id, image);
|
||||||
|
})
|
||||||
|
.catch(ex => {
|
||||||
|
console.warn("Failed to do Update Image:", JSON.stringify(ex));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// see https://docs.podman.io/en/latest/markdown/podman-events.1.html
|
||||||
|
|
||||||
|
handleImageEvent(event) {
|
||||||
|
switch (event.Action) {
|
||||||
|
case 'push':
|
||||||
|
case 'save':
|
||||||
|
case 'tag':
|
||||||
|
this.updateImage(event.Actor.ID);
|
||||||
|
break;
|
||||||
|
case 'pull': // Pull event has not event.id
|
||||||
|
case 'untag':
|
||||||
|
case 'import':
|
||||||
|
case 'prune':
|
||||||
|
case 'load':
|
||||||
|
this.updateImages();
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
this.setState(prevState => {
|
||||||
|
const images = { ...prevState.images };
|
||||||
|
delete images[event.Actor.ID];
|
||||||
|
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unhandled event type ', event.Type, event.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContainerEvent(event) {
|
||||||
|
if (event.Action.includes(':'))
|
||||||
|
event.Action = event.Action.split(':')[0];
|
||||||
|
const id = event.Actor.ID;
|
||||||
|
|
||||||
|
switch (event.Action) {
|
||||||
|
/* The following events do not need to trigger any state updates */
|
||||||
|
case 'attach':
|
||||||
|
case 'resize':
|
||||||
|
case 'kill':
|
||||||
|
case 'prune':
|
||||||
|
case 'restart':
|
||||||
|
break;
|
||||||
|
/* The following events need only to update the Container list
|
||||||
|
* We do get the container affected in the event object, but for
|
||||||
|
* now we'll do a batch update
|
||||||
|
*/
|
||||||
|
case 'exec_start':
|
||||||
|
case 'start':
|
||||||
|
this.updateContainer(id, event);
|
||||||
|
break;
|
||||||
|
case 'exec_create':
|
||||||
|
case 'create':
|
||||||
|
case 'die':
|
||||||
|
case 'exec_die':
|
||||||
|
case 'health_status':
|
||||||
|
case 'pause':
|
||||||
|
case 'stop':
|
||||||
|
case 'unpause':
|
||||||
|
case 'rename': // rename event is available starting podman v4.1; until then the container does not get refreshed after renaming
|
||||||
|
this.updateContainer(id, event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'destroy':
|
||||||
|
this.setState(prevState => {
|
||||||
|
const containers = { ...prevState.containers };
|
||||||
|
delete containers[id];
|
||||||
|
|
||||||
|
return { containers };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
// only needs to update the Image list, this ought to be an image event
|
||||||
|
case 'commit':
|
||||||
|
this.updateImages();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unhandled event type ', event.Type, event.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event) {
|
||||||
|
switch (event.Type) {
|
||||||
|
case 'container':
|
||||||
|
this.handleContainerEvent(event);
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
this.handleImageEvent(event);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unhandled event type ', event.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAfterService(key) {
|
||||||
|
["images", "containers"].forEach(t => {
|
||||||
|
if (this.state[t])
|
||||||
|
this.setState(prevState => {
|
||||||
|
const copy = {};
|
||||||
|
Object.entries(prevState[t] || {}).forEach(([id, v]) => {
|
||||||
|
copy[id] = v;
|
||||||
|
});
|
||||||
|
return { [t]: copy };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
client.getInfo()
|
||||||
|
.then(reply => {
|
||||||
|
this.setState({
|
||||||
|
serviceAvailable: true,
|
||||||
|
version: reply.ServerVersion,
|
||||||
|
registries: reply.RegistryConfig.IndexConfigs,
|
||||||
|
cgroupVersion: reply.CgroupVersion,
|
||||||
|
});
|
||||||
|
this.updateImages();
|
||||||
|
this.initContainers();
|
||||||
|
client.streamEvents(message => this.handleEvent(message))
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ serviceAvailable: false });
|
||||||
|
this.cleanupAfterService();
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.log(e);
|
||||||
|
this.setState({ serviceAvailable: false });
|
||||||
|
this.cleanupAfterService();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen if docker is still running
|
||||||
|
const ch = cockpit.channel({ payload: "stream", unix: client.getAddress() });
|
||||||
|
ch.addEventListener("close", () => {
|
||||||
|
this.setState({ serviceAvailable: false });
|
||||||
|
this.cleanupAfterService();
|
||||||
|
});
|
||||||
|
|
||||||
|
ch.send("GET " + client.VERSION + "/events HTTP/1.0\r\nContent-Length: 0\r\n\r\n");
|
||||||
|
})
|
||||||
|
.catch((r) => {
|
||||||
|
console.log("Failed to get info from docker", r);
|
||||||
|
this.setState({
|
||||||
|
serviceAvailable: false,
|
||||||
|
containersLoaded: true,
|
||||||
|
imagesLoaded: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
cockpit.script("[ `id -u` -eq 0 ] || [ `id -nG | grep -qw docker; echo $?` -eq 0 ]; echo $?")
|
||||||
|
.done(result => {
|
||||||
|
const hasDockerGroup = result.trim() === "0";
|
||||||
|
this.setState({ hasDockerGroup });
|
||||||
|
if (hasDockerGroup) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => console.log("Could not determine if user has docker group: ", e.message));
|
||||||
|
cockpit.spawn("selinuxenabled", { error: "ignore" })
|
||||||
|
.then(() => this.setState({ selinuxAvailable: true }))
|
||||||
|
.catch(() => this.setState({ selinuxAvailable: false }));
|
||||||
|
|
||||||
|
cockpit.spawn(["systemctl", "show", "--value", "-p", "LoadState", "docker"], { environ: ["LC_ALL=C"], error: "ignore" })
|
||||||
|
.then(out => this.setState({ dockerRestartAvailable: out.trim() === "loaded" }));
|
||||||
|
|
||||||
|
superuser.addEventListener("changed", () => this.setState({ privileged: !!superuser.allowed }));
|
||||||
|
this.setState({ privileged: superuser.allowed });
|
||||||
|
|
||||||
|
cockpit.addEventListener("locationchanged", this.onNavigate);
|
||||||
|
this.onNavigate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
cockpit.removeEventListener("locationchanged", this.onNavigate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigate() {
|
||||||
|
// HACK: Use usePageLocation when this is rewritten into a functional component
|
||||||
|
const { options, path } = cockpit.location;
|
||||||
|
this.setState({ location: options }, () => {
|
||||||
|
// only use the root path
|
||||||
|
if (path.length === 0) {
|
||||||
|
if (options.name) {
|
||||||
|
this.onFilterChanged(options.name);
|
||||||
|
}
|
||||||
|
if (options.container) {
|
||||||
|
this.onContainerFilterChanged(options.container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startService(e) {
|
||||||
|
if (!e || e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let argv;
|
||||||
|
if (this.state.enableService)
|
||||||
|
argv = ["systemctl", "enable", "--now", "docker.socket"];
|
||||||
|
else
|
||||||
|
argv = ["systemctl", "start", "docker.socket"];
|
||||||
|
|
||||||
|
cockpit.spawn(argv, { superuser: "require", err: "message" })
|
||||||
|
.then(() => this.init())
|
||||||
|
.catch(err => {
|
||||||
|
this.setState({
|
||||||
|
serviceAvailable: false,
|
||||||
|
containersLoaded: true,
|
||||||
|
imagesLoaded: true
|
||||||
|
});
|
||||||
|
console.warn("Failed to start docker.socket:", JSON.stringify(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToServicePage(e) {
|
||||||
|
if (!e || e.button !== 0)
|
||||||
|
return;
|
||||||
|
cockpit.jump("/system/services#/docker.socket");
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.hasDockerGroup) {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
|
<EmptyState variant={EmptyStateVariant.full}>
|
||||||
|
<EmptyStateHeader titleText={_("You are not a member of the docker group")} icon={<EmptyStateIcon icon={ExclamationCircleIcon} />} headingLevel="h2" />
|
||||||
|
<EmptyStateFooter>
|
||||||
|
<Button onClick={() => cockpit.jump("/users")}>
|
||||||
|
{_("Manage users")}
|
||||||
|
</Button>
|
||||||
|
</EmptyStateFooter>
|
||||||
|
</EmptyState>
|
||||||
|
</PageSection>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.serviceAvailable === null) // not detected yet
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
|
<EmptyState variant={EmptyStateVariant.full}>
|
||||||
|
{/* loading spinner */}
|
||||||
|
<Spinner size="xl" />
|
||||||
|
<EmptyStateHeader titleText={_("Loading...")} />
|
||||||
|
</EmptyState>
|
||||||
|
</PageSection>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.state.serviceAvailable) {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
|
<EmptyState variant={EmptyStateVariant.full}>
|
||||||
|
<EmptyStateHeader titleText={_("Docker service is not active")} icon={<EmptyStateIcon icon={ExclamationCircleIcon} />} headingLevel="h2" />
|
||||||
|
<EmptyStateFooter>
|
||||||
|
<Checkbox isChecked={this.state.enableService}
|
||||||
|
id="enable"
|
||||||
|
label={_("Automatically start docker on boot")}
|
||||||
|
onChange={ (_event, checked) => this.setState({ enableService: checked }) } />
|
||||||
|
<Button onClick={this.startService}>
|
||||||
|
{_("Start docker")}
|
||||||
|
</Button>
|
||||||
|
{ cockpit.manifests.system &&
|
||||||
|
<EmptyStateActions>
|
||||||
|
<Button variant="link" onClick={this.goToServicePage}>
|
||||||
|
{_("Troubleshoot")}
|
||||||
|
</Button>
|
||||||
|
</EmptyStateActions>
|
||||||
|
}
|
||||||
|
</EmptyStateFooter>
|
||||||
|
</EmptyState>
|
||||||
|
</PageSection>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageContainerList = {};
|
||||||
|
if (this.state.containers !== null) {
|
||||||
|
Object.keys(this.state.containers).forEach(c => {
|
||||||
|
const container = this.state.containers[c];
|
||||||
|
const image = container.Image;
|
||||||
|
if (imageContainerList[image]) {
|
||||||
|
imageContainerList[image].push({
|
||||||
|
container,
|
||||||
|
stats: this.state.containersStats[container.Id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
imageContainerList[image] = [{
|
||||||
|
container,
|
||||||
|
stats: this.state.containersStats[container.Id]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
imageContainerList = null;
|
||||||
|
|
||||||
|
let startService = "";
|
||||||
|
const action = (
|
||||||
|
<>
|
||||||
|
<AlertActionLink variant='secondary' onClick={this.startService}>{_("Start")}</AlertActionLink>
|
||||||
|
<AlertActionCloseButton onClose={() => this.setState({ showStartService: false })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (!this.state.serviceAvailable && this.state.privileged) {
|
||||||
|
startService = (
|
||||||
|
<Alert
|
||||||
|
title={_("Docker service is available")}
|
||||||
|
actionClose={action} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageList = (
|
||||||
|
<Images
|
||||||
|
key="imageList"
|
||||||
|
images={this.state.imagesLoaded ? this.state.images : null}
|
||||||
|
imageContainerList={imageContainerList}
|
||||||
|
onAddNotification={this.onAddNotification}
|
||||||
|
textFilter={this.state.textFilter}
|
||||||
|
ownerFilter={this.state.ownerFilter}
|
||||||
|
showAll={ () => this.setState({ containersFilter: "all" }) }
|
||||||
|
user={this.state.currentUser}
|
||||||
|
serviceAvailable={this.state.serviceAvailable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const containerList = (
|
||||||
|
<Containers
|
||||||
|
key="containerList"
|
||||||
|
version={this.state.version}
|
||||||
|
images={this.state.imagesLoaded ? this.state.images : null}
|
||||||
|
containers={this.state.containersLoaded ? this.state.containers : null}
|
||||||
|
containersStats={this.state.containersStats}
|
||||||
|
filter={this.state.containersFilter}
|
||||||
|
handleFilterChange={this.onContainerFilterChanged}
|
||||||
|
textFilter={this.state.textFilter}
|
||||||
|
ownerFilter={this.state.ownerFilter}
|
||||||
|
user={this.state.currentUser}
|
||||||
|
onAddNotification={this.onAddNotification}
|
||||||
|
serviceAvailable={this.state.serviceAvailable}
|
||||||
|
cgroupVersion={this.state.cgroupVersion}
|
||||||
|
updateContainer={this.updateContainer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationList = (
|
||||||
|
<AlertGroup isToast>
|
||||||
|
{this.state.notifications.map((notification, index) => {
|
||||||
|
return (
|
||||||
|
<Alert key={index} title={notification.error} variant={notification.type}
|
||||||
|
isLiveRegion
|
||||||
|
actionClose={<AlertActionCloseButton onClose={() => this.onDismissNotification(notification.index)} />}>
|
||||||
|
{notification.errorDetail}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AlertGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextInfo = {
|
||||||
|
cgroupVersion: this.state.cgroupVersion,
|
||||||
|
registries: this.state.registries,
|
||||||
|
selinuxAvailable: this.state.selinuxAvailable,
|
||||||
|
dockerRestartAvailable: this.state.dockerRestartAvailable,
|
||||||
|
version: this.state.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WithDockerInfo value={contextInfo}>
|
||||||
|
<WithDialogs>
|
||||||
|
<Page id="overview" key="overview">
|
||||||
|
{notificationList}
|
||||||
|
<PageSection className="content-filter" padding={{ default: 'noPadding' }}
|
||||||
|
variant={PageSectionVariants.light}>
|
||||||
|
<ContainerHeader
|
||||||
|
handleFilterChanged={this.onFilterChanged}
|
||||||
|
ownerFilter={this.state.ownerFilter}
|
||||||
|
textFilter={this.state.textFilter}
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
<PageSection className='ct-pagesection-mobile'>
|
||||||
|
<Stack hasGutter>
|
||||||
|
{ this.state.showStartService ? startService : null }
|
||||||
|
{imageList}
|
||||||
|
{containerList}
|
||||||
|
</Stack>
|
||||||
|
</PageSection>
|
||||||
|
</Page>
|
||||||
|
</WithDialogs>
|
||||||
|
</WithDockerInfo>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Application;
|
172
ui/cockpit-docker/src/client.js
Normal file
172
ui/cockpit-docker/src/client.js
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import rest from './rest.js';
|
||||||
|
|
||||||
|
const DOCKER_ADDRESS = "/var/run/docker.sock";
|
||||||
|
export const VERSION = "/v1.43";
|
||||||
|
|
||||||
|
export function getAddress() {
|
||||||
|
return DOCKER_ADDRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerCall(name, method, args, body) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
path: VERSION + name,
|
||||||
|
body: body || "",
|
||||||
|
params: args,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (method === "POST" && body)
|
||||||
|
options.headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
// console.log("dockerCall", options);
|
||||||
|
|
||||||
|
return rest.call(getAddress(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockerJson = (name, method, args, body) => dockerCall(name, method, args, body)
|
||||||
|
.then(reply => JSON.parse(reply));
|
||||||
|
|
||||||
|
function dockerMonitor(name, method, args, callback) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
path: VERSION + name,
|
||||||
|
body: "",
|
||||||
|
params: args,
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log("dockerMonitor", options);
|
||||||
|
|
||||||
|
const connection = rest.connect(getAddress());
|
||||||
|
return connection.monitor(options, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streamEvents = (callback) => dockerMonitor("/events", "GET", {}, callback);
|
||||||
|
|
||||||
|
export function getInfo() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error("timeout")), 15000);
|
||||||
|
dockerJson("/info", "GET", {})
|
||||||
|
.then(reply => resolve(reply))
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => clearTimeout(timeout));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getContainers = () => dockerJson("/containers/json", "GET", { all: true });
|
||||||
|
|
||||||
|
export const streamContainerStats = (id, callback) => dockerMonitor("/containers/" + id + "/stats", "GET", { stream: true }, callback);
|
||||||
|
|
||||||
|
export function inspectContainer(id) {
|
||||||
|
const options = {
|
||||||
|
size: false // set true to display filesystem usage
|
||||||
|
};
|
||||||
|
return dockerJson("/containers/" + id + "/json", "GET", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delContainer = (id, force) => dockerCall("/containers/" + id, "DELETE", { force });
|
||||||
|
|
||||||
|
export const renameContainer = (id, config) => dockerCall("/containers/" + id + "/rename", "POST", config);
|
||||||
|
|
||||||
|
export const createContainer = (config) => dockerJson("/containers/create", "POST", {}, JSON.stringify(config));
|
||||||
|
|
||||||
|
export const commitContainer = (commitData) => dockerCall("/commit", "POST", commitData);
|
||||||
|
|
||||||
|
export const postContainer = (action, id, args) => dockerCall("/containers/" + id + "/" + action, "POST", args);
|
||||||
|
|
||||||
|
export function execContainer(id) {
|
||||||
|
const args = {
|
||||||
|
AttachStderr: true,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStdin: true,
|
||||||
|
Tty: true,
|
||||||
|
Cmd: ["/bin/sh"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return dockerJson("/containers/" + id + "/exec", "POST", {}, JSON.stringify(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeContainersTTY(id, exec, width, height) {
|
||||||
|
const args = {
|
||||||
|
h: height,
|
||||||
|
w: width,
|
||||||
|
};
|
||||||
|
|
||||||
|
let point = "containers/";
|
||||||
|
if (!exec)
|
||||||
|
point = "exec/";
|
||||||
|
|
||||||
|
console.log("resizeContainersTTY", point + id + "/resize", args);
|
||||||
|
return dockerCall("/" + point + id + "/resize", "POST", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImageInfo(info) {
|
||||||
|
const image = {};
|
||||||
|
|
||||||
|
if (info.Config) {
|
||||||
|
image.Entrypoint = info.Config.Entrypoint;
|
||||||
|
image.Command = info.Config.Cmd;
|
||||||
|
image.Ports = Object.keys(info.Config.ExposedPorts || {});
|
||||||
|
image.Env = info.Config.Env;
|
||||||
|
}
|
||||||
|
image.Author = info.Author;
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImages(id) {
|
||||||
|
const options = {};
|
||||||
|
if (id)
|
||||||
|
options.filters = JSON.stringify({ id: [id] });
|
||||||
|
return dockerJson("/images/json", "GET", options)
|
||||||
|
.then(reply => {
|
||||||
|
const images = {};
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const image of reply) {
|
||||||
|
images[image.Id] = image;
|
||||||
|
promises.push(dockerJson("/images/" + image.Id + "/json", "GET", {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises)
|
||||||
|
.then(replies => {
|
||||||
|
for (const info of replies) {
|
||||||
|
images[info.Id] = Object.assign(images[info.Id], parseImageInfo(info));
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delImage = (id, force) => dockerJson("/images/" + id, "DELETE", { force });
|
||||||
|
|
||||||
|
export const untagImage = (id, repo, tag) => dockerCall("/images/" + id + "/untag", "POST", { repo, tag });
|
||||||
|
|
||||||
|
export function pullImage(reference) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
fromImage: reference,
|
||||||
|
};
|
||||||
|
dockerCall("/images/create", "POST", options)
|
||||||
|
.then(r => {
|
||||||
|
// Need to check the last response if it contains error
|
||||||
|
const responses = r.trim().split("\n");
|
||||||
|
const response = JSON.parse(responses[responses.length - 1]);
|
||||||
|
if (response.error) {
|
||||||
|
response.message = response.error;
|
||||||
|
reject(response);
|
||||||
|
} else if (response.cause) // present for 400 and 500 errors
|
||||||
|
reject(response);
|
||||||
|
else
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pruneUnusedImages = () => dockerJson("/images/prune", "POST", {});
|
||||||
|
|
||||||
|
export const imageHistory = (id) => dockerJson(`/images/${id}/history`, "GET", {});
|
||||||
|
|
||||||
|
export const imageExists = (id) => dockerCall("/images/" + id + "/json", "GET", {});
|
||||||
|
|
||||||
|
export const containerExists = (id) => dockerCall("/containers/" + id + "/json", "GET", {});
|
149
ui/cockpit-docker/src/docker.scss
Normal file
149
ui/cockpit-docker/src/docker.scss
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
@use "ct-card.scss";
|
||||||
|
@use "page.scss";
|
||||||
|
@import "global-variables";
|
||||||
|
// For pf-v5-line-clamp
|
||||||
|
@import "@patternfly/patternfly/sass-utilities/mixins.scss";
|
||||||
|
// For pf-u-disabled-color-100
|
||||||
|
@import "@patternfly/patternfly/utilities/Text/text.css";
|
||||||
|
|
||||||
|
#app .pf-v5-c-card.containers-containers, #app .pf-v5-c-card.containers-images {
|
||||||
|
@extend .ct-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-modal-box__title-text {
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
#containers-images, #containers-containers {
|
||||||
|
// Decrease padding for the image/container toggle button list
|
||||||
|
.pf-v5-c-table.pf-m-compact .pf-v5-c-table__toggle {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
// Badges should not stretch in mobile mode
|
||||||
|
.pf-v5-c-table [data-label] > .pf-v5-c-badge {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-block small {
|
||||||
|
@include pf-v5-line-clamp("1");
|
||||||
|
color: var(--pf-v5-global--Color--200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-name {
|
||||||
|
font-size: var(--pf-v5-global--FontSize--lg);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containers-run-onbuildvarclaim input {
|
||||||
|
max-inline-size: 15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-alert__description {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-action {
|
||||||
|
inline-size: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge-container-running, .ct-badge-pod-running {
|
||||||
|
background-color: var(--pf-v5-global--info-color--100);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge-container-healthy {
|
||||||
|
background-color: var(--pf-v5-global--success-color--100);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge-container-unhealthy {
|
||||||
|
background-color: var(--pf-v5-global--danger-color--100);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge-toolbox {
|
||||||
|
background-color: var(--pf-v5-global--palette--purple-100);
|
||||||
|
color: var(--pf-v5-global--palette--purple-600);
|
||||||
|
|
||||||
|
.pf-v5-theme-dark & {
|
||||||
|
background-color: var(--pf-v5-global--palette--purple-500);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge-distrobox {
|
||||||
|
background-color: var(--pf-v5-global--palette--gold-100);
|
||||||
|
color: var(--pf-v5-global--palette--gold-600);
|
||||||
|
|
||||||
|
.pf-v5-theme-dark & {
|
||||||
|
background-color: var(--pf-v5-global--palette--gold-500);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
color: var(--pf-v5-global--success-color--100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.red {
|
||||||
|
color: var(--pf-v5-global--danger-color--100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the header nav from the expandable rows - this should be better done with JS but the current cockpit-listing-panel implementation does not support this variant
|
||||||
|
#containers-images .ct-listing-panel-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-grey-text {
|
||||||
|
color: var(--pf-v5-global--Color--200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-action {
|
||||||
|
text-align: end;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove doubled-up padding and borders on nested tables in mobile
|
||||||
|
.ct-listing-panel-body .ct-table tr {
|
||||||
|
--pf-v5-c-table-tr--responsive--PaddingTop: 0;
|
||||||
|
--pf-v5-c-table-tr--responsive--PaddingRight: 0;
|
||||||
|
--pf-v5-c-table-tr--responsive--PaddingBottom: 0;
|
||||||
|
--pf-v5-c-table-tr--responsive--PaddingLeft: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $pf-v5-global--breakpoint--md - 1) {
|
||||||
|
.show-only-when-wide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: $pf-v5-global--breakpoint--md) {
|
||||||
|
.show-only-when-narrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add borders to no pod containers list and images list
|
||||||
|
.container-pod.pf-m-plain tbody,
|
||||||
|
.containers-images tbody {
|
||||||
|
border: var(--pf-v5-c-card--m-flat--BorderWidth) solid var(--pf-v5-c-card--m-flat--BorderColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override table padding on mobile
|
||||||
|
@media (max-width: $pf-v5-global--breakpoint--md) {
|
||||||
|
.health-logs.pf-m-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr):not(.pf-v5-c-table__expandable-row) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
36
ui/cockpit-docker/src/index.html
Normal file
36
ui/cockpit-docker/src/index.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2017 Red Hat, Inc.
|
||||||
|
|
||||||
|
Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public License
|
||||||
|
along with this package; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
<html id="docker-page" class="pf-theme-dark" lang="en">
|
||||||
|
<head>
|
||||||
|
<title translatable="yes">docker containers</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="index.css">
|
||||||
|
|
||||||
|
<script type="text/javascript" src="index.js"></script>
|
||||||
|
<script type="text/javascript" src="po.js"></script>
|
||||||
|
<script type="text/javascript" src="../manifests.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="pf-m-redhat-font pf-v5-m-tabular-nums">
|
||||||
|
<div class="ct-page-fill" id="app">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
30
ui/cockpit-docker/src/index.js
Normal file
30
ui/cockpit-docker/src/index.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Cockpit.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2017 Red Hat, Inc.
|
||||||
|
*
|
||||||
|
* Cockpit is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Cockpit is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
* Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "cockpit-dark-theme";
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import 'patternfly/patternfly-5-cockpit.scss';
|
||||||
|
import Application from './app.jsx';
|
||||||
|
import './docker.scss';
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const root = createRoot(document.getElementById('app'));
|
||||||
|
root.render(<Application />);
|
||||||
|
});
|
16
ui/cockpit-docker/src/manifest.json
Normal file
16
ui/cockpit-docker/src/manifest.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{"path-exists": "/lib/systemd/system/docker.socket"}
|
||||||
|
],
|
||||||
|
"menu": {
|
||||||
|
"index": {
|
||||||
|
"label": "Docker containers",
|
||||||
|
"order": 50,
|
||||||
|
"keywords": [
|
||||||
|
{
|
||||||
|
"matches": ["docker", "container", "image"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
100
ui/cockpit-docker/src/rest.js
Normal file
100
ui/cockpit-docker/src/rest.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import cockpit from "cockpit";
|
||||||
|
import { debug } from "./util.js";
|
||||||
|
|
||||||
|
function manage_error(reject, error, content) {
|
||||||
|
let content_o = {};
|
||||||
|
if (content) {
|
||||||
|
try {
|
||||||
|
content_o = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
content_o.message = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const c = { ...error, ...content_o };
|
||||||
|
reject(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// calls are async, so keep track of a call counter to associate a result with a call
|
||||||
|
let call_id = 0;
|
||||||
|
|
||||||
|
function connect(address) {
|
||||||
|
/* This doesn't create a channel until a request */
|
||||||
|
const http = cockpit.http(address, { superuser: null });
|
||||||
|
const connection = {};
|
||||||
|
|
||||||
|
connection.monitor = function(options, callback, return_raw) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
http.request(options)
|
||||||
|
.stream(data => {
|
||||||
|
if (return_raw)
|
||||||
|
callback(data);
|
||||||
|
else {
|
||||||
|
buffer += data;
|
||||||
|
const chunks = buffer.split("\n");
|
||||||
|
buffer = chunks.pop();
|
||||||
|
|
||||||
|
chunks.forEach(chunk => {
|
||||||
|
debug("monitor", chunk);
|
||||||
|
callback(JSON.parse(chunk));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error, content) => {
|
||||||
|
manage_error(reject, error, content);
|
||||||
|
})
|
||||||
|
.then(resolve);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.call = function (options) {
|
||||||
|
const id = call_id++;
|
||||||
|
debug(`call ${id}:`, JSON.stringify(options));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
options = options || {};
|
||||||
|
http.request(options)
|
||||||
|
.then(result => {
|
||||||
|
debug(`call ${id} result:`, JSON.stringify(result));
|
||||||
|
resolve(result);
|
||||||
|
})
|
||||||
|
.catch((error, content) => {
|
||||||
|
debug(`call ${id} error:`, JSON.stringify(error), "content", JSON.stringify(content));
|
||||||
|
manage_error(reject, error, content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.close = function () {
|
||||||
|
http.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Connects to the docker service, performs a single call, and closes the
|
||||||
|
* connection.
|
||||||
|
*/
|
||||||
|
async function call (address, parameters) {
|
||||||
|
const connection = connect(address);
|
||||||
|
const result = await connection.call(parameters);
|
||||||
|
connection.close();
|
||||||
|
// if (parameters.method === "GET")
|
||||||
|
// return result;
|
||||||
|
|
||||||
|
// let p = {};
|
||||||
|
// try {
|
||||||
|
// p = JSON.parse(result);
|
||||||
|
// } catch {
|
||||||
|
// p = result;
|
||||||
|
// }
|
||||||
|
// console.log("call", { method: parameters.method, path: parameters.path, parameters, result: p });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connect,
|
||||||
|
call
|
||||||
|
};
|
214
ui/cockpit-docker/src/util.js
Normal file
214
ui/cockpit-docker/src/util.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
|
||||||
|
import cockpit from 'cockpit';
|
||||||
|
|
||||||
|
import { debounce } from 'throttle-debounce';
|
||||||
|
import * as dfnlocales from 'date-fns/locale';
|
||||||
|
import { formatRelative } from 'date-fns';
|
||||||
|
const _ = cockpit.gettext;
|
||||||
|
|
||||||
|
export const DockerInfoContext = React.createContext();
|
||||||
|
export const useDockerInfo = () => useContext(DockerInfoContext);
|
||||||
|
|
||||||
|
export const WithDockerInfo = ({ value, children }) => {
|
||||||
|
return (
|
||||||
|
<DockerInfoContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DockerInfoContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/containers/podman/blob/main/libpod/define/containerstate.go
|
||||||
|
// "Restarting" comes from special handling of restart case in Application.updateContainer()
|
||||||
|
export const states = [_("Exited"), _("Paused"), _("Stopped"), _("Removing"), _("Configured"), _("Created"), _("Restart"), _("Running")];
|
||||||
|
|
||||||
|
export const fallbackRegistries = ["docker.io", "quay.io"];
|
||||||
|
|
||||||
|
export function debug(...args) {
|
||||||
|
if (window.debugging === "all" || window.debugging?.includes("docker"))
|
||||||
|
console.debug("docker", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate_id(id) {
|
||||||
|
if (!id) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.indexOf(":") !== -1)
|
||||||
|
id = id.split(":")[1];
|
||||||
|
|
||||||
|
return id.substr(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localize_time(unix_timestamp) {
|
||||||
|
if (unix_timestamp === undefined || isNaN(unix_timestamp))
|
||||||
|
return "";
|
||||||
|
const locale = (cockpit.language == "en") ? dfnlocales.enUS : dfnlocales[cockpit.language.replace('_', '')];
|
||||||
|
return formatRelative(unix_timestamp * 1000, Date.now(), { locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format_cpu_usage(stats) {
|
||||||
|
const cpu_usage = stats?.cpu_stats?.cpu_usage?.total_usage;
|
||||||
|
const system_cpu_usage = stats?.cpu_stats?.system_cpu_usage;
|
||||||
|
const precpu_usage = stats?.precpu_stats?.cpu_usage?.total_usage;
|
||||||
|
const precpu_system_cpu_usage = stats?.precpu_stats?.system_cpu_usage;
|
||||||
|
|
||||||
|
if (cpu_usage === undefined || isNaN(cpu_usage))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
let cpu_percent = 0;
|
||||||
|
if (precpu_usage !== undefined && precpu_system_cpu_usage !== undefined) {
|
||||||
|
const cpu_delta = cpu_usage - precpu_usage;
|
||||||
|
const system_delta = system_cpu_usage - precpu_system_cpu_usage;
|
||||||
|
if (system_delta > 0 && cpu_delta > 0)
|
||||||
|
cpu_percent = (cpu_delta / system_delta) * stats.cpu_stats.online_cpus * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [cpu_percent.toFixed(2) + "%", cpu_percent];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format_memory_and_limit(stats) {
|
||||||
|
const usage = stats?.memory_stats?.usage;
|
||||||
|
const limit = stats?.memory_stats?.limit;
|
||||||
|
|
||||||
|
if (usage === undefined || isNaN(usage))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
let mtext = "";
|
||||||
|
let unit;
|
||||||
|
let parts;
|
||||||
|
if (limit) {
|
||||||
|
parts = cockpit.format_bytes(limit, undefined, { separate: true });
|
||||||
|
mtext = " / " + parts.join(" ");
|
||||||
|
unit = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usage) {
|
||||||
|
parts = cockpit.format_bytes(usage, unit, { separate: true });
|
||||||
|
if (mtext)
|
||||||
|
return [_(parts[0] + mtext), usage];
|
||||||
|
else
|
||||||
|
return [_(parts.join(" ")), usage];
|
||||||
|
} else {
|
||||||
|
return ["", -1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The functions quote_cmdline and unquote_cmdline implement
|
||||||
|
* a simple shell-like quoting syntax. They are used when letting the
|
||||||
|
* user edit a sequence of words as a single string.
|
||||||
|
*
|
||||||
|
* When parsing, words are separated by whitespace. Single and double
|
||||||
|
* quotes can be used to protect a sequence of characters that
|
||||||
|
* contains whitespace or the other quote character. A backslash can
|
||||||
|
* be used to protect any character. Quotes can appear in the middle
|
||||||
|
* of a word.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function quote_cmdline(words) {
|
||||||
|
words = words || [];
|
||||||
|
|
||||||
|
if (typeof words === 'string')
|
||||||
|
words = words.split(' ');
|
||||||
|
|
||||||
|
function is_whitespace(c) {
|
||||||
|
return c == ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(word) {
|
||||||
|
let text = "";
|
||||||
|
let quote_char = "";
|
||||||
|
let i;
|
||||||
|
for (i = 0; i < word.length; i++) {
|
||||||
|
if (word[i] == '\\' || word[i] == quote_char)
|
||||||
|
text += '\\';
|
||||||
|
else if (quote_char === "") {
|
||||||
|
if (word[i] == "'" || is_whitespace(word[i]))
|
||||||
|
quote_char = '"';
|
||||||
|
else if (word[i] == '"')
|
||||||
|
quote_char = "'";
|
||||||
|
}
|
||||||
|
text += word[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return quote_char + text + quote_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.map(quote).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unquote_cmdline(text) {
|
||||||
|
const words = [];
|
||||||
|
let next;
|
||||||
|
|
||||||
|
function is_whitespace(c) {
|
||||||
|
return c == ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip_whitespace() {
|
||||||
|
while (next < text.length && is_whitespace(text[next]))
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse_word() {
|
||||||
|
let word = "";
|
||||||
|
let quote_char = null;
|
||||||
|
|
||||||
|
while (next < text.length) {
|
||||||
|
if (text[next] == '\\') {
|
||||||
|
next++;
|
||||||
|
if (next < text.length) {
|
||||||
|
word += text[next];
|
||||||
|
}
|
||||||
|
} else if (text[next] == quote_char) {
|
||||||
|
quote_char = null;
|
||||||
|
} else if (quote_char) {
|
||||||
|
word += text[next];
|
||||||
|
} else if (text[next] == '"' || text[next] == "'") {
|
||||||
|
quote_char = text[next];
|
||||||
|
} else if (is_whitespace(text[next])) {
|
||||||
|
break;
|
||||||
|
} else
|
||||||
|
word += text[next];
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
next = 0;
|
||||||
|
skip_whitespace();
|
||||||
|
while (next < text.length) {
|
||||||
|
words.push(parse_word());
|
||||||
|
skip_whitespace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function image_name(image) {
|
||||||
|
return image.RepoTags.length > 0 ? image.RepoTags[0] : "<none>:<none>";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function is_valid_container_name(name) {
|
||||||
|
return /^[a-zA-Z0-9][a-zA-Z0-9_\\.-]*$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clears a single field in validationFailed object.
|
||||||
|
*
|
||||||
|
* Arguments:
|
||||||
|
* - validationFailed (object): Object containing list of fields with validation error
|
||||||
|
* - key (string): Specified which field from validationFailed object is clear
|
||||||
|
* - onValidationChange (func)
|
||||||
|
*/
|
||||||
|
export const validationClear = (validationFailed, key, onValidationChange) => {
|
||||||
|
if (!validationFailed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const delta = { ...validationFailed };
|
||||||
|
delete delta[key];
|
||||||
|
onValidationChange(delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
// This method needs to be outside of component as re-render would create a new instance of debounce
|
||||||
|
export const validationDebounce = debounce(500, (validationHandler) => validationHandler());
|
82
ui/cockpit-docker/test/browser/browser.sh
Executable file
82
ui/cockpit-docker/test/browser/browser.sh
Executable file
@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eux
|
||||||
|
cd "${0%/*}/../.."
|
||||||
|
|
||||||
|
# HACK: ensure that critical components are up to date: https://github.com/psss/tmt/issues/682
|
||||||
|
dnf update -y docker crun conmon criu
|
||||||
|
|
||||||
|
# if we run during cross-project testing against our main-builds COPR, then let that win
|
||||||
|
# even if Fedora has a newer revision
|
||||||
|
main_builds_repo="$(ls /etc/yum.repos.d/*cockpit*main-builds* 2>/dev/null || true)"
|
||||||
|
if [ -n "$main_builds_repo" ]; then
|
||||||
|
echo 'priority=0' >> "$main_builds_repo"
|
||||||
|
dnf distro-sync -y --repo 'copr*' cockpit-docker
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show critical package versions
|
||||||
|
rpm -q runc crun docker criu passt kernel-core selinux-policy cockpit-docker cockpit-bridge || true
|
||||||
|
|
||||||
|
# Show network information, for pasta debugging
|
||||||
|
ip address show
|
||||||
|
ip -4 route show
|
||||||
|
ip -6 route show
|
||||||
|
|
||||||
|
# allow test to set up things on the machine
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
curl https://raw.githubusercontent.com/cockpit-project/bots/main/machine/identity.pub >> /root/.ssh/authorized_keys
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
|
||||||
|
# create user account for logging in
|
||||||
|
if ! id admin 2>/dev/null; then
|
||||||
|
useradd -c Administrator -G wheel admin
|
||||||
|
echo admin:foobar | chpasswd
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set root's password
|
||||||
|
echo root:foobar | chpasswd
|
||||||
|
|
||||||
|
# avoid sudo lecture during tests
|
||||||
|
su -c 'echo foobar | sudo --stdin whoami' - admin
|
||||||
|
|
||||||
|
# disable core dumps, we rather investigate them upstream where test VMs are accessible
|
||||||
|
echo core > /proc/sys/kernel/core_pattern
|
||||||
|
|
||||||
|
# grab a few images to play with; tests run offline, so they cannot download images
|
||||||
|
docker rmi --all
|
||||||
|
|
||||||
|
# set up our expected images, in the same way that we do for upstream CI
|
||||||
|
# this sometimes runs into network issues, so retry a few times
|
||||||
|
for retry in $(seq 5); do
|
||||||
|
if curl https://raw.githubusercontent.com/cockpit-project/bots/main/images/scripts/lib/podman-images.setup | sh -eux; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep $((5 * retry * retry))
|
||||||
|
done
|
||||||
|
|
||||||
|
CONTAINER="$(cat .cockpit-ci/container)"
|
||||||
|
|
||||||
|
# import the test CONTAINER image as a directory tree for nspawn
|
||||||
|
mkdir /var/tmp/tasks
|
||||||
|
podman export "$(podman create --name tasks-import $CONTAINER)" | tar -x -C /var/tmp/tasks
|
||||||
|
podman rm tasks-import
|
||||||
|
podman rmi $CONTAINER
|
||||||
|
|
||||||
|
# image setup, shared with upstream tests
|
||||||
|
sh -x test/vm.install
|
||||||
|
|
||||||
|
systemctl enable --now cockpit.socket docker.socket
|
||||||
|
|
||||||
|
# Run tests in the cockpit tasks container, as unprivileged user
|
||||||
|
# Use nspawn to avoid the tests killing the tasks container itself
|
||||||
|
chown -R 1111:1111 "${TMT_TEST_DATA}" .
|
||||||
|
|
||||||
|
SYSTEMD_SECCOMP=0 systemd-nspawn \
|
||||||
|
-D /var/tmp/tasks/ \
|
||||||
|
--ephemeral \
|
||||||
|
--user user \
|
||||||
|
--setenv=TEST_AUDIT_NO_SELINUX="${TEST_AUDIT_NO_SELINUX:-}" \
|
||||||
|
--bind="${TMT_TEST_DATA}":/logs --setenv=LOGS=/logs \
|
||||||
|
--bind="$(pwd)":/source --setenv=SOURCE=/source \
|
||||||
|
--bind-ro=/usr/lib/os-release:/run/host/usr/lib/os-release \
|
||||||
|
sh /source/test/browser/run-test.sh "$@"
|
20
ui/cockpit-docker/test/browser/main.fmf
Normal file
20
ui/cockpit-docker/test/browser/main.fmf
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
require:
|
||||||
|
- cockpit-docker
|
||||||
|
- cockpit-ws
|
||||||
|
- cockpit-system
|
||||||
|
- criu
|
||||||
|
# HACK: https://bugzilla.redhat.com/show_bug.cgi?id=2269485
|
||||||
|
- slirp4netns
|
||||||
|
duration: 30m
|
||||||
|
|
||||||
|
/system:
|
||||||
|
test: ./browser.sh system
|
||||||
|
summary: Run *System tests
|
||||||
|
|
||||||
|
/user:
|
||||||
|
test: ./browser.sh user
|
||||||
|
summary: Run *User tests
|
||||||
|
|
||||||
|
/other:
|
||||||
|
test: ./browser.sh other
|
||||||
|
summary: Run all other tests
|
54
ui/cockpit-docker/test/browser/run-test.sh
Normal file
54
ui/cockpit-docker/test/browser/run-test.sh
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
set -eux
|
||||||
|
|
||||||
|
PLAN="$1"
|
||||||
|
|
||||||
|
cd "${SOURCE}"
|
||||||
|
|
||||||
|
# tests need cockpit's bots/ libraries and test infrastructure
|
||||||
|
rm -f bots # common local case: existing bots symlink
|
||||||
|
make bots test/common
|
||||||
|
|
||||||
|
if [ -e .git ]; then
|
||||||
|
tools/node-modules checkout
|
||||||
|
# disable detection of affected tests; testing takes too long as there is no parallelization
|
||||||
|
mv .git dot-git
|
||||||
|
else
|
||||||
|
# upstream tarballs ship test dependencies; print version for debugging
|
||||||
|
grep '"version"' node_modules/chrome-remote-interface/package.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
. /run/host/usr/lib/os-release
|
||||||
|
export TEST_OS="${ID}-${VERSION_ID/./-}"
|
||||||
|
|
||||||
|
if [ "$TEST_OS" = "centos-8" ] || [ "$TEST_OS" = "centos-9" ]; then
|
||||||
|
TEST_OS="${TEST_OS}-stream"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Chromium sometimes gets OOM killed on testing farm
|
||||||
|
export TEST_BROWSER=firefox
|
||||||
|
|
||||||
|
# select subset of tests according to plan
|
||||||
|
TESTS="$(test/common/run-tests -l)"
|
||||||
|
case "$PLAN" in
|
||||||
|
system) TESTS="$(echo "$TESTS" | grep 'System$')" ;;
|
||||||
|
user) TESTS="$(echo "$TESTS" | grep 'User$')" ;;
|
||||||
|
other) TESTS="$(echo "$TESTS" | grep -vE '(System|User)$')" ;;
|
||||||
|
*) echo "Unknown test plan: $PLAN" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
EXCLUDES=""
|
||||||
|
|
||||||
|
# make it easy to check in logs
|
||||||
|
echo "TEST_ALLOW_JOURNAL_MESSAGES: ${TEST_ALLOW_JOURNAL_MESSAGES:-}"
|
||||||
|
echo "TEST_AUDIT_NO_SELINUX: ${TEST_AUDIT_NO_SELINUX:-}"
|
||||||
|
|
||||||
|
RC=0
|
||||||
|
./test/common/run-tests \
|
||||||
|
--nondestructive \
|
||||||
|
--machine localhost:22 \
|
||||||
|
--browser localhost:9090 \
|
||||||
|
$TESTS \
|
||||||
|
$EXCLUDES \
|
||||||
|
|| RC=$?
|
||||||
|
cp --verbose Test* "$LOGS" || true
|
||||||
|
exit $RC
|
2714
ui/cockpit-docker/test/check-application
Executable file
2714
ui/cockpit-docker/test/check-application
Executable file
File diff suppressed because it is too large
Load Diff
1
ui/cockpit-docker/test/reference-image
Normal file
1
ui/cockpit-docker/test/reference-image
Normal file
@ -0,0 +1 @@
|
|||||||
|
fedora-39
|
17
ui/cockpit-docker/test/run
Executable file
17
ui/cockpit-docker/test/run
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
# This is the expected entry point for Cockpit CI; will be called without
|
||||||
|
# arguments but with an appropriate $TEST_OS
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export RUN_TESTS_OPTIONS=--track-naughties
|
||||||
|
|
||||||
|
TEST_SCENARIO=${TEST_SCENARIO:-}
|
||||||
|
|
||||||
|
if [ "$TEST_SCENARIO" == "devel" ]; then
|
||||||
|
export TEST_COVERAGE=yes
|
||||||
|
fi
|
||||||
|
|
||||||
|
make codecheck
|
||||||
|
make check
|
||||||
|
make po/docker.pot
|
39
ui/cockpit-docker/test/vm.install
Executable file
39
ui/cockpit-docker/test/vm.install
Executable file
@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# image-customize script to prepare a bots VM for cockpit-docker testing
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if grep -q ID.*debian /usr/lib/os-release; then
|
||||||
|
# Debian does not enable user namespaces by default
|
||||||
|
echo kernel.unprivileged_userns_clone = 1 > /etc/sysctl.d/00-local-userns.conf
|
||||||
|
systemctl restart systemd-sysctl
|
||||||
|
|
||||||
|
# disable services that get in the way of /var/lib/containers
|
||||||
|
if systemctl is-enabled docker.service; then
|
||||||
|
systemctl disable docker.service
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# don't force https:// (self-signed cert)
|
||||||
|
mkdir -p /etc/cockpit
|
||||||
|
printf "[WebService]\\nAllowUnencrypted=true\\n" > /etc/cockpit/cockpit.conf
|
||||||
|
|
||||||
|
if systemctl is-active -q firewalld.service; then
|
||||||
|
firewall-cmd --add-service=cockpit --permanent
|
||||||
|
fi
|
||||||
|
|
||||||
|
. /usr/lib/os-release
|
||||||
|
|
||||||
|
# Remove extra images, tests assume our specific set
|
||||||
|
# Since 4.0 docker now ships the pause image
|
||||||
|
docker images --format '{{.Repository}}:{{.Tag}}' | grep -Ev 'localhost/test-|pause|cockpit/ws' | xargs -r docker rmi -f
|
||||||
|
|
||||||
|
# tests reset podman, save the images
|
||||||
|
mkdir -p /var/lib/test-images
|
||||||
|
for img in $(podman images --format '{{.Repository}}:{{.Tag}}'); do
|
||||||
|
fname="$(echo "$img" | tr -dc '[a-zA-Z-]')"
|
||||||
|
podman save -o "/var/lib/test-images/${fname}.tar" "$img"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 15minutes after boot tmp files are removed and docker stores some tmp lock files
|
||||||
|
systemctl disable --now systemd-tmpfiles-clean.timer
|
||||||
|
systemctl --global disable systemd-tmpfiles-clean.timer
|
Loading…
Reference in New Issue
Block a user