diff --git a/.babelrc b/.babelrc deleted file mode 100644 index d0ef093..0000000 --- a/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@babel/env", - "@babel/preset-react"] -} diff --git a/.eslintignore b/.eslintignore index 8d87b1d..85f5a45 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ node_modules/* +pkg/lib/* diff --git a/.eslintrc.json b/.eslintrc.json index 3b7538e..1918f1a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,18 +1,15 @@ { + "root": true, "env": { "browser": true, "es6": true }, - "extends": ["eslint:recommended", "standard", "standard-react"], - "parser": "babel-eslint", + "extends": ["eslint:recommended", "standard", "standard-jsx", "standard-react"], "parserOptions": { - "ecmaFeatures": { - "experimentalObjectRestSpread": true, - "jsx": true - }, + "ecmaVersion": "2022", "sourceType": "module" }, - "plugins": ["flowtype", "react"], + "plugins": ["flowtype", "react", "react-hooks"], "rules": { "indent": ["error", 4, { @@ -22,11 +19,15 @@ "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 }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + "camelcase": "off", "comma-dangle": "off", "curly": "off", @@ -38,12 +39,7 @@ "react/jsx-indent-props": "off", "react/prop-types": "off", "space-before-function-paren": "off", - "standard/no-callback-literal": "off", - - "eqeqeq": "off", - "import/no-webpack-loader-syntax": "off", - "object-property-newline": "off", - "react/jsx-no-bind": "off" + "standard/no-callback-literal": "off" }, "globals": { "require": false, diff --git a/.fmf/version b/.fmf/version new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5a05625 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +# Create a GitHub upstream release +# See README.md. +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/unit-tests + options: --user root + permissions: + # create GitHub release + contents: write + steps: + - name: Clone repository + uses: actions/checkout@v3 + 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-session-recording/cockpit-session-recording + + - 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@88d994da62d1451c7073e26748c18413fcdf46e9 + with: + filename: "cockpit-session-recording-${{ github.ref_name }}.tar.xz" diff --git a/.gitignore b/.gitignore index 32b6401..aa13142 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *~ *.retry -*.tar.gz +*.tar.xz *.rpm node_modules/ dist/ @@ -8,8 +8,12 @@ dist/ /.vagrant package-lock.json Test*FAIL* -bots/ +/bots test/common/ test/images/ +pkg *.pot POTFILES* +tmp/ +/po/LINGUAS +/tools diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..352fbe6 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,38 @@ +{ + "extends": "stylelint-config-standard-scss", + "rules": { + "declaration-colon-newline-after": null, + "selector-list-comma-newline-after": null, + + "at-rule-empty-line-before": null, + "declaration-colon-space-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, + "indentation": null, + "keyframes-name-pattern": null, + "max-line-length": null, + "no-descending-specificity": null, + "no-duplicate-selectors": null, + "scss/at-extend-no-missing-placeholder": null, + "scss/at-import-partial-extension": null, + "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 + } +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e5d53fc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -dist: trusty -sudo: false -language: node_js -node_js: - - "8" -script: - - npm install - - npm run build diff --git a/Makefile b/Makefile index 6bd672c..8d8c499 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,45 @@ # 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 2>/dev/null) || T=1; echo $$T | tr '-' '.') ifeq ($(TEST_OS),) -TEST_OS = centos-7 +TEST_OS = centos-8-stream 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=org.cockpit-project.$(PACKAGE_NAME).metainfo.xml VM_IMAGE=$(CURDIR)/test/images/$(TEST_OS) -# one example directory from `npm install` to check if that already ran -NODE_MODULES_TEST=node_modules/po2json -# one example file in dist/ from webpack to check if that already ran -WEBPACK_TEST=dist/index.html +# 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 -all: $(WEBPACK_TEST) +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 \ + $(NULL) + +COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git +COCKPIT_REPO_COMMIT = 9c73bec7e1dc2395a00aa0c510fd7210b6c96a16 # 300.1 + 42 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 @@ -19,89 +47,98 @@ all: $(WEBPACK_TEST) LINGUAS=$(basename $(notdir $(wildcard po/*.po))) -po/POTFILES.js.in: - mkdir -p $(dir $@) - find src/ -name '*.js' -o -name '*.jsx' -o -name '*.es6' > $@ - -po/$(PACKAGE_NAME).js.pot: po/POTFILES.js.in - xgettext --default-domain=cockpit --output=$@ --language=C --keyword= \ - --keyword=_:1,1t --keyword=_:1c,2,1t --keyword=C_:1c,2 \ +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 --files-from=$^ + --from-code=UTF-8 $$(find src/ -name '*.js' -o -name '*.jsx') -po/POTFILES.html.in: - mkdir -p $(dir $@) - find src -name '*.html' > $@ +po/$(PACKAGE_NAME).html.pot: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) + pkg/lib/html2po.js -o $@ $$(find src -name '*.html') -po/$(PACKAGE_NAME).html.pot: po/POTFILES.html.in - po/html2po -f $^ -o $@ +po/$(PACKAGE_NAME).manifest.pot: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) + pkg/lib/manifest2po.js src/manifest.json -o $@ -po/$(PACKAGE_NAME).manifest.pot: - po/manifest2po 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).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=$@ $^ -# Update translations against current PO template -update-po: po/$(PACKAGE_NAME).pot - for lang in $(LINGUAS); do \ - msgmerge --output-file=po/$$lang.po po/$$lang.po $<; \ - done - -dist/po.%.js: po/%.po $(NODE_MODULES_TEST) - mkdir -p $(dir $@) - po/po2json -m po/po.empty.js -o $@.js.tmp $< - mv $@.js.tmp $@ +po/LINGUAS: + echo $(LINGUAS) | tr ' ' '\n' > $@ # # Build/Install/dist # -%.spec: %.spec.in - sed -e 's/@VERSION@/$(VERSION)/g' $< > $@ +$(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' $< > $@ -$(WEBPACK_TEST): $(NODE_MODULES_TEST) $(shell find src/ -type f) package.json webpack.config.js $(patsubst %,dist/po.%.js,$(LINGUAS)) - NODE_ENV=$(NODE_ENV) npm run build +$(DIST_TEST): $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) $(shell find src/ -type f) package.json build.js + NODE_ENV=$(NODE_ENV) ./build.js + +watch: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) + NODE_ENV=$(NODE_ENV) ./build.js --watch clean: rm -rf dist/ - [ ! -e cockpit-$(PACKAGE_NAME).spec.in ] || rm -f cockpit-$(PACKAGE_NAME).spec + rm -f $(SPEC) + rm -f po/LINGUAS -install: $(WEBPACK_TEST) - mkdir -p $(DESTDIR)/usr/share/cockpit/$(PACKAGE_NAME) - cp -r dist/* $(DESTDIR)/usr/share/cockpit/$(PACKAGE_NAME) - mkdir -p $(DESTDIR)/usr/share/metainfo/ - cp org.cockpit-project.$(PACKAGE_NAME).metainfo.xml $(DESTDIR)/usr/share/metainfo/ +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: $(WEBPACK_TEST) +devel-install: $(DIST_TEST) mkdir -p ~/.local/share/cockpit ln -s `pwd`/dist ~/.local/share/cockpit/$(PACKAGE_NAME) -# when building a distribution tarball, call webpack with a 'production' environment -# ship a stub node_modules/ so that `make` works without re-running `npm install` -dist-gzip: NODE_ENV=production -dist-gzip: all cockpit-$(PACKAGE_NAME).spec - mv node_modules node_modules.release - mkdir -p $(NODE_MODULES_TEST) - touch -r package.json $(NODE_MODULES_TEST) - touch dist/* - tar czf cockpit-$(PACKAGE_NAME)-$(VERSION).tar.gz --transform 's,^,cockpit-$(PACKAGE_NAME)/,' \ - --exclude cockpit-$(PACKAGE_NAME).spec.in \ - $$(git ls-files) cockpit-$(PACKAGE_NAME).spec dist/ node_modules - rm -rf node_modules - mv node_modules.release node_modules +# 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) -srpm: dist-gzip cockpit-$(PACKAGE_NAME).spec +print-version: + @echo "$(VERSION)" + +dist: $(TARFILE) + @ls -1 $(TARFILE) + +# when building a distribution tarball, call bundler with a 'production' environment +# we don't ship node_modules for license and compactness reasons; 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) + 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 packaging/$(SPEC).in --exclude node_modules \ + $$(git ls-files) $(COCKPIT_REPO_FILES) $(NODE_MODULES_TEST) $(SPEC) dist/ + +$(NODE_CACHE): $(NODE_MODULES_TEST) + tar --xz $(TAR_ARGS) -cf $@ node_modules + +node-cache: $(NODE_CACHE) + +# convenience target for developers +srpm: $(TARFILE) $(NODE_CACHE) $(SPEC) rpmbuild -bs \ --define "_sourcedir `pwd`" \ --define "_srcrpmdir `pwd`" \ - cockpit-$(PACKAGE_NAME).spec + $(SPEC) -rpm: dist-gzip cockpit-$(PACKAGE_NAME).spec +# convenience target for developers +rpm: $(TARFILE) $(NODE_CACHE) $(SPEC) mkdir -p "`pwd`/output" mkdir -p "`pwd`/rpmbuild" rpmbuild -bb \ @@ -111,39 +148,46 @@ rpm: dist-gzip cockpit-$(PACKAGE_NAME).spec --define "_srcrpmdir `pwd`" \ --define "_rpmdir `pwd`/output" \ --define "_buildrootdir `pwd`/build" \ - cockpit-$(PACKAGE_NAME).spec + $(SPEC) find `pwd`/output -name '*.rpm' -printf '%f\n' -exec mv {} . \; rm -r "`pwd`/rpmbuild" rm -r "`pwd`/output" "`pwd`/build" -# build a VM with locally built rpm installed -$(VM_IMAGE): rpm bots - rm -f $(VM_IMAGE) $(VM_IMAGE).qcow2 - bots/image-customize -v -i cockpit -i `pwd`/cockpit-$(PACKAGE_NAME)-*.noarch.rpm -s $(CURDIR)/test/vm.install $(TEST_OS) +# build a VM with locally built distro pkgs installed +# disable networking, VM images have mock/pbuilder with the common build dependencies pre-installed +$(VM_IMAGE): $(TARFILE) $(NODE_CACHE) bots test/vm.install + bots/image-customize --fresh \ + --upload $(NODE_CACHE):/var/tmp/ --build $(TARFILE) \ + --upload ./test/files/1.journal:/var/log/journal/1.journal \ + --upload ./test/files/binary-rec.journal:/var/log/journal/binary-rec.journal \ + --script $(CURDIR)/test/vm.install $(TEST_OS) # convenience target for the above vm: $(VM_IMAGE) - echo $(VM_IMAGE) + @echo $(VM_IMAGE) -# run the browser integration tests; skip check for SELinux denials -check: $(NODE_MODULES_TEST) $(VM_IMAGE) test/common - TEST_AUDIT_NO_SELINUX=1 test/check-application +# convenience target to print the filename of the test image +print-vm: + @echo $(VM_IMAGE) -# checkout Cockpit's bots/ directory for standard test VM images and API to launch them -# must be from cockpit's master, as only that has current and existing images; but testvm.py API is stable -bots: - git fetch --depth=1 https://github.com/cockpit-project/cockpit.git - git checkout --force FETCH_HEAD -- bots/ - git reset bots +# 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 -# checkout Cockpit's test API; this has no API stability guarantee, so check out a stable tag -# when you start a new project, use the latest relese, and update it from time to time -test/common: - git fetch --depth=1 https://github.com/cockpit-project/cockpit.git 176 - git checkout --force FETCH_HEAD -- test/common - git reset test/common +# run the browser integration tests +# this will run all tests/check-* and format them as TAP +check: prepare-check + test/common/run-tests ${RUN_TESTS_OPTIONS} + +# checkout Cockpit's bots for standard test VM images and API to launch them +bots: $(COCKPIT_REPO_STAMP) + test/common/make-bots $(NODE_MODULES_TEST): package.json - npm install + # if it exists already, npm install won't update it; force that so that we always get up-to-date packages + rm -f package-lock.json + # unset NODE_ENV, skips devDependencies otherwise + env -u NODE_ENV npm install --ignore-scripts + env -u NODE_ENV npm prune -.PHONY: all clean install devel-install dist-gzip srpm rpm check vm update-po +.PHONY: all clean install devel-install devel-uninstall print-version dist node-cache rpm prepare-check check vm print-vm diff --git a/README.md b/README.md index 1f408d0..0987ec5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,24 @@ -# Cockpit Starter Kit +# Cockpit Session Recording -Scaffolding for a [Cockpit](http://www.cockpit-project.org) module. +Module for [Cockpit](http://www.cockpit-project.org) which provides session recording +configuration and playback. +It requires [tlog](https://github.com/Scribery/tlog) to record terminal sessions. +SSSD is required to manage which users / groups are recorded. Systemd Journal is used to store recordings. +Ansible role for session-recording is [here](https://github.com/nkinder/session-recording). + +Demos & Talks: + + * [Demo 1 on YouTube](https://youtu.be/5-0WBf4rOrc) + * [Demo 2 on YouTube](https://youtu.be/Fw8g_fFvwcs) + * [FOSDEM talk](https://youtu.be/sHO5y28EHXg) + +GitHub Organization: + + * [scribery.github.io](http://scribery.github.io/) + * [Scribery](https://github.com/Scribery) + +This project is based on the [Cockpit Starter Kit](https://github.com/cockpit-project/starter-kit). +See [Starter Kit Intro](http://cockpit-project.org/blog/cockpit-starter-kit.html) for details. # Getting and building the source @@ -8,37 +26,66 @@ Make sure you have `npm` available (usually from your distribution package). These commands check out the source and build it into the `dist/` directory: ``` -git clone https://github.com/cockpit-project/starter-kit.git -cd starter-kit +git clone https://github.com/Scribery/cockpit-session-recording.git +cd cockpit-session-recording make ``` # Installing -`make install` compiles and installs the package in `/usr/share/cockpit/`. The +`make install` compiles and installs the package in `/usr/local/share/cockpit/`. The convenience targets `srpm` and `rpm` build the source and binary rpms, -respectively. Both of these make use of the `dist-gzip` target, which is used +respectively. Both of these make use of the `dist` target, which is used to generate the distribution tarball. In `production` mode, source files are automatically minified and compressed. Set `NODE_ENV=production` if you want to duplicate this behavior. For development, you usually want to run your module straight out of the git -tree. To do that, link that to the location were `cockpit-bridge` looks for packages: +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/starter-kit +ln -s `pwd`/dist ~/.local/share/cockpit/session-recording ``` 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 + + $ ./build.js -w + +or + + $ 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 + +To "uninstall" the locally installed version, run `make devel-uninstall`, or +remove manually the symlink: + + rm ~/.local/share/cockpit/starter-kit + # Running eslint Cockpit Starter Kit uses [ESLint](https://eslint.org/) to automatically check -JavaScript code style in `.jsx` and `.es6` files. +JavaScript code style in `.js` and `.jsx` files. -The linter is executed within every build as a webpack preloader. +eslint is executed within every build. For developer convenience, the ESLint can be started explicitly by: @@ -50,60 +97,49 @@ Violations of some rules can be fixed automatically by: Rules configuration can be found in the `.eslintrc.json` file. -# Automated Testing +## Running stylelint + +Cockpit uses [Stylelint](https://stylelint.io/) to automatically check CSS code +style in `.css` and `scss` files. + +styleint is executed within every build. + +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. + +During fast iterative development, you can also choose to not run eslint/stylelint. +This speeds up the build and avoids build failures due to e. g. ill-formatted +css or other issues: + + $ ./build.js -es + +# Running tests locally Run `make check` to build an RPM, install it into a standard Cockpit test VM -(centos-7 by default), and run the test/check-application integration test on +(centos-8-stream by default), and run the test/check-application integration test on it. This uses Cockpit's Chrome DevTools Protocol based browser tests, through a Python API abstraction. Note that this API is not guaranteed to be stable, so if you run into failures and don't want to adjust tests, consider checking out -Cockpit's test/common from a tag instead of master (see the `test/common` +Cockpit's test/common from a tag instead of main (see the `test/common` target in `Makefile`). After the test VM is prepared, you can manually run the test without rebuilding the VM, possibly with extra options for tracing and halting on test failures (for interactive debugging): - TEST_OS=centos-7 test/check-application -tvs + TEST_OS=centos-8-stream test/check-application -tvs + +It is possible to setup the test environment without running the tests: + + TEST_OS=centos-8-stream make prepare-check You can also run the test against a different Cockpit image, for example: - TEST_OS=fedora-28 make check - -# Vagrant - -This directory contains a Vagrantfile that installs and starts cockpit on a -Fedora 26 cloud image. Run `vagrant up` to start it and `vagrant rsync` to -synchronize the `dist` directory to `/usr/local/share/cockit/starter-kit`. Use -`vagrant rsync-auto` to automatically sync when contents of the `dist` -directory change. - -# Customizing - -After cloning the Starter Kit you should rename the files, package names, and -labels to your own project's name. Use these commands to find out what to -change: - - find -iname '*starter*' - git grep -i starter - -# Automated release - -Once your cloned project is ready for a release, you should consider automating -that. [Cockpituous release](https://github.com/cockpit-project/cockpituous/tree/master/release) -aims to fully automate project releases to GitHub, Fedora, Ubuntu, COPR, Docker -Hub, and other places. The intention is that the only manual step for releasing -a project is to create a signed tag for the version number; pushing the tag -then triggers a GitHub webhook that calls a set of release scripts (on -Cockpit's CI infrastructure). - -starter-kit includes an example [cockpitous release script](./cockpituous-release) -that builds an upstream release tarball and source RPM. Please see the above -cockpituous documentation for details. - -# Further reading - - * The [Starter Kit announcement](http://cockpit-project.org/blog/cockpit-starter-kit.html) - blog post explains the rationale for this project. - * [Cockpit Deployment and Developer documentation](http://cockpit-project.org/guide/latest/) - * [Make your project easily discoverable](http://cockpit-project.org/blog/making-a-cockpit-application.html) + TEST_OS=fedora-34 make check diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index c446c94..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,30 +0,0 @@ -Vagrant.configure(2) do |config| - config.vm.box = "fedora/28-cloud-base" - config.vm.network "forwarded_port", guest: 9090, host: 9090 - - if Dir.glob("dist/*").length == 0 - config.vm.post_up_message = "NOTE: Distribution directory is empty. Run `make` to see your module show up in cockpit" - end - - config.vm.synced_folder ".", "/vagrant", disabled: true - config.vm.synced_folder "dist/", "/usr/local/share/cockpit/" + File.basename(Dir.pwd), type: "rsync", create: true - - config.vm.provider "libvirt" do |libvirt| - libvirt.memory = 1024 - end - - config.vm.provider "virtualbox" do |virtualbox| - virtualbox.memory = 1024 - end - - config.vm.provision "shell", inline: <<-EOF - set -eu - - sudo dnf install -y cockpit - - printf "[WebService]\nAllowUnencrypted=true\n" > /etc/cockpit/cockpit.conf - - systemctl enable cockpit.socket - systemctl start cockpit.socket - EOF -end diff --git a/build.js b/build.js new file mode 100755 index 0000000..8fca807 --- /dev/null +++ b/build.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import copy from 'esbuild-plugin-copy'; + +import { cleanPlugin } from './pkg/lib/esbuild-cleanup-plugin.js'; +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 { esbuildStylesPlugins } from './pkg/lib/esbuild-common.js'; +import { eslintPlugin } from './pkg/lib/esbuild-eslint-plugin.js'; +import { stylelintPlugin } from './pkg/lib/esbuild-stylelint-plugin.js'; + +const production = process.env.NODE_ENV === 'production'; +const useWasm = os.arch() !== 'x64'; +const esbuild = (await import(useWasm ? 'esbuild-wasm' : 'esbuild')).default; +const lintDefault = process.env.LINT ? process.env.LINT === '0' : production; + +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" }); +parser.add_argument('-e', '--no-eslint', { action: 'store_true', help: "Disable eslint linting", default: lintDefault }); +parser.add_argument('-s', '--no-stylelint', { action: 'store_true', help: "Disable stylelint linting", default: lintDefault }); +const args = parser.parse_args(); + +if (args.rsync) + process.env.RSYNC = args.rsync; + +// List of directories to use when using import statements +const nodePaths = ['pkg/lib']; +const outdir = 'dist'; + +// Obtain package name from package.json +const packageJson = JSON.parse(fs.readFileSync('package.json')); + +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 cwd = process.cwd(); + +// similar to fs.watch(), but recursively watches all subdirectories +function watch_dirs(dir, on_change) { + const callback = (ev, dir, fname) => { + // only listen for "change" events, as renames are noisy + // ignore hidden files + const isHidden = /^\./.test(fname); + if (ev !== "change" || isHidden) { + return; + } + on_change(path.join(dir, fname)); + }; + + fs.watch(dir, {}, (ev, path) => callback(ev, dir, path)); + + // watch all subdirectories in dir + const d = fs.opendirSync(dir); + let dirent; + + while ((dirent = d.readSync()) !== null) { + if (dirent.isDirectory()) + watch_dirs(path.join(dir, dirent.name), on_change); + } + d.closeSync(); +} + +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(), + ...args.no_stylelint ? [] : [stylelintPlugin({ filter: new RegExp(cwd + '\/src\/.*\.(css?|scss?)$') })], + ...args.no_eslint ? [] : [eslintPlugin({ filter: new RegExp(cwd + '\/src\/.*\.(jsx?|js?)$') })], + // 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 { + await context.rebuild(); +} catch (e) { + if (!args.watch) + process.exit(1); + // ignore errors in watch mode +} + +if (args.watch) { + const on_change = async path => { + console.log("change detected:", path); + await context.cancel(); + + try { + await context.rebuild(); + } catch (e) {} // ignore in watch mode + }; + + watch_dirs('src', on_change); + + // wait forever until Control-C + await new Promise(() => {}); +} + +context.dispose(); diff --git a/cockpit-starter-kit.spec.in b/cockpit-starter-kit.spec.in deleted file mode 100644 index 53e8b70..0000000 --- a/cockpit-starter-kit.spec.in +++ /dev/null @@ -1,25 +0,0 @@ -Name: cockpit-starter-kit -Version: @VERSION@ -Release: 1%{?dist} -Summary: Cockpit Starter Kit Example Module -License: LGPLv2+ - -Source: cockpit-starter-kit-%{version}.tar.gz -BuildArch: noarch - -%define debug_package %{nil} - -%description -Cockpit Starter Kit Example Module - -%prep -%setup -n cockpit-starter-kit - -%install -%make_install - -%files -%{_datadir}/cockpit/* -%{_datadir}/metainfo/* - -%changelog diff --git a/cockpituous-release b/cockpituous-release deleted file mode 100644 index b876779..0000000 --- a/cockpituous-release +++ /dev/null @@ -1,31 +0,0 @@ -# This is a script run to release welder-web through Cockpituous: -# https://github.com/cockpit-project/cockpituous/tree/master/release - -# Anything that start with 'job' may run in a way that it SIGSTOP's -# itself when preliminary preparition and then gets a SIGCONT in -# order to complete its work. -# -# Check cockpituous documentation for available release targets. - -RELEASE_SOURCE="_release/source" -RELEASE_SPEC="cockpit-starter-kit.spec" -RELEASE_SRPM="_release/srpm" - -job release-source -job release-srpm - -# Once you have a Fedora package and add the https://pagure.io/user/cockpit -# user to your project's maintainers, you can also upload to Fedora automatically: - -## Authenticate for pushing into Fedora dist-git (works in Cockpituous release container) -# cat ~/.fedora-password | kinit cockpit@FEDORAPROJECT.ORG -## Do fedora builds for the tag, using tarball -# job release-koji -k master -# job release-koji f29 -# job release-bodhi F29 - -# These are likely the first of your release targets; but run them after Fedora uploads, -# so that failures there will fail the release early, before publishing on GitHub - -# job release-github -# job release-copr @myorg/myrepo diff --git a/org.cockpit-project.session-recording.metainfo.xml b/org.cockpit-project.session-recording.metainfo.xml new file mode 100644 index 0000000..3671e85 --- /dev/null +++ b/org.cockpit-project.session-recording.metainfo.xml @@ -0,0 +1,18 @@ + + + org.cockpit_project.session-recording + CC0-1.0 + Session Recording + Session Recording module for Cockpit + +

+ Provides Session Recording module for Cockpit. Provides list of recorded by tlog terminal sessions from Journal. + Allows to play them in a player with various controls. Shows correlated logs which happened during session. +

+
+ org.cockpit_project.cockpit + session-recording + https://github.com/Scribery/cockpit-session-recording + https://github.com/Scribery/cockpit-session-recording/issues + cockpit-devel_AT_lists.fedorahosted.org +
diff --git a/org.cockpit-project.starter-kit.metainfo.xml b/org.cockpit-project.starter-kit.metainfo.xml deleted file mode 100644 index ad720d8..0000000 --- a/org.cockpit-project.starter-kit.metainfo.xml +++ /dev/null @@ -1,15 +0,0 @@ - - org.cockpit-project.starter-kit - CC0-1.0 - Starter Kit - - Scaffolding for a cockpit module. - - -

- Scaffolding for a cockpit module. -

-
- cockpit.desktop - cockpit-starter-kit -
diff --git a/package.json b/package.json index ae2f5a8..06e460a 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,67 @@ { - "name": "starter-kit", - "version": "0.1.0", - "description": "Scaffolding for a cockpit module", + "name": "session-recording", + "description": "Module for Cockpit which provides session recording configuration and playback", + "type": "module", "main": "index.js", - "repository": "git@github.com:cockpit/starter-kit.git", + "repository": "git@github.com:Scribery/cockpit-session-recording.git", "author": "", "license": "LGPL-2.1", + "engines": { + "node": ">= 16" + }, "scripts": { - "build": "webpack", - "eslint": "eslint --ext .jsx --ext .es6 src/", - "eslint:fix": "eslint --fix --ext .jsx --ext .es6 src/" + "watch": "ESBUILD_WATCH='true' ./build.js", + "build": "./build.js", + "eslint": "eslint --ext .js --ext .jsx src/", + "eslint:fix": "eslint --fix --ext .js --ext .jsx src/", + "stylelint": "stylelint src/*{.css,scss}", + "stylelint:fix": "stylelint --fix src/*{.css,scss}" }, "devDependencies": { - "@babel/core": "^7.0.0", - "@babel/preset-env": "^7.0.0", - "@babel/preset-react": "^7.0.0", - "babel-eslint": "^9.0.0", - "babel-loader": "^8.0.0", - "chrome-remote-interface": "^0.25.5", - "compression-webpack-plugin": "^1.1.11", - "copy-webpack-plugin": "^4.5.2", - "css-loader": "^0.28.11", - "eslint": "^5.4.0", - "eslint-config-standard": "^11.0.0", - "eslint-config-standard-react": "^6.0.0", - "eslint-loader": "^2.1.0", - "eslint-plugin-flowtype": "^2.50.0", - "eslint-plugin-import": "^2.14.0", - "eslint-plugin-node": "^7.0.1", - "eslint-plugin-promise": "^4.0.0", - "eslint-plugin-react": "^6.9.0", - "eslint-plugin-standard": "^3.1.0", - "extract-text-webpack-plugin": "^4.0.0-beta.0", + "argparse": "^2.0.1", + "chrome-remote-interface": "^0.32.1", + "esbuild": "0.18.6", + "esbuild-plugin-copy": "^2.1.1", + "esbuild-plugin-replace": "^1.3.0", + "esbuild-sass-plugin": "2.10.0", + "esbuild-wasm": "^0.18.6", + "eslint": "^8.13.0", + "eslint-config-standard": "^17.0.0-1", + "eslint-config-standard-jsx": "^11.0.0-1", + "eslint-config-standard-react": "^13.0.0", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.29.4", + "eslint-plugin-react-hooks": "^4.4.0", + "gettext-parser": "2.0.0", "htmlparser": "^1.7.7", "jed": "^1.1.1", - "po2json": "^0.4.5", - "sass-loader": "^7.0.3", + "qunit": "^2.9.3", + "sass": "^1.61.0", "sizzle": "^2.3.3", - "stdio": "^0.2.7", - "webpack": "^4.17.1", - "webpack-cli": "^3.1.0" + "stylelint": "^15.10.1", + "stylelint-config-standard": "^34.0.0", + "stylelint-config-standard-scss": "^10.0.0", + "stylelint-formatter-pretty": "^3.2.0" }, "dependencies": { - "@babel/polyfill": "^7.0.0", - "node-sass": "^4.9.0", - "react": "^16.4.2", - "react-dom": "^16.4.2" + "@patternfly/patternfly": "5.0.4", + "@patternfly/react-core": "5.0.1", + "@patternfly/react-icons": "5.0.1", + "@patternfly/react-styles": "5.0.1", + "@patternfly/react-table": "5.0.1", + "@patternfly/react-tokens": "5.0.1", + "buffer": "^6.0.3", + "comment-json": "^4.2.3", + "date-fns": "^2.29.3", + "ini": "^4.1.0", + "jquery": "^3.6.4", + "react": "18.2.0", + "react-dom": "18.2.0", + "throttle-debounce": "^5.0.0", + "xterm": "5.1.0", + "xterm-addon-canvas": "^0.4.0" } } diff --git a/packaging/cockpit-session-recording.spec.in b/packaging/cockpit-session-recording.spec.in new file mode 100644 index 0000000..4028196 --- /dev/null +++ b/packaging/cockpit-session-recording.spec.in @@ -0,0 +1,82 @@ +Name: cockpit-session-recording +Version: %{VERSION} +Release: 1%{?dist} +Summary: Cockpit Session Recording +License: LGPL-2.1-or-later +URL: https://github.com/Scribery/%{name} +Source: https://github.com/Scribery/%{name}/releases/download/%{version}/%{name}-%{version}.tar.xz + +BuildArch: noarch +BuildRequires: nodejs +BuildRequires: make +BuildRequires: libappstream-glib +BuildRequires: gettext +%if 0%{?rhel} && 0%{?rhel} <= 8 +BuildRequires: libappstream-glib-devel +%endif + +Requires: cockpit-system +Requires: tlog + +%{NPM_PROVIDES} + +%description +Cockpit module providing session recording configuration and playback. +This module allows viewing and playback of journal-stored terminal session +recordings generated by the tlog component. + +%prep +%setup -q -n %{name} + +%build +LINT=0 NODE_ENV=production make + +%install +%make_install PREFIX=/usr +appstream-util validate-relax --nonet %{buildroot}/%{_datadir}/metainfo/* + +%files +%{_datadir}/cockpit/* +%{_datadir}/metainfo/* + +%changelog +* Wed Jan 13 2021 Justin Stephenson - 7-1 +- Release v7 +- Remove bots sudo rm from Makefile +- Use journalctl --utc for Logs view to handle DST +- Add Applications Menu test +- Install cockpit-packagekit in local VM +- Set timezone for Logs Correlation test + +* Mon Oct 12 2020 Justin Stephenson - 6-1 +- Release v6 +- Bump testlib to 229 +- Add binary recording test + +* Wed May 20 2020 Justin Stephenson - 4-1 +- Release v4 +- Update parent id in metainfo file +- Update package manifest +- Fix rpmmacro to resolve correc t path on CentOS7 +- Handle byte-array encoded journal data +- Don't clobber cockpit bots directory +- Move code out of deprecated React lifecycle functions + +* Mon Nov 25 2019 Justin Stephenson - 3-1 +- Release v3 +- Reset Logs View on Player Rewind +- Configuration page UI CSS Improvements + +* Wed Sep 11 2019 Justin Stephenson - 2-1 +- Release 2 +- Optimize performance when playing back flooded output recordings. +- Make Logs View optional rendered with a toggle button. +- Make Logs component a child of Recording component. +- Fix Recording page column sorting in Google Chrome. +- CSS updates for Patternfly 4 compatibility. +- Replace term.js with maintained xterm.js library. +- Fix hostname and username filtering. + +* Thu Apr 4 2019 Kirill Glebov - 1-1 +- Release 1 +- First release. Includes logs correlation, player controls, journal remote support. diff --git a/packit.yaml b/packit.yaml new file mode 100644 index 0000000..30bf483 --- /dev/null +++ b/packit.yaml @@ -0,0 +1,70 @@ +# Enable RPM builds and running integration tests in PRs through https://packit.dev/ +# To use this, enable Packit-as-a-service in GitHub: https://packit.dev/docs/packit-as-a-service/ +# See https://packit.dev/docs/configuration/ for the format of this file +# +upstream_project_url: https://github.com/Scribery/cockpit-session-recording +# enable notification of failed downstream jobs as issues +issue_repository: https://github.com/Scribery/cockpit-session-recording + +specfile_path: cockpit-session-recording.spec +upstream_package_name: cockpit-session-recording +downstream_package_name: cockpit-session-recording +# use the nicely formatted release description from our upstream release, instead of git shortlog +copy_upstream_release_description: true + +srpm_build_deps: + - make + - nodejs-npm + +actions: + post-upstream-clone: + - make cockpit-session-recording.spec + create-archive: make dist + # starter-kit.git has no release tags; your project can drop this once you have a release + get-current-version: make print-version + +jobs: + - job: copr_build + trigger: pull_request + targets: + - fedora-all + - fedora-latest-aarch64 + - centos-stream-8 + - centos-stream-9 + - centos-stream-9-aarch64 + + - job: tests + trigger: pull_request + targets: + - fedora-all + - fedora-latest-aarch64 + - centos-stream-8 + - centos-stream-9 + - centos-stream-9-aarch64 + + # Build releases in COPR: https://packit.dev/docs/configuration/#copr_build + #- job: copr_build + # trigger: release + # owner: your_copr_login + # project: your_copr_project + # preserve_project: True + # targets: + # - fedora-all + # - centos-stream-9-x86_64 + + # Build releases in Fedora: https://packit.dev/docs/configuration/#propose_downstream + - job: propose_downstream + trigger: release + dist_git_branches: + - fedora-all + + - job: koji_build + trigger: commit + dist_git_branches: + - fedora-all + + - job: bodhi_update + trigger: commit + dist_git_branches: + # rawhide updates are created automatically + - fedora-branched diff --git a/plans/all.fmf b/plans/all.fmf new file mode 100644 index 0000000..689393e --- /dev/null +++ b/plans/all.fmf @@ -0,0 +1,10 @@ +summary: + Run all tests +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 diff --git a/po/de.po b/po/de.po index d117b0b..ed65a63 100644 --- a/po/de.po +++ b/po/de.po @@ -4,27 +4,20 @@ msgid "" msgstr "" "Project-Id-Version: starter-kit 1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-08-29 00:14+0200\n" +"POT-Creation-Date: 2022-03-09 16:09+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" -"Language: \n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1\n" -#: src/index.html:20 -msgid "Cockpit Starter Kit" -msgstr "Cockpit Bausatz" - -#: src/app.jsx:42 +#: src/app.jsx:43 msgid "Running on $0" msgstr "Läuft auf $0" -#: src/manifest.json -msgid "Starter Kit" -msgstr "Bausatz" - #: src/app.jsx:29 msgid "Unknown" msgstr "Unbekannt" diff --git a/po/html2po b/po/html2po deleted file mode 100755 index 2c92897..0000000 --- a/po/html2po +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env node - -/* - * Extracts translatable strings from HTML files in the following forms: - * - * String - * String - * String - * - * - * Supports the following Glade compatible forms: - * - * String - * String - * - * Supports the following angular-gettext compatible forms: - * - * String - * Singular - * - * Note that some of the use of the translated may not support all the strings - * depending on the code actually using these strings to translate the HTML. - */ - - -function fatal(message, code) { - console.log((filename || "html2po") + ": " + message); - process.exit(code || 1); -} - -function usage() { - console.log("usage: html2po input output"); - process.exit(2); -} - -var fs, htmlparser, path, stdio; - -try { - fs = require('fs'); - path = require('path'); - htmlparser = require('htmlparser'); - stdio = require('stdio'); -} catch (ex) { - fatal(ex.message, 127); /* missing looks for this */ -} - -var opts = stdio.getopt({ - directory: { key: "d", args: 1, description: "Base directory for input files" }, - output: { key: "o", args: 1, description: "Output file" }, - from: { key: "f", args: 1, description: "File containing list of input files" }, -}); - -if (!opts.from && opts.args.length < 1) { - usage(); -} - -var input = opts.args; -var entries = { }; - -/* Filename being parsed and offset of line number */ -var filename = null; -var offsets = 0; - -/* The HTML parser we're using */ -var handler = new htmlparser.DefaultHandler(function(error, dom) { - if (error) - fatal(error); - else - walk(dom); -}); - -prepare(); - -/* Decide what input files to process */ -function prepare() { - if (opts.from) { - fs.readFile(opts.from, { encoding: "utf-8"}, function(err, data) { - if (err) - fatal(err.message); - input = data.split("\n").filter(function(value) { - return !!value; - }).concat(input); - step(); - }); - } else { - step(); - } -} - -/* Now process each file in turn */ -function step() { - filename = input.shift(); - if (filename === undefined) { - finish(); - return; - } - - /* Qualify the filename if necessary */ - var full = filename; - if (opts.directory) - full = path.join(opts.directory, filename); - - fs.readFile(full, { encoding: "utf-8"}, function(err, data) { - if (err) - fatal(err.message); - - var parser = new htmlparser.Parser(handler, { includeLocation: true }); - parser.parseComplete(data); - step(); - }); -} - -/* Process an array of nodes */ -function walk(children) { - if (!children) - return; - - children.forEach(function(child) { - var line = (child.location || { }).line || 0; - var offset = line - 1; - - /* Scripts get their text processed as HTML */ - if (child.type == 'script' && child.children) { - var parser = new htmlparser.Parser(handler, { includeLocation: true }); - - /* Make note of how far into the outer HTML file we are */ - offsets += offset; - - child.children.forEach(function(node) { - parser.parseChunk(node.raw); - }); - parser.done(); - - offsets -= offset; - - /* Tags get extracted as usual */ - } else if (child.type == 'tag') { - tag(child); - } - }); -} - -/* Process a single loaded tag */ -function tag(node) { - - var tasks, line, entry; - var attrs = node.attribs || { }; - var nest = true; - - /* Extract translate strings */ - if ("translate" in attrs || "translatable" in attrs) { - tasks = (attrs["translate"] || attrs["translatable"] || "yes").split(" "); - - /* Calculate the line location taking into account nested parsing */ - line = (node.location || { })["line"] || 0; - line += offsets; - - entry = { - msgctxt: attrs['translate-context'] || attrs['context'], - msgid_plural: attrs['translate-plural'], - locations: [ filename + ":" + line ] - }; - - /* For each thing listed */ - tasks.forEach(function(task) { - var copy = Object.assign({}, entry); - - /* The element text itself */ - if (task == "yes" || task == "translate") { - copy.msgid = extract(node.children); - nest = false; - - /* An attribute */ - } else if (task) { - copy.msgid = attrs[task]; - } - - if (copy.msgid) - push(copy); - }); - } - - /* Walk through all the children */ - if (nest) - walk(node.children); -} - -/* Push an entry onto the list */ -function push(entry) { - var key = entry.msgid + "\0" + entry.msgid_plural + "\0" + entry.msgctxt; - var prev = entries[key]; - if (prev) { - prev.locations = prev.locations.concat(entry.locations); - } else { - entries[key] = entry; - } -} - -/* Extract the given text */ -function extract(children) { - if (!children) - return null; - - var i, len, node, str = []; - children.forEach(function(node) { - if (node.type == 'tag' && node.children) - str.push(extract(node.children)) - else if (node.type == 'text' && node.data) - str.push(node.data); - }); - - return str.join(""); -} - -/* Escape a string for inclusion in po file */ -function escape(string) { - var bs = string.split('\\').join('\\\\').split('"').join('\\"'); - return bs.split("\n").map(function(line) { - return '"' + line + '"'; - }).join("\n"); -} - -/* Finish by writing out the strings */ -function finish() { - var result = [ - 'msgid ""', - 'msgstr ""', - '"Project-Id-Version: PACKAGE_VERSION\\n"', - '"MIME-Version: 1.0\\n"', - '"Content-Type: text/plain; charset=UTF-8\\n"', - '"Content-Transfer-Encoding: 8bit\\n"', - '"X-Generator: Cockpit html2po\\n"', - '', - ]; - - var msgid, entry; - for (msgid in entries) { - entry = entries[msgid]; - result.push('#: ' + entry.locations.join(" ")); - if (entry.msgctxt) - result.push('msgctxt ' + escape(entry.msgctxt)); - result.push('msgid ' + escape(entry.msgid)); - if (entry.msgid_plural) { - result.push('msgid_plural ' + escape(entry.msgid_plural)); - result.push('msgstr[0] ""'); - result.push('msgstr[1] ""'); - } else { - result.push('msgstr ""'); - } - result.push(''); - } - - var data = result.join('\n'); - if (!opts.output) { - process.stdout.write(data); - process.exit(0); - } else { - fs.writeFile(opts.output, data, function(err) { - if (err) - fatal(err.message); - process.exit(0); - }); - } -} diff --git a/po/manifest2po b/po/manifest2po deleted file mode 100755 index 4d26db3..0000000 --- a/po/manifest2po +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node - -/* - * Extracts translatable strings from manifest.json files. - * - */ - -function fatal(message, code) { - console.log((filename || "manifest2po") + ": " + message); - process.exit(code || 1); -} - -function usage() { - console.log("usage: manifest2po [-o output] input..."); - process.exit(2); -} - -var fs, path, stdio; - -try { - fs = require('fs'); - path = require('path'); - stdio = require('stdio'); -} catch (ex) { - fatal(ex.message, 127); /* missing looks for this */ -} - -var opts = stdio.getopt({ - directory: { key: "d", args: 1, description: "Base directory for input files" }, - output: { key: "o", args: 1, description: "Output file" }, - from: { key: "f", args: 1, description: "File containing list of input files" }, -}); - -if (!opts.from && opts.args.length < 1) { - usage(); -} - -var input = opts.args; -var entries = { }; - -/* Filename being parsed */ -var filename = null; - -prepare(); - -/* Decide what input files to process */ -function prepare() { - if (opts.from) { - fs.readFile(opts.from, { encoding: "utf-8"}, function(err, data) { - if (err) - fatal(err.message); - input = data.split("\n").filter(function(value) { - return !!value; - }).concat(input); - step(); - }); - } else { - step(); - } -} - -/* Now process each file in turn */ -function step() { - filename = input.shift(); - if (filename === undefined) { - finish(); - return; - } - - if (path.basename(filename) != "manifest.json") - return step(); - - fs.readFile(filename, { encoding: "utf-8"}, function(err, data) { - if (err) - fatal(err.message); - - process_manifest(JSON.parse(data)); - - return step(); - }); -} - -function process_manifest(manifest) { - if (manifest.menu) - process_menu(manifest.menu); - if (manifest.tools) - process_menu(manifest.tools); -} - -function process_menu(menu) { - for (var m in menu) { - if (menu[m].label) { - push({ - msgid: menu[m].label, - locations: [ filename ] - }); - } - } -} - -/* Push an entry onto the list */ -function push(entry) { - var key = entry.msgid + "\0" + entry.msgid_plural + "\0" + entry.msgctxt; - var prev = entries[key]; - if (prev) { - prev.locations = prev.locations.concat(entry.locations); - } else { - entries[key] = entry; - } -} - -/* Escape a string for inclusion in po file */ -function escape(string) { - var bs = string.split('\\').join('\\\\').split('"').join('\\"'); - return bs.split("\n").map(function(line) { - return '"' + line + '"'; - }).join("\n"); -} - -/* Finish by writing out the strings */ -function finish() { - var result = [ - 'msgid ""', - 'msgstr ""', - '"Project-Id-Version: PACKAGE_VERSION\\n"', - '"MIME-Version: 1.0\\n"', - '"Content-Type: text/plain; charset=UTF-8\\n"', - '"Content-Transfer-Encoding: 8bit\\n"', - '"X-Generator: Cockpit manifest2po\\n"', - '', - ]; - - var msgid, entry; - for (msgid in entries) { - entry = entries[msgid]; - result.push('#: ' + entry.locations.join(" ")); - if (entry.msgctxt) - result.push('msgctxt ' + escape(entry.msgctxt)); - result.push('msgid ' + escape(entry.msgid)); - if (entry.msgid_plural) { - result.push('msgid_plural ' + escape(entry.msgid_plural)); - result.push('msgstr[0] ""'); - result.push('msgstr[1] ""'); - } else { - result.push('msgstr ""'); - } - result.push(''); - } - - var data = result.join('\n'); - if (!opts.output) { - process.stdout.write(data); - process.exit(0); - } else { - fs.writeFile(opts.output, data, function(err) { - if (err) - fatal(err.message); - process.exit(0); - }); - } -} diff --git a/po/po.empty.js b/po/po.empty.js deleted file mode 100644 index b27dad9..0000000 --- a/po/po.empty.js +++ /dev/null @@ -1,14 +0,0 @@ -(function (root, data) { - var loaded, module; - - /* Load into Cockpit locale */ - if (typeof cockpit === 'object') { - cockpit.locale(data) - loaded = true; - } - - if (!loaded) - root.po = data; - -/* The syntax of this line is important by po2json */ -}(this, {"":{"language":"en"}})); diff --git a/po/po2json b/po/po2json deleted file mode 100755 index d2185a4..0000000 --- a/po/po2json +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node - -function fatal(message, code) { - console.log((filename || "html2po") + ": " + message); - process.exit(code || 1); -} - -function usage() { - console.log("usage: po2json [--module=template.js] input output"); - process.exit(2); -} - -var fs, po2json, Jed, stdio; - -try { - fs = require('fs'); - po2json = require('po2json'); - Jed = require('jed'); - stdio = require('stdio'); -} catch(ex) { - fatal(ex.message, 127); /* missing looks for this */ -} - -var argi = 2; -var filename = null; - -var opts = stdio.getopt({ - module: { key: "m", args: 1, description: "Module template to include" }, - output: { key: "o", args: 1, description: "Output file" }, -}); - -if (opts.args.length != 1) { - usage(); -} - -parse(); - -function prepareHeader(header) { - var body, statement, plurals = header["plural-forms"], ret = null; - if (plurals) { - try { - /* Check that the plural forms isn't being sneaky since we build a function here */ - Jed.PF.parse(plurals); - } catch(ex) { - fatal("bad plural forms: " + ex.message, 1); - } - - /* A function for the front end */ - statement = header["plural-forms"]; - if (statement[statement.length - 1] != ';') - statement += ';'; - ret = 'function(n) {\nvar nplurals, plural;\n' + statement + '\nreturn plural;\n}'; - - /* Added back in later */ - delete header["plural-forms"]; - } - - /* We don't need to be transferring this */ - delete header["project-id-version"]; - delete header["report-msgid-bugs-to"]; - delete header["pot-creation-date"]; - delete header["po-revision-date"]; - delete header["last-translator"]; - delete header["language-team"]; - delete header["mime-version"]; - delete header["content-type"]; - delete header["content-transfer-encoding"]; - - return ret; -} - -/* Parse and process the po data */ -function parse() { - filename = opts.args[0]; - po2json.parseFile(opts.args[0], { "fuzzy": true }, function(err, jsonData) { - var plurals, pos; - - if (err) - fatal(err.message); - - var header = jsonData[""]; - if (header) - plurals = prepareHeader(header); - - var data = JSON.stringify(jsonData, null, 1); - - /* We know the brace in is the location to insert our function */ - if (plurals) { - pos = data.indexOf('{', 1); - data = data.substr(0, pos + 1) + "'plural-forms':" + String(plurals) + "," + data.substr(pos + 1); - } - - if (data == JSON.stringify({})) - finish(""); - else - wrap(data); - }); -} - -/* Wrap the data if desired */ -function wrap(data) { - if (opts.module) { - filename = opts.module; - fs.readFile(opts.module, { encoding: "utf-8" }, function(err, template) { - if (err) - fatal(err.message); - data = template.replace('{"":{"language":"en"}}', data); - finish(data); - }); - } else { - finish(data); - } -} - -/* Write it out */ -function finish(data) { - if (opts.output) { - fs.writeFile(opts.output, data, function(err) { - if (err) - fatal(err.message); - process.exit(0); - }); - } else { - process.stdout.write(data); - process.exit(0); - } -} diff --git a/src/app.jsx b/src/app.jsx index 55f1c88..35db035 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -19,29 +19,23 @@ import cockpit from 'cockpit'; import React from 'react'; -import './app.scss'; +import View from "./recordings.jsx"; const _ = cockpit.gettext; export class Application extends React.Component { constructor() { super(); - this.state = { 'hostname': _("Unknown") }; + this.state = { hostname: _("Unknown") }; - cockpit.file('/etc/hostname').read() - .done((content) => { - this.setState({ 'hostname': content.trim() }); - }); + cockpit.file('/etc/hostname').watch(content => { + this.setState({ hostname: content.trim() }); + }); } render() { return ( -
-

Starter Kit

-

- { cockpit.format(_("Running on $0"), this.state.hostname) } -

-
+ ); } } diff --git a/src/app.scss b/src/app.scss index 87b46f4..3c2f0b0 100644 --- a/src/app.scss +++ b/src/app.scss @@ -1,3 +1,18 @@ +@use "page.scss"; + p { font-weight: bold; } + +// Ensure UI fills the entire page (and does not run over) +.ct-page-fill { + height: 100% !important; +} + +.config-container { + row-gap: var(--pf-global--spacer--sm); + + > .pf-c-card { + min-width: 30rem; + } +} diff --git a/src/config.jsx b/src/config.jsx new file mode 100644 index 0000000..ead9c87 --- /dev/null +++ b/src/config.jsx @@ -0,0 +1,662 @@ +/* + * 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 . + */ + +import React from "react"; +import { + Breadcrumb, BreadcrumbItem, + Button, + Flex, + Form, + FormGroup, + FormSelect, + FormSelectOption, + TextInput, + ActionGroup, + Spinner, + Card, + CardTitle, + CardBody, + Checkbox, + Bullseye, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + EmptyStateVariant, + Page, PageSection, EmptyStateHeader, +} from "@patternfly/react-core"; +import { ExclamationCircleIcon } from "@patternfly/react-icons"; +import { global_danger_color_200 } from "@patternfly/react-tokens"; +import cockpit from 'cockpit'; + +const json = require('comment-json'); +const ini = require('ini'); +const _ = cockpit.gettext; + +class GeneralConfig extends React.Component { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + this.setConfig = this.setConfig.bind(this); + this.fileReadFailed = this.fileReadFailed.bind(this); + this.readConfig = this.readConfig.bind(this); + this.file = null; + this.config = null; + this.state = { + config_loaded: false, + file_error: false, + submitting: false, + shell: "", + notice: "", + latency: "", + payload: "", + log_input: false, + log_output: true, + log_window: true, + limit_rate: "", + limit_burst: "", + limit_action: "", + file_path: "", + syslog_facility: "", + syslog_priority: "", + journal_augment: "", + journal_priority: "", + writer: "", + }; + } + + handleSubmit(event) { + this.setState({ submitting: true }); + const config = { + shell: this.state.shell, + notice: this.state.notice, + latency: parseInt(this.state.latency), + payload: parseInt(this.state.payload), + log: { + input: this.state.log_input, + output: this.state.log_output, + window: this.state.log_window, + }, + limit: { + rate: parseInt(this.state.limit_rate), + burst: parseInt(this.state.limit_burst), + action: this.state.limit_action, + }, + file: { + path: this.state.file_path, + }, + syslog: { + facility: this.state.syslog_facility, + priority: this.state.syslog_priority, + }, + journal: { + priority: this.state.journal_priority, + augment: this.state.journal_augment + }, + writer: this.state.writer + }; + this.file.replace(config).done(() => { + this.setState({ submitting: false }); + }) + .fail((error) => { + console.log(error); + }); + event.preventDefault(); + } + + setConfig(data) { + delete data.configuration; + delete data.args; + const flattenObject = function(ob) { + const toReturn = {}; + + for (const i in ob) { + if (!Object.prototype.hasOwnProperty.call(ob, i)) continue; + + if ((typeof ob[i]) === 'object') { + const flatObject = flattenObject(ob[i]); + for (const x in flatObject) { + if (!Object.prototype.hasOwnProperty.call(flatObject, x)) continue; + + toReturn[i + '_' + x] = flatObject[x]; + } + } else { + toReturn[i] = ob[i]; + } + } + return toReturn; + }; + const state = flattenObject(data); + state.config_loaded = true; + this.setState(state); + } + + getConfig() { + const proc = cockpit.spawn(["tlog-rec-session", "--configuration"]); + + proc.stream((data) => { + this.setConfig(json.parse(data, null, true)); + proc.close(); + }); + + proc.fail((fail) => { + console.log(fail); + this.readConfig(); + }); + } + + readConfig() { + const parseFunc = function(data) { + return json.parse(data, null, true); + }; + + const stringifyFunc = function(data) { + return json.stringify(data, null, true); + }; + // needed for cockpit.file usage + const syntax_object = { + parse: parseFunc, + stringify: stringifyFunc, + }; + + this.file = cockpit.file("/etc/tlog/tlog-rec-session.conf", { + syntax: syntax_object, + superuser: true, + }); + } + + fileReadFailed(reason) { + console.log(reason); + this.setState({ file_error: reason }); + } + + componentDidMount() { + this.getConfig(); + this.readConfig(); + } + + handleInputChange(name, value) { + const state = {}; + state[name] = value; + this.setState(state); + } + + render() { + const form = + (this.state.config_loaded === false && this.state.file_error === false) + ? + : (this.state.config_loaded === true && this.state.file_error === false) + ? ( +
+ + this.handleInputChange("shell", value)} + /> + + + this.handleInputChange("notice", value)} + /> + + + this.handleInputChange("latency", value)} + /> + + + this.handleInputChange("payload", value)} + /> + + + this.setState({ log_input })} + label={_("User's Input")} + /> + this.setState({ log_output })} + label={_("User's Output")} + /> + this.setState({ log_window })} + label={_("Window Resize")} + /> + + + this.handleInputChange("limit_rate", value)} + /> + + + this.handleInputChange("limit_burst", value)} + /> + + + this.handleInputChange("limit_action", value)} + > + {[ + { value: "", label: "" }, + { value: "pass", label: _("Pass") }, + { value: "delay", label: _("Delay") }, + { value: "drop", label: _("Drop") } + ].map((option, index) => + + )} + + + + this.handleInputChange("file_path", value)} + /> + + + this.handleInputChange("syslog_facility", value)} + + /> + + + this.handleInputChange("syslog_priority", value)} + > + {[ + { value: "", label: "" }, + { value: "info", label: _("Info") }, + ].map((option, index) => + + )} + + + + this.handleInputChange("journal_priority", value)} + + > + {[ + { value: "", label: "" }, + { value: "info", label: _("Info") }, + ].map((option, index) => + + )} + + + + this.setState({ journal_augment })} + label={_("Augment")} + /> + + + this.handleInputChange("writer", value)} + > + {[ + { value: "", label: "" }, + { value: "journal", label: _("Journal") }, + { value: "syslog", label: _("Syslog") }, + { value: "file", label: _("File") }, + ].map((option, index) => + + )} + + + + + {this.state.submitting === true && } + +
+ ) + : ( + + + {_("There is no configuration file of tlog present in your system.")}} + icon={ + + } headingLevel="h4" + /> + {_("Please, check the /etc/tlog/tlog-rec-session.conf or if tlog is installed.")}} headingLevel="h4" /> + + {this.state.file_error} + + + + ); + + return ( + + General Config + {form} + + ); + } +} + +class SssdConfig extends React.Component { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + this.confSave = this.confSave.bind(this); + this.restartSSSD = this.restartSSSD.bind(this); + this.file = null; + this.state = { + scope: "", + users: "", + exclude_users: "", + exclude_groups: "", + groups: "", + submitting: false, + }; + } + + customIniUnparser(obj) { + return ini.stringify(obj, { platform: 'linux' }).replace('domainnssfiles', 'domain/nssfiles'); + } + + restartSSSD() { + const sssd_cmd = ["systemctl", "restart", "sssd"]; + cockpit.spawn(sssd_cmd, { superuser: "require" }); + this.setState({ submitting: false }); + } + + confSave(obj) { + const chmod_cmd = ["chmod", "600", "/etc/sssd/conf.d/sssd-session-recording.conf"]; + /* Update nsswitch, this will fail on RHEL8/F34 and lower as 'with-files-domain' feature is not added there */ + const authselect_cmd = ["authselect", "select", "sssd", "with-files-domain", "--force"]; + this.setState({ submitting: true }); + this.file.replace(obj) + .then(tag => { + cockpit.spawn(chmod_cmd, { superuser: "require" }) + .then(() => { + cockpit.spawn(authselect_cmd, { superuser: "require" }) + .then(this.restartSSSD) + .catch(this.restartSSSD); + }); + }) + .catch(error => { + console.error(error); + }); + } + + componentDidMount() { + const syntax_object = { + parse: ini.parse, + stringify: this.customIniUnparser, + }; + + this.file = cockpit.file("/etc/sssd/conf.d/sssd-session-recording.conf", { + syntax: syntax_object, + superuser: true, + }); + + const conf_syntax_object = { + parse: ini.parse, + }; + + this.sssdconf = cockpit.file("/etc/sssd/sssd.conf", { + syntax: conf_syntax_object, + superuser: true, + }); + + const promise = this.file.read(); + const sssdconfpromise = this.sssdconf.read(); + + promise.fail(function(error) { + console.log(error); + }); + + /* It is not an error when the file does not exist, then() callback will + * be called with a null value for content and tag is "-" */ + sssdconfpromise + .then((content, tag) => { + if (content !== null) { + this.existingServices = content.sssd.services; + this.existingDomains = content.sssd.domains; + } + }) + .catch(error => { + console.log("Error: " + error); + }); + } + + handleSubmit(e) { + const obj = {}; + /* SSSD section */ + obj.sssd = {}; + /* Avoid overwriting services and domain sections of existing sssd.conf + * Copy the services section used in sssd.conf, and append 'proxy' to + * existing domain section */ + if (this.existingServices) { + obj.sssd.services = this.existingServices; + } else { + obj.sssd.services = "nss, pam"; + } + + if (this.existingDomains) { + obj.sssd.domains = this.existingDomains + ", nssfiles"; + } else { + obj.sssd.domains = "nssfiles"; + } + /* Proxy provider */ + obj.domainnssfiles = {}; /* Unparser converts this into domain/nssfiles */ + obj.domainnssfiles.id_provider = "proxy"; + obj.domainnssfiles.proxy_lib_name = "files"; + obj.domainnssfiles.proxy_pam_target = "sssd-shadowutils"; + /* Session recording */ + obj.session_recording = {}; + obj.session_recording.scope = this.state.scope; + switch (this.state.scope) { + case "all": + obj.session_recording.exclude_users = this.state.exclude_users; + obj.session_recording.exclude_groups = this.state.exclude_groups; + break; + case "none": + break; + case "some": + obj.session_recording.users = this.state.users; + obj.session_recording.groups = this.state.groups; + break; + default: + break; + } + this.confSave(obj); + e.preventDefault(); + } + + handleInputChange(name, value) { + const state = {}; + state[name] = value; + this.setState(state); + } + + render() { + const form = ( +
+ + this.handleInputChange("scope", value)} + > + {[ + { value: "none", label: _("None") }, + { value: "some", label: _("Some") }, + { value: "all", label: _("All") } + ].map((option, index) => + + )} + + + {this.state.scope === "some" && + <> + + this.handleInputChange("users", value)} + /> + + + this.handleInputChange("groups", value)} + /> + + } + {this.state.scope === "all" && + <> + + this.handleInputChange("exclude_users", value)} + /> + + + this.handleInputChange("exclude_groups", value)} + /> + + } + + + {this.state.submitting === true && } + +
+ ); + + return ( + + SSSD Config + {form} + + ); + } +} + +export function Config () { + const goBack = () => { + cockpit.location.go("/"); + }; + + return ( + + + {_("Session Recording")} + + + {_("Settings")} + + + } + > + + + + + + + + ); +} diff --git a/src/index.html b/src/index.html index 5182d9b..57b6003 100644 --- a/src/index.html +++ b/src/index.html @@ -17,17 +17,15 @@ along with this package; If not, see . --> - Cockpit Starter Kit + Cockpit Session Recording - - - + diff --git a/src/index.es6 b/src/index.js similarity index 74% rename from src/index.es6 rename to src/index.js index cb3ca05..ff4aa0d 100644 --- a/src/index.es6 +++ b/src/index.js @@ -17,10 +17,14 @@ * along with Cockpit; If not, see . */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Application } from './app.jsx'; +import "cockpit-dark-theme"; +import "patternfly/patternfly-5-cockpit.scss"; -document.addEventListener("DOMContentLoaded", function () { - ReactDOM.render(React.createElement(Application, {}), document.getElementById('app')); +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Application } from './app.jsx'; +import './app.scss'; + +document.addEventListener("DOMContentLoaded", () => { + createRoot(document.getElementById("app")).render(); }); diff --git a/src/manifest.json b/src/manifest.json index 3e2454a..b52d873 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,12 +1,26 @@ { - "version": "0.1", + "version": "163.x", + "name": "session-recording", + "requires": { - "cockpit": "137" + "cockpit": "137" }, - "tools": { + "menu": { "index": { - "label": "Starter Kit" + "label": "Session Recording", + "order": 110, + "docs": [ + { + "label": "Recording sessions", + "url": "https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/recording_sessions/index" + } + ], + "keywords": [ + { + "matches": ["tlog", "sssd"] + } + ] } } } diff --git a/src/player.css b/src/player.css new file mode 100644 index 0000000..d2581d4 --- /dev/null +++ b/src/player.css @@ -0,0 +1,84 @@ +@import "xterm/css/xterm.css"; + +.player-wrap { + min-width: 672px; + height: auto; + overflow: hidden; +} + +.player-wrap .panel-body, .player-wrap .console-ct > .terminal { + padding: 0; +} + +.dragnpan { + -webkit-touch-callout: none; + user-select: none; +} + +#logs-view { + height: 300px; + overflow-y: scroll; + margin-bottom: 0; +} + +#recording-wrap { + height: 100%; +} + +.logs-view-log-time { + display: inline-block; + width: 150px; + vertical-align: middle; +} + +#input-textarea { + width: 100%; + height: 100%; + font-family: monospace; + resize: none; +} + +#input-player-wrap { + margin-top: 5px; +} + +.panel-footer { + padding: 5px 15px; +} + +.search-result { + margin-left: 5px; + float: left; +} + +.search-results { + float: left; + min-height: 25px; +} + +.search-component { + float: left; + width: 33%; +} + +.search-wrap { + min-height: 25px; + display: block; + clear: both; +} + +.session_time { + margin-right: 5px; +} + +.pf-c-progress__indicator::after { + content: ""; + position: relative; + width: 20px; + height: 20px; + border-radius: 10px; + background-color: var(--pf-c-progress__indicator--BackgroundColor); + top: -2px; + left: 10px; + float: right; +} diff --git a/src/player.jsx b/src/player.jsx new file mode 100644 index 0000000..ab28823 --- /dev/null +++ b/src/player.jsx @@ -0,0 +1,1509 @@ +/* +* 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 . +*/ +import React from 'react'; +import './player.css'; +import { Terminal as Term } from 'xterm'; +import { CanvasAddon } from 'xterm-addon-canvas'; +import { + Alert, + AlertGroup, + Button, + Chip, + ChipGroup, + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + ExpandableSection, + InputGroup, + Progress, + TextInput, + Toolbar, + ToolbarContent, + ToolbarItem, + ToolbarGroup, InputGroupItem, +} from '@patternfly/react-core'; +import { + ArrowRightIcon, + ExpandIcon, + PauseIcon, + PlayIcon, + RedoIcon, + SearchMinusIcon, + SearchPlusIcon, + SearchIcon, + MinusIcon, + UndoIcon, + ThumbtackIcon, + MigrationIcon, +} from '@patternfly/react-icons'; + +import cockpit from 'cockpit'; +import { journal } from 'journal'; + +const _ = cockpit.gettext; +const $ = require("jquery"); + +const padInt = function (n, w) { + const i = Math.floor(n); + const a = Math.abs(i); + let s = a.toString(); + for (w -= s.length; w > 0; w--) { + s = '0' + s; + } + return ((i < 0) ? '-' : '') + s; +}; + +/* + * Format date and time for a number of milliseconds since Epoch. + * YYYY-MM-DD HH:mm:ss + */ +const formatDateTime = function (ms) { + /* Convert local timezone offset */ + const t = new Date(ms); + const z = t.getTimezoneOffset() * 60 * 1000; + let tLocal = t - z; + tLocal = new Date(tLocal); + let iso = tLocal.toISOString(); + + /* cleanup ISO format */ + iso = iso.slice(0, 19); + iso = iso.replace('T', ' '); + return iso; +}; + +/* + * Format a time interval from a number of milliseconds. + */ +const formatDuration = function (ms) { + let v = Math.floor(ms / 1000); + const s = Math.floor(v % 60); + v = Math.floor(v / 60); + const m = Math.floor(v % 60); + v = Math.floor(v / 60); + const h = Math.floor(v % 24); + const d = Math.floor(v / 24); + let str = ''; + + if (d > 0) { + str += d + ' ' + _("days") + ' '; + } + + if (h > 0 || str.length > 0) { + str += padInt(h, 2) + ':'; + } + + str += padInt(m, 2) + ':' + padInt(s, 2); + + return (ms < 0 ? '-' : '') + str; +}; + +const scrollToBottom = function(id) { + const el = document.getElementById(id); + if (el) { + el.scrollTop = el.scrollHeight; + } +}; + +function ErrorList(props) { + let list = []; + + if (props.list) { + list = props.list.map((message, key) => { return }); + } + + return ( + + {list} + + ); +} + +function ErrorItem(props) { + return ( + + {props.message} + + ); +} + +const ErrorService = class { + constructor() { + this.addMessage = this.addMessage.bind(this); + this.errors = []; + } + + addMessage(message) { + if (typeof message === "object" && message !== null) { + if ("toString" in message) { + message = message.toString(); + } else { + message = _("unknown error"); + } + } + if (typeof message === "string" || message instanceof String) { + if (this.errors.indexOf(message) === -1) { + this.errors.push(message); + } + } + } +}; + +/* + * An auto-loading buffer of recording's packets. + */ +const PacketBuffer = class { + /* + * Initialize a buffer. + */ + constructor(matchList, reportError) { + this.handleError = this.handleError.bind(this); + this.handleStream = this.handleStream.bind(this); + this.handleDone = this.handleDone.bind(this); + this.getValidField = this.getValidField.bind(this); + /* RegExp used to parse message's timing field */ + this.timingRE = new RegExp( + /* Delay (1) */ + "\\+(\\d+)|" + + /* Text input (2) */ + "<(\\d+)|" + + /* Binary input (3, 4) */ + "\\[(\\d+)/(\\d+)|" + + /* Text output (5) */ + ">(\\d+)|" + + /* Binary output (6, 7) */ + "\\](\\d+)/(\\d+)|" + + /* Window (8, 9) */ + "=(\\d+)x(\\d+)|" + + /* End of string */ + "$", + /* Continue after the last match only */ + /* FIXME Support likely sparse */ + "y" + ); + /* List of matches to apply when loading the buffer from Journal */ + this.matchList = matchList; + this.reportError = reportError; + /* + * An array of two-element arrays (tuples) each containing a + * packet index and a deferred object. The list is kept sorted to + * have tuples with lower packet indices first. Once the buffer + * receives a packet at the specified index, the matching tuple is + * removed from the list, and its deferred object is resolved. + * This is used to keep users informed about packets arriving. + */ + this.idxDfdList = []; + /* Last seen message ID */ + this.id = 0; + /* Last seen time position */ + this.pos = 0; + /* Last seen window width */ + this.width = null; + /* Last seen window height */ + this.height = null; + /* List of packets read */ + this.pktList = []; + /* Error which stopped the loading */ + this.error = null; + /* The journalctl reading the recording */ + this.journalctl = journal.journalctl( + this.matchList, + { count: "all", follow: false, merge: true }); + this.journalctl.fail(this.handleError); + this.journalctl.stream(this.handleStream); + this.journalctl.done(this.handleDone); + /* + * Last seen cursor of the first, non-follow, journalctl run. + * Null if no entry was received yet, or the second run has + * skipped the entry received last by the first run. + */ + this.cursor = null; + /* True if the first, non-follow, journalctl run has completed */ + this.done = false; + } + + /* + * Get an object field, verifying its presence and type. + */ + getValidField(object, field, type) { + if (!(field in object)) { + this.reportError("\"" + field + "\" field is missing"); + } + const value = object[field]; + if (typeof (value) !== typeof (type)) { + this.reportError("invalid \"" + field + "\" field type: " + typeof (value)); + } + return value; + } + + /* + * Return a promise which is resolved when a packet at a particular + * index is received by the buffer. The promise is rejected with a + * non-null argument if an error occurs or has occurred previously. + * The promise is rejected with null, when the buffer is stopped. If + * the packet index is not specified, assume it's the next packet. + */ + awaitPacket(idx) { + let i; + let idxDfd; + + /* If an error has occurred previously */ + if (this.error !== null) { + /* Reject immediately */ + return $.Deferred().reject(this.error) + .promise(); + } + + /* If the buffer was stopped */ + if (this.journalctl === null) { + return $.Deferred().reject(null) + .promise(); + } + + /* If packet index is not specified */ + if (idx === undefined) { + /* Assume it's the next one */ + idx = this.pktList.length; + } else { + /* If it has already been received */ + if (idx < this.pktList.length) { + /* Return resolved promise */ + return $.Deferred().resolve() + .promise(); + } + } + + /* Try to find an existing, matching tuple */ + for (i = 0; i < this.idxDfdList.length; i++) { + idxDfd = this.idxDfdList[i]; + if (idxDfd[0] === idx) { + return idxDfd[1].promise(); + } else if (idxDfd[0] > idx) { + break; + } + } + + /* Not found, create and insert a new tuple */ + idxDfd = [idx, $.Deferred()]; + this.idxDfdList.splice(i, 0, idxDfd); + + /* Return its promise */ + return idxDfd[1].promise(); + } + + /* + * Return true if the buffer was done loading everything logged to + * journal so far and is now waiting for and loading new entries. + * Return false if the buffer is loading existing entries so far. + */ + isDone() { + return this.done; + } + + /* + * Stop receiving the entries + */ + stop() { + if (this.journalctl === null) { + return; + } + /* Destroy journalctl */ + this.journalctl.stop(); + this.journalctl = null; + /* Notify everyone we stopped */ + for (let i = 0; i < this.idxDfdList.length; i++) { + this.idxDfdList[i][1].reject(null); + } + this.idxDfdList = []; + } + + /* + * Add a packet to the received packet list. + */ + addPacket(pkt) { + /* TODO Validate the packet */ + /* Add the packet */ + this.pktList.push(pkt); + /* Notify any matching listeners */ + while (this.idxDfdList.length > 0) { + const idxDfd = this.idxDfdList[0]; + if (idxDfd[0] < this.pktList.length) { + this.idxDfdList.shift(); + idxDfd[1].resolve(); + } else { + break; + } + } + } + + /* + * Handle an error. + */ + handleError(error) { + /* Remember the error */ + this.error = error; + /* Destroy journalctl, don't try to recover */ + if (this.journalctl !== null) { + this.journalctl.stop(); + this.journalctl = null; + } + /* Notify everyone we had an error */ + for (let i = 0; i < this.idxDfdList.length; i++) { + this.idxDfdList[i][1].reject(error); + } + this.idxDfdList = []; + this.reportError(error); + } + + /* + * Parse packets out of a tlog message data and add them to the buffer. + */ + parseMessageData(timing, in_txt, out_txt) { + let matches; + let in_txt_pos = 0; + let out_txt_pos = 0; + let t; + let x; + let y; + let s; + let io = []; + let is_output; + + /* While matching entries in timing */ + this.timingRE.lastIndex = 0; + for (;;) { + /* Match next timing entry */ + matches = this.timingRE.exec(timing); + if (matches === null) { + this.reportError(_("invalid timing string")); + } else if (matches[0] === "") { + break; + } + + /* Switch on entry type character */ + switch (t = matches[0][0]) { + /* Delay */ + case "+": + x = parseInt(matches[1], 10); + if (x === 0) { + break; + } + if (io.length > 0) { + this.addPacket({ + pos: this.pos, + is_io: true, + is_output, + io: io.join() + }); + io = []; + } + this.pos += x; + break; + /* Text or binary input */ + case "<": + case "[": + x = parseInt(matches[(t === "<") ? 2 : 3], 10); + if (x === 0) { + break; + } + if (io.length > 0 && is_output) { + this.addPacket({ + pos: this.pos, + is_io: true, + is_output, + io: io.join() + }); + io = []; + } + is_output = false; + /* Add (replacement) input characters */ + s = in_txt.slice(in_txt_pos, in_txt_pos += x); + if (s.length !== x) { + this.reportError(_("timing entry out of input bounds")); + } + io.push(s); + break; + /* Text or binary output */ + case ">": + case "]": + x = parseInt(matches[(t === ">") ? 5 : 6], 10); + if (x === 0) { + break; + } + if (io.length > 0 && !is_output) { + this.addPacket({ + pos: this.pos, + is_io: true, + is_output, + io: io.join() + }); + io = []; + } + is_output = true; + /* Add (replacement) output characters */ + s = out_txt.slice(out_txt_pos, out_txt_pos += x); + if (s.length !== x) { + this.reportError(_("timing entry out of output bounds")); + } + io.push(s); + break; + /* Window */ + case "=": + x = parseInt(matches[8], 10); + y = parseInt(matches[9], 10); + if (x === this.width && y === this.height) { + break; + } + if (io.length > 0) { + this.addPacket({ + pos: this.pos, + is_io: true, + is_output, + io: io.join() + }); + io = []; + } + this.addPacket({ + pos: this.pos, + is_io: false, + width: x, + height: y + }); + this.width = x; + this.height = y; + break; + default: + // continue + break; + } + } + + if (in_txt_pos < [...in_txt].length) { + this.reportError(_("extra input present")); + } + if (out_txt_pos < [...out_txt].length) { + this.reportError(_("extra output present")); + } + + if (io.length > 0) { + this.addPacket({ + pos: this.pos, + is_io: true, + is_output, + io: io.join() + }); + } + } + + /* + * Parse packets out of a tlog message and add them to the buffer. + */ + parseMessage(message) { + const number = Number(); + const string = String(); + + /* Check version */ + const ver = this.getValidField(message, "ver", string); + const matches = ver.match("^(\\d+)\\.(\\d+)$"); + if (matches === null || matches[1] > 2) { + this.reportError("\"ver\" field has invalid value: " + ver); + } + + /* TODO Perhaps check host, rec, user, term, and session fields */ + + /* Extract message ID */ + const id = this.getValidField(message, "id", number); + if (id <= this.id) { + this.reportError("out of order \"id\" field value: " + id); + } + + /* Extract message time position */ + const pos = this.getValidField(message, "pos", number); + if (pos < this.message_pos) { + this.reportError("out of order \"pos\" field value: " + pos); + } + + /* Update last received message ID and time position */ + this.id = id; + this.pos = pos; + + /* Parse message data */ + this.parseMessageData( + this.getValidField(message, "timing", string), + this.getValidField(message, "in_txt", string), + this.getValidField(message, "out_txt", string)); + } + + /* + * Handle journalctl "stream" event. + */ + handleStream(entryList) { + let i; + let e; + for (i = 0; i < entryList.length; i++) { + e = entryList[i]; + /* If this is the second, "follow", run */ + if (this.done) { + /* Skip the last entry we added on the first run */ + if (this.cursor !== null) { + this.cursor = null; + continue; + } + } else { + if (!('__CURSOR' in e)) { + this.handleError("No cursor in a Journal entry"); + } + this.cursor = e.__CURSOR; + } + /* TODO Refer to entry number/cursor in errors */ + if (!('MESSAGE' in e)) { + this.handleError("No message in Journal entry"); + } + /* Parse the entry message */ + try { + const utf8decoder = new TextDecoder(); + + /* Journalctl stores fields with non-printable characters + * in an array of raw bytes formatted as unsigned + * integers */ + if (Array.isArray(e.MESSAGE)) { + const u8arr = new Uint8Array(e.MESSAGE); + this.parseMessage(JSON.parse(utf8decoder.decode(u8arr))); + } else { + this.parseMessage(JSON.parse(e.MESSAGE)); + } + } catch (error) { + this.handleError(error); + return; + } + } + } + + /* + * Handle journalctl "done" event. + */ + handleDone() { + this.done = true; + if (this.journalctl !== null) { + this.journalctl.stop(); + this.journalctl = null; + } + /* Continue with the "following" run */ + this.journalctl = journal.journalctl( + this.matchList, + { + cursor: this.cursor, follow: true, merge: true, count: "all" + }); + this.journalctl.fail(this.handleError); + this.journalctl.stream(this.handleStream); + /* NOTE: no "done" handler on purpose */ + } +}; + +function SearchEntry(props) { + return ( + props.fastForwardToTS(props.pos, e)}>{formatDuration(props.pos)} + ); +} + +class Search extends React.Component { + constructor(props) { + super(props); + this.handleInputChange = this.handleInputChange.bind(this); + this.handleStream = this.handleStream.bind(this); + this.handleError = this.handleError.bind(this); + this.handleSearchSubmit = this.handleSearchSubmit.bind(this); + this.handleClearSearchResults = this.handleClearSearchResults.bind(this); + + this.state = { + search: cockpit.location.options.search_rec || cockpit.location.options.search || "", + }; + } + + handleInputChange(name, value) { + const state = {}; + state[name] = value; + this.setState(state); + cockpit.location.go(cockpit.location.path[0], $.extend(cockpit.location.options, { search_rec: value })); + } + + handleSearchSubmit() { + this.journalctl = journal.journalctl( + this.props.matchList, + { count: "all", follow: false, merge: true, grep: this.state.search }); + this.journalctl.fail(this.handleError); + this.journalctl.stream(this.handleStream); + } + + handleStream(data) { + let items = data.map(item => { + return JSON.parse(item.MESSAGE); + }); + items = items.map(item => { + return ( + + ); + }); + this.setState({ items }); + } + + handleError(data) { + this.props.errorService.addMessage(data); + } + + handleClearSearchResults() { + delete cockpit.location.options.search; + cockpit.location.go(cockpit.location.path[0], cockpit.location.options); + this.setState({ search: "" }); + this.handleStream([]); + } + + componentDidMount() { + if (this.state.search) { + this.handleSearchSubmit(); + } + } + + render() { + return ( + + + + this.handleInputChange("search", value)} + /> + + + + + + + + + + {this.state.items} + + + ); + } +} + +class InputPlayer extends React.Component { + render() { + const input = String(this.props.input).replace(/(?:\r\n|\r|\n)/g, " "); + + return ( +