diff --git a/.cirrus.yml b/.cirrus.yml deleted file mode 100644 index 8d81802..0000000 --- a/.cirrus.yml +++ /dev/null @@ -1,22 +0,0 @@ -container: - # official cockpit CI container, with cockpit related build and test dependencies - # if you want to use your own, see the documentation about required packages: - # https://github.com/cockpit-project/cockpit/blob/main/HACKING.md#getting-the-development-dependencies - image: ghcr.io/cockpit-project/tasks - kvm: true - # increase this if you have many tests that benefit from parallelism - cpu: 1 - -test_task: - env: - matrix: - - TEST_OS: fedora-42 - - TEST_OS: centos-9-stream - - fix_kvm_script: sudo chmod 666 /dev/kvm - - # test PO template generation - pot_build_script: make po/starter-kit.pot - - # chromium has too little /dev/shm, and we can't make that bigger - check_script: TEST_BROWSER=firefox TEST_JOBS=$(nproc) TEST_OS=$TEST_OS make check diff --git a/.cockpit-ci/container b/.cockpit-ci/container deleted file mode 100644 index c6a7065..0000000 --- a/.cockpit-ci/container +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/cockpit-project/tasks:2025-07-26 diff --git a/.eslintrc.json b/.eslintrc.json index d499b3e..1918f1a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,7 @@ "ecmaVersion": "2022", "sourceType": "module" }, - "plugins": ["react", "react-hooks"], + "plugins": ["flowtype", "react", "react-hooks"], "rules": { "indent": ["error", 4, { @@ -37,25 +37,12 @@ "quotes": "off", "react/jsx-curly-spacing": "off", "react/jsx-indent-props": "off", - "react/jsx-no-useless-fragment": "error", "react/prop-types": "off", "space-before-function-paren": "off", "standard/no-callback-literal": "off" }, "globals": { - "require": "readonly", - "module": "readonly" - }, - "overrides": [ - { - "files": ["**/*.ts", "**/*.tsx"], - "plugins": [ - "@typescript-eslint" - ], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": ["./tsconfig.json"] - } - }] + "require": false, + "module": false + } } diff --git a/.flake8 b/.flake8 deleted file mode 100644 index cf4c387..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 118 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index ccce5b3..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,51 +0,0 @@ -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 - groups: - eslint: - patterns: - - "eslint*" - esbuild: - patterns: - - "esbuild*" - patternfly: - patterns: - - "@patternfly*" - react: - patterns: - - "react*" - stylelint: - patterns: - - "stylelint*" - types: - patterns: - - "@types*" - - "types*" - - ignore: - # https://github.com/cockpit-project/cockpit/issues/21151 - - dependency-name: "sass" - versions: [">=1.80.0", "2.x"] - - # needs to be done in Cockpit first - - dependency-name: "@patternfly/*" - update-types: ["version-update:semver-major"] - - # PF5 requires React 18 - - dependency-name: "*react*" - update-types: ["version-update:semver-major"] - - - package-ecosystem: "github-actions" - directory: "/" - open-pull-requests-limit: 3 - labels: - - "no-test" - schedule: - interval: "weekly" diff --git a/.github/workflows/cockpit-lib-update.yml b/.github/workflows/cockpit-lib-update.yml deleted file mode 100644 index d3dfa70..0000000 --- a/.github/workflows/cockpit-lib-update.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: cockpit-lib-update -on: - schedule: - - cron: '0 2 * * 4' - # can be run manually on https://github.com/cockpit-project/starter-kit/actions - workflow_dispatch: -jobs: - cockpit-lib-update: - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: write - steps: - - name: Set up dependencies - run: | - sudo apt update - sudo apt install -y make - - - name: Set up configuration and secrets - run: | - printf '[user]\n\tname = Cockpit Project\n\temail=cockpituous@gmail.com\n' > ~/.gitconfig - echo '${{ secrets.GITHUB_TOKEN }}' > ~/.config/github-token - - - name: Clone repository - uses: actions/checkout@v4 - - - name: Run cockpit-lib-update - run: | - make bots - bots/cockpit-lib-update diff --git a/.github/workflows/release.yml.disabled b/.github/workflows/release.yml similarity index 62% rename from .github/workflows/release.yml.disabled rename to .github/workflows/release.yml index 161b184..5a05625 100644 --- a/.github/workflows/release.yml.disabled +++ b/.github/workflows/release.yml @@ -1,5 +1,4 @@ -# Create a GitHub upstream release. Replace "TARNAME" with your project tarball -# name and enable this by dropping the ".disabled" suffix from the file name. +# Create a GitHub upstream release # See README.md. name: release on: @@ -11,20 +10,20 @@ jobs: source: runs-on: ubuntu-latest container: - image: ghcr.io/cockpit-project/tasks:latest + image: ghcr.io/cockpit-project/unit-tests options: --user root permissions: # create GitHub release contents: write steps: - name: Clone repository - uses: actions/checkout@v4 + 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/ + 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) @@ -33,6 +32,6 @@ jobs: run: make dist - name: Publish GitHub release - uses: cockpit-project/action-release@7d2e2657382e8d34f88a24b5987f2b81ea165785 + uses: cockpit-project/action-release@88d994da62d1451c7073e26748c18413fcdf46e9 with: - filename: "TARNAME-${{ github.ref_name }}.tar.xz" + filename: "cockpit-session-recording-${{ github.ref_name }}.tar.xz" diff --git a/.github/workflows/tasks-container-update.yml b/.github/workflows/tasks-container-update.yml deleted file mode 100644 index f572569..0000000 --- a/.github/workflows/tasks-container-update.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: tasks-container-update -on: - schedule: - - cron: '0 2 * * 1' - # can be run manually on https://github.com/cockpit-project/starter-kit/actions - workflow_dispatch: -jobs: - tasks-container-update: - 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 - - # https://github.blog/2022-04-12-git-security-vulnerability-announced/ - - name: Pacify git's permission check - run: git config --global --add safe.directory /__w/starter-kit/starter-kit - - - name: Run tasks-container-update - run: | - make bots - bots/tasks-container-update diff --git a/.gitignore b/.gitignore index a439cd4..aa13142 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,19 @@ -# 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 +*~ +*.retry +*.tar.xz *.rpm - -# toplevel (/...) -/Test*.html -/Test*.json -/Test*.log -/Test*.log.gz -/Test*.png -/*.whl +node_modules/ +dist/ +/*.spec +/.vagrant +package-lock.json +Test*FAIL* /bots -/cockpit-*.tar.xz -/cockpit-navigator.spec -/dist/ -/package-lock.json -/pkg/ -/node_modules/ -/tmp/ -/tools/ - -# subdirs (/subdir/...) -/packaging/arch/PKGBUILD -/packaging/debian/changelog -/po/*.pot - /po/LINGUAS -/test/common/ -/test/images/ -/test/static-code +test/common/ +test/images/ +pkg +*.pot +POTFILES* +tmp/ +/po/LINGUAS +/tools diff --git a/.stylelintrc.json b/.stylelintrc.json index 5ba11dc..352fbe6 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,7 +1,11 @@ { "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, @@ -15,13 +19,13 @@ "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/load-partial-extension": null, - "scss/at-import-no-partial-leading-underscore": null, - "scss/load-no-partial-leading-underscore": true, + "scss/at-import-partial-extension": null, "scss/at-mixin-pattern": null, "scss/comment-no-empty": null, "scss/dollar-variable-pattern": null, diff --git a/Makefile b/Makefile index 06baee1..8d8c499 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,14 @@ PACKAGE_NAME := $(shell awk '/"name":/ {gsub(/[",]/, "", $$2); print $$2}' packa RPM_NAME := cockpit-$(PACKAGE_NAME) VERSION := $(shell T=$$(git describe 2>/dev/null) || T=1; echo $$T | tr '-' '.') ifeq ($(TEST_OS),) -TEST_OS = centos-9-stream +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.$(subst -,_,$(PACKAGE_NAME)).metainfo.xml +APPSTREAMFILE=org.cockpit-project.$(PACKAGE_NAME).metainfo.xml VM_IMAGE=$(CURDIR)/test/images/$(TEST_OS) # stamp file to check for node_modules/ NODE_MODULES_TEST=package-lock.json @@ -32,7 +32,7 @@ COCKPIT_REPO_FILES = \ $(NULL) COCKPIT_REPO_URL = https://github.com/cockpit-project/cockpit.git -COCKPIT_REPO_COMMIT = 8076a6044ea41f378547d04e9f539a77f63191dc # 343 + 1 commits +COCKPIT_REPO_COMMIT = 9c73bec7e1dc2395a00aa0c510fd7210b6c96a16 # 300.1 + 42 commits $(COCKPIT_REPO_FILES): $(COCKPIT_REPO_STAMP) COCKPIT_REPO_TREE = '$(strip $(COCKPIT_REPO_COMMIT))^{tree}' @@ -48,21 +48,19 @@ $(COCKPIT_REPO_STAMP): Makefile LINGUAS=$(basename $(notdir $(wildcard po/*.po))) po/$(PACKAGE_NAME).js.pot: - xgettext --default-domain=$(PACKAGE_NAME) --output=- --language=C --keyword= \ - --add-comments=Translators: \ + 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 '*.[jt]s' -o -name '*.[jt]sx') | \ - sed '/^#/ s/, c-format//' > $@ + --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 -o $@ $$(find src -name '*.html') + pkg/lib/html2po.js -o $@ $$(find src -name '*.html') -po/$(PACKAGE_NAME).manifest.pot: $(COCKPIT_REPO_STAMP) - pkg/lib/manifest2po -o $@ src/manifest.json +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=$@ $< @@ -81,9 +79,6 @@ $(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)/' $< > $@ - $(DIST_TEST): $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) $(shell find src/ -type f) package.json build.js NODE_ENV=$(NODE_ENV) ./build.js @@ -92,7 +87,7 @@ watch: $(NODE_MODULES_TEST) $(COCKPIT_REPO_STAMP) clean: rm -rf dist/ - rm -f $(SPEC) packaging/arch/PKGBUILD + rm -f $(SPEC) rm -f po/LINGUAS install: $(DIST_TEST) po/LINGUAS @@ -124,12 +119,11 @@ dist: $(TARFILE) # 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 +$(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) packaging/arch/PKGBUILD dist/ + $$(git ls-files) $(COCKPIT_REPO_FILES) $(NODE_MODULES_TEST) $(SPEC) dist/ $(NODE_CACHE): $(NODE_MODULES_TEST) tar --xz $(TAR_ARGS) -cf $@ node_modules @@ -161,10 +155,11 @@ rpm: $(TARFILE) $(NODE_CACHE) $(SPEC) # 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): export XZ_OPT=-0 $(VM_IMAGE): $(TARFILE) $(NODE_CACHE) bots test/vm.install - bots/image-customize --no-network --fresh \ + 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 @@ -184,9 +179,6 @@ prepare-check: $(NODE_MODULES_TEST) $(VM_IMAGE) test/common check: prepare-check test/common/run-tests ${RUN_TESTS_OPTIONS} -codecheck: test/common $(NODE_MODULES_TEST) - test/common/static-code - # checkout Cockpit's bots for standard test VM images and API to launch them bots: $(COCKPIT_REPO_STAMP) test/common/make-bots diff --git a/README.md b/README.md index 284459b..0987ec5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,33 @@ -# Cockpit Starter Kit +# Cockpit Session Recording -Scaffolding for a [Cockpit](https://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). -# Development dependencies +Demos & Talks: -On Debian/Ubuntu: - - sudo apt install gettext nodejs npm make - -On Fedora: - - sudo dnf install gettext nodejs npm make + * [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 +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 ``` @@ -39,7 +47,7 @@ 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 @@ -49,23 +57,23 @@ 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 + $ ./build.js -w or - make watch + $ 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 + $ 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 + $ RSYNC_DEVEL=example.com make watch To "uninstall" the locally installed version, run `make devel-uninstall`, or remove manually the symlink: @@ -75,17 +83,17 @@ remove manually the symlink: # Running eslint Cockpit Starter Kit uses [ESLint](https://eslint.org/) to automatically check -JavaScript/TypeScript code style in `.js[x]` and `.ts[x]` files. +JavaScript code style in `.js` and `.jsx` files. -eslint is executed as part of `test/static-code`, aka. `make codecheck`. +eslint is executed within every build. For developer convenience, the ESLint can be started explicitly by: - npm run eslint + $ npm run eslint Violations of some rules can be fixed automatically by: - npm run eslint:fix + $ npm run eslint:fix Rules configuration can be found in the `.eslintrc.json` file. @@ -94,22 +102,28 @@ Rules configuration can be found in the `.eslintrc.json` file. 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`. +styleint is executed within every build. For developer convenience, the Stylelint can be started explicitly by: - npm run stylelint + $ npm run stylelint Violations of some rules can be fixed automatically by: - npm run stylelint:fix + $ 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-9-stream 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 @@ -120,81 +134,12 @@ 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-9-stream 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-9-stream make prepare-check + TEST_OS=centos-8-stream make prepare-check You can also run the test against a different Cockpit image, for example: - TEST_OS=fedora-40 make check - -# Running tests in CI - -These tests can be run in [Cirrus CI](https://cirrus-ci.org/), on their free -[Linux Containers](https://cirrus-ci.org/guide/linux/) environment which -explicitly supports `/dev/kvm`. Please see [Quick -Start](https://cirrus-ci.org/guide/quick-start/) how to set up Cirrus CI for -your project after forking from starter-kit. - -The included [.cirrus.yml](./.cirrus.yml) runs the integration tests for two -operating systems (Fedora and CentOS 8). Note that if/once your project grows -bigger, or gets frequent changes, you may need to move to a paid account, or -different infrastructure with more capacity. - -Tests also run in [Packit](https://packit.dev/) for all currently supported -Fedora releases; see the [packit.yaml](./packit.yaml) control file. You need to -[enable Packit-as-a-service](https://packit.dev/docs/packit-service/) in your GitHub project to use this. -To run the tests in the exact same way for upstream pull requests and for -[Fedora package update gating](https://docs.fedoraproject.org/en-US/ci/), the -tests are wrapped in the [FMF metadata format](https://github.com/teemtee/fmf) -for using with the [tmt test management tool](https://docs.fedoraproject.org/en-US/ci/tmt/). -Note that Packit tests can *not* run their own virtual machine images, thus -they only run [@nondestructive tests](https://github.com/cockpit-project/cockpit/blob/main/test/common/testlib.py). - -# 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. 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.disabled) -[GitHub action](https://github.com/features/actions) workflow. This creates the -official release tarball and publishes as upstream release to GitHub. The -workflow is disabled by default -- to use it, edit the file as per the comment -at the top, and rename it to just `*.yml`. - -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). - -# Further reading - - * The [Starter Kit announcement](https://cockpit-project.org/blog/cockpit-starter-kit.html) - blog post explains the rationale for this project. - * [Cockpit Deployment and Developer documentation](https://cockpit-project.org/guide/latest/) - * [Make your project easily discoverable](https://cockpit-project.org/blog/making-a-cockpit-application.html) + TEST_OS=fedora-34 make check diff --git a/build.js b/build.js index 55a7a4f..8fca807 100755 --- a/build.js +++ b/build.js @@ -2,7 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import process from 'node:process'; import os from 'node:os'; import copy from 'esbuild-plugin-copy'; @@ -12,15 +11,19 @@ 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('-m', '--metafile', { help: "Enable bundle size information file", metavar: "FILE" }); +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) @@ -52,12 +55,15 @@ function notifyEndPlugin() { }; } +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 - if (ev !== "change" || fname.startsWith('.')) { + const isHidden = /^\./.test(fname); + if (ev !== "change" || isHidden) { return; } on_change(path.join(dir, fname)); @@ -83,13 +89,14 @@ const context = await esbuild.context({ 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" }, - metafile: !!args.metafile, 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({ @@ -107,10 +114,7 @@ const context = await esbuild.context({ }); try { - const result = await context.rebuild(); - if (args.metafile) { - fs.writeFileSync(args.metafile, JSON.stringify(result.metafile)); - } + await context.rebuild(); } catch (e) { if (!args.watch) process.exit(1); 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 4430d28..0000000 --- a/org.cockpit_project.starter_kit.metainfo.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - org.cockpit_project.starter_kit - CC0-1.0 - Starter Kit - Scaffolding for a cockpit module - -

- Scaffolding for a cockpit module. - - This is just a demo which does not do much. Please replace - this with a real description. -

-
- org.cockpit_project.cockpit - starter-kit - https://github.com/cockpit-project/starter-kit - https://github.com/cockpit-project/starter-kit/issues - cockpit-devel_AT_lists.fedorahosted.org - - Cockpit Project - -
diff --git a/package.json b/package.json index 1010855..06e460a 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "starter-kit", - "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": { @@ -12,48 +12,56 @@ "scripts": { "watch": "ESBUILD_WATCH='true' ./build.js", "build": "./build.js", - "eslint": "eslint src/", - "eslint:fix": "eslint --fix src/", + "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": { - "@types/react": "18.3.13", - "@types/react-dom": "18.3.1", - "@typescript-eslint/eslint-plugin": "8.38.0", - "argparse": "2.0.1", - "esbuild": "0.25.8", - "esbuild-plugin-copy": "2.1.1", - "esbuild-plugin-replace": "1.4.0", - "esbuild-sass-plugin": "3.3.1", - "esbuild-wasm": "0.25.8", - "eslint": "8.57.1", - "eslint-config-standard": "17.1.0", - "eslint-config-standard-jsx": "11.0.0", - "eslint-config-standard-react": "13.0.0", - "eslint-plugin-import": "2.32.0", - "eslint-plugin-node": "11.1.0", - "eslint-plugin-promise": "6.6.0", - "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "4.6.2", - "gettext-parser": "8.0.0", - "glob": "11.0.3", - "jed": "1.1.1", - "qunit": "2.24.1", - "sass": "1.79.6", - "stylelint": "16.22.0", - "stylelint-config-recommended-scss": "15.0.1", - "stylelint-config-standard": "38.0.0", - "stylelint-config-standard-scss": "15.0.1", - "stylelint-formatter-pretty": "4.0.1", - "typescript": "5.8.3" + "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", + "qunit": "^2.9.3", + "sass": "^1.61.0", + "sizzle": "^2.3.3", + "stylelint": "^15.10.1", + "stylelint-config-standard": "^34.0.0", + "stylelint-config-standard-scss": "^10.0.0", + "stylelint-formatter-pretty": "^3.2.0" }, "dependencies": { - "@patternfly/patternfly": "6.1.0", - "@patternfly/react-core": "6.1.0", - "@patternfly/react-icons": "6.1.0", - "@patternfly/react-styles": "6.3.0", - "react": "18.3.1", - "react-dom": "18.3.1" + "@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/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in deleted file mode 100644 index 8597b62..0000000 --- a/packaging/arch/PKGBUILD.in +++ /dev/null @@ -1,15 +0,0 @@ -pkgname=cockpit-starter-kit -pkgver=VERSION -pkgrel=1 -pkgdesc='Cockpit Starter Kit Example Module' -arch=('x86_64') -url='https://github.com/cockpit-project/starter-kit' -license=(LGPL) -source=("SOURCE") -sha256sums=('SKIP') - -package() { - depends=(cockpit) - cd $pkgname - make DESTDIR="$pkgdir" install PREFIX=/usr -} 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/packaging/cockpit-starter-kit.spec.in b/packaging/cockpit-starter-kit.spec.in deleted file mode 100644 index e9a650f..0000000 --- a/packaging/cockpit-starter-kit.spec.in +++ /dev/null @@ -1,64 +0,0 @@ -Name: cockpit-starter-kit -Version: %{VERSION} -Release: 1%{?dist} -Summary: Cockpit Starter Kit Example Module -License: LGPL-2.1-or-later - -Source0: https://github.com/cockpit-project/starter-kit/releases/download/%{version}/%{name}-%{version}.tar.xz -Source1: https://github.com/cockpit-project/starter-kit/releases/download/%{version}/%{name}-node-%{version}.tar.xz -BuildArch: noarch -%if ! 0%{?suse_version} -ExclusiveArch: %{nodejs_arches} noarch -%endif -%if ! 0%{?rhel} || 0%{?rhel} >= 10 -BuildRequires: nodejs >= 18 -%endif -BuildRequires: make -%if 0%{?suse_version} -# Suse's package has a different name -BuildRequires: appstream-glib -%else -BuildRequires: libappstream-glib -%endif -BuildRequires: gettext -%if 0%{?rhel} && 0%{?rhel} <= 8 -BuildRequires: libappstream-glib-devel -%endif - -Requires: cockpit-bridge - -%{NPM_PROVIDES} - -%description -Cockpit Starter Kit Example Module - -%prep -%autosetup -n %{name} -a 1 -# ignore pre-built bundle in release tarball and rebuild it -# but keep it in RHEL/CentOS-8/9, as that has a too old nodejs -%if ! 0%{?rhel} || 0%{?rhel} >= 10 -rm -rf dist -%endif - -%build -NODE_ENV=production make - -%install -%make_install PREFIX=/usr - -# drop source maps, they are large and just for debugging -find %{buildroot}%{_datadir}/cockpit/ -name '*.map' | xargs --no-run-if-empty rm --verbose - -%check -appstream-util validate-relax --nonet %{buildroot}/%{_datadir}/metainfo/* - -# this can't be meaningfully tested during package build; tests happen through -# FMF (see plans/all.fmf) during package gating - -%files -%doc README.md -%license LICENSE dist/index.js.LEGAL.txt -%{_datadir}/cockpit/* -%{_datadir}/metainfo/* - -%changelog diff --git a/packit.yaml b/packit.yaml index cb24629..30bf483 100644 --- a/packit.yaml +++ b/packit.yaml @@ -1,8 +1,14 @@ # 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-starter-kit.spec +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 @@ -12,27 +18,29 @@ srpm_build_deps: actions: post-upstream-clone: - - make cockpit-starter-kit.spec - # replace Source1 manually, as create-archive: can't handle multiple tarballs - - make node-cache - - sh -c 'sed -i "/^Source1:/ s/https:.*/$(ls *-node*.tar.xz)/" cockpit-*.spec' + - 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: tests - trigger: pull_request - targets: &test_targets - - fedora-all - - fedora-latest-aarch64 - - centos-stream-9 - - centos-stream-9-aarch64 - - centos-stream-10 - - job: copr_build trigger: pull_request - targets: *test_targets + 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 @@ -45,18 +53,18 @@ jobs: # - 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: propose_downstream + trigger: release + dist_git_branches: + - fedora-all - #- job: koji_build - # trigger: commit - # 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 + - job: bodhi_update + trigger: commit + dist_git_branches: + # rawhide updates are created automatically + - fedora-branched diff --git a/po/de.po b/po/de.po index 0394e91..ed65a63 100644 --- a/po/de.po +++ b/po/de.po @@ -14,26 +14,10 @@ msgstr "" "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:43 msgid "Running on $0" msgstr "Läuft auf $0" -#: org.cockpit-project.starter-kit.metainfo.xml:6 -msgid "Scaffolding for a cockpit module" -msgstr "Gerüst für ein Cockpit-Modul" - -#: org.cockpit-project.starter-kit.metainfo.xml:8 -msgid "Scaffolding for a cockpit module." -msgstr "Gerüst für ein Cockpit-Modul." - -#: src/manifest.json:0 org.cockpit-project.starter-kit.metainfo.xml:5 -msgid "Starter Kit" -msgstr "Bausatz" - #: src/app.jsx:29 msgid "Unknown" msgstr "Unbekannt" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 8963df6..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,69 +0,0 @@ -[tool.mypy] -follow_imports = 'silent' # https://github.com/python-lsp/pylsp-mypy/issues/81 -scripts_are_modules = true # allow checking all scripts in one invocation -explicit_package_bases = true -mypy_path = 'test/common:test:bots' -exclude = [ - "bots" -] - -[[tool.mypy.overrides]] -ignore_missing_imports = true -module = [ - # run without bots checked out - "machine.*", - "testvm", - - # run without gobject-introspection - "gi.*", -] - -[tool.ruff] -exclude = [ - ".git/", - "modules/", - "node_modules/", -] -line-length = 118 -src = [] - -[tool.ruff.lint] -select = [ - "A", # flake8-builtins - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "D300", # pydocstyle: Forbid ''' in docstrings - "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]*", -] diff --git a/src/app.jsx b/src/app.jsx new file mode 100644 index 0000000..35db035 --- /dev/null +++ b/src/app.jsx @@ -0,0 +1,41 @@ +/* + * 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 cockpit from 'cockpit'; +import React from 'react'; +import View from "./recordings.jsx"; + +const _ = cockpit.gettext; + +export class Application extends React.Component { + constructor() { + super(); + this.state = { hostname: _("Unknown") }; + + cockpit.file('/etc/hostname').watch(content => { + this.setState({ hostname: content.trim() }); + }); + } + + render() { + return ( + + ); + } +} diff --git a/src/app.scss b/src/app.scss index 6d2c5d8..3c2f0b0 100644 --- a/src/app.scss +++ b/src/app.scss @@ -3,3 +3,16 @@ 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/app.tsx b/src/app.tsx deleted file mode 100644 index 0d3b12f..0000000 --- a/src/app.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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, { useEffect, useState } from 'react'; -import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; - -import cockpit from 'cockpit'; - -const _ = cockpit.gettext; - -export const Application = () => { - const [hostname, setHostname] = useState(_("Unknown")); - - useEffect(() => { - const hostname = cockpit.file('/etc/hostname'); - hostname.watch(content => setHostname(content?.trim() ?? "")); - return hostname.close; - }, []); - - return ( - - Starter Kit - - - - - ); -}; 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 2b06bb4..57b6003 100644 --- a/src/index.html +++ b/src/index.html @@ -17,7 +17,7 @@ along with this package; If not, see . --> - Cockpit Starter Kit + Cockpit Session Recording diff --git a/src/index.tsx b/src/index.js similarity index 88% rename from src/index.tsx rename to src/index.js index c01c34c..ff4aa0d 100644 --- a/src/index.tsx +++ b/src/index.js @@ -17,16 +17,14 @@ * along with Cockpit; If not, see . */ +import "cockpit-dark-theme"; +import "patternfly/patternfly-5-cockpit.scss"; + import React from 'react'; import { createRoot } from 'react-dom/client'; - -import "cockpit-dark-theme"; - import { Application } from './app.jsx'; - -import "patternfly/patternfly-6-cockpit.scss"; import './app.scss'; document.addEventListener("DOMContentLoaded", () => { - createRoot(document.getElementById("app")!).render(); + createRoot(document.getElementById("app")).render(); }); diff --git a/src/manifest.json b/src/manifest.json index 3a45f56..b52d873 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,11 +1,26 @@ { + "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 ( +